Merge main into M13.5 long-tail polish

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 00:25:09 -04:00
co-authored by Claude Opus 5.5
177 changed files with 25734 additions and 510 deletions
+30 -18
View File
@@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::ConfigStore;
use crate::config::{ConfigStore, FileOps, StdFiles};
use crate::prefs::Prefs;
/// Folder created next to a project for its auto-saves.
@@ -68,16 +68,12 @@ pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) ->
/// Existing auto-saves of `stem` in `dir`: (slot, path, modified).
pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut v: Vec<_> = rd
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let n = slot_of(stem, &name)?;
let m = e.metadata().ok()?.modified().ok()?;
Some((n, e.path(), m))
})
.collect();
existing_in(&StdFiles, dir, stem)
}
/// [`existing`] through `fs`.
pub fn existing_in(fs: &dyn FileOps, dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
let mut v: Vec<_> = fs.list(dir).into_iter().filter_map(|(name, m)| Some((slot_of(stem, &name)?, dir.join(&name), m))).collect();
v.sort_by_key(|x| x.0);
v
}
@@ -95,18 +91,29 @@ pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Optio
/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path.
pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option<u32>, json: &str) -> std::io::Result<(PathBuf, u32)> {
write_in(&StdFiles, prefs, project, fallback, last_slot, json)
}
/// [`write`] through `fs`.
pub fn write_in(
fs: &dyn FileOps,
prefs: &Prefs,
project: Option<&str>,
fallback: Option<&Path>,
last_slot: Option<u32>,
json: &str,
) -> std::io::Result<(PathBuf, u32)> {
let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?;
std::fs::create_dir_all(&dir)?;
let stem = project_stem(project);
let max = prefs.auto_save.max_versions.max(1);
let have = existing(&dir, &stem);
let have = existing_in(fs, &dir, &stem);
let slot = next_slot(&have, last_slot, max);
let path = dir.join(file_name(&stem, slot));
crate::config::atomic_write(&path, json.as_bytes())?;
fs.write(&path, json.as_bytes())?;
// Max versions lowered: drop slots above it.
for (n, p, _) in have {
if n > max {
let _ = std::fs::remove_file(p);
let _ = fs.remove(&p);
}
}
Ok((path, slot))
@@ -114,8 +121,13 @@ pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last
/// The newest auto-save of a project, if any.
pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
latest_in(&StdFiles, prefs, project, fallback)
}
/// [`latest`] through `fs`.
pub fn latest_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
let dir = folder(prefs, project, fallback)?;
existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
existing_in(fs, &dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
}
/// What the crash-recovery sentinel records.
@@ -151,8 +163,8 @@ pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option<Recovery> {
let autosave = s
.autosave
.clone()
.filter(|p| Path::new(p).is_file())
.or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
.filter(|p| store.files().is_file(Path::new(p)))
.or_else(|| latest_in(store.files(), prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
Some(Recovery { project: s.project, autosave, dirty: s.dirty })
}
+655
View File
@@ -0,0 +1,655 @@
//! 3D Camera Tracker runtime: the analysis job (`camera.analyze`, `track.camera`), keeping
//! analyses valid, and turning a solve into composition layers (`camera.createFromSolve`).
//!
//! The job renders the layer's input to the effect (`Renderer::layer_input`) for every frame
//! between the layer's In and Out points on a background thread and tracks features through
//! them (step 1 of 2, "Analyzing in background"), then solves the camera (step 2, "Solving
//! 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.
//!
//! 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
//! scale in the comp), so the "3D Tracker Camera" reproduces the footage's view and created
//! layers sit on the selected points.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_effects::camera_tracker::{self as ct, AVERAGE_ERROR, METHOD_USED, SOLVE, SOLVE_KEY, TRACKS, TRACKS_KEY};
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::{AutoOrient, GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use effectcraft_track::camtrack::linalg::{self, Similarity, V3};
use effectcraft_track::camtrack::{AnalyzeOpts, CameraSolve, CameraTracks, SolveSettings, Target, TrackAnalyzer};
use serde::Serialize;
use crate::offload::JobKind;
use crate::tracking::lock;
use crate::warp::{analysis_times, signature};
use crate::{Event, Session};
/// Live analysis progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CameraProgress {
/// 1 = tracking features, 2 = solving the camera.
pub step: u8,
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub fps: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
/// Re-solving stored tracks (no tracking step).
pub solve_only: bool,
}
impl CameraProgress {
/// The viewer banner text, as After Effects words it.
pub fn banner(&self) -> String {
match self.step {
2 => "Solving camera".into(),
_ => {
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
format!("Analyzing in background (step 1 of 2): {pc}%")
}
}
}
}
struct CamResult {
/// New tracks (None for a re-solve).
tracks: Option<CameraTracks>,
solve: Result<CameraSolve, String>,
}
#[derive(Default)]
pub struct CameraShared {
pub cancel: AtomicBool,
pub state: Mutex<CameraProgress>,
result: Mutex<Option<CamResult>>,
}
/// A running (or finished, not yet polled) camera analysis.
pub struct CameraJob {
pub shared: Arc<CameraShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
/// The layer signature the tracks are made from, and the solve settings key.
key: String,
solve_key: String,
}
impl CameraJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> CameraProgress {
lock(&self.shared.state).clone()
}
}
struct CamWork {
project: Arc<Project>,
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
cache: Arc<LayerCache>,
comp: ItemId,
layer: LayerId,
effect_index: usize,
size: [f64; 2],
detailed: bool,
times: Vec<Tick>,
/// Stored tracks to re-solve (skips step 1).
tracks: Option<Arc<CameraTracks>>,
settings: SolveSettings,
}
pub(crate) fn is_tracker(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == ct::ID)
}
fn str_of(g: &PropGroup, m: &str) -> String {
match g.prop(m).map(|p| &p.value) {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
}
}
/// What a tracker's tracks are made from: the layer's frames and Detailed Analysis.
fn tracks_key(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> String {
let detailed = effectcraft_effects::warp_stab::param(&static_params(g), "advanced/detailedAnalysis").is_some_and(Value::as_bool);
format!("{}{}", signature(project, cid, l), if detailed { "+detailed" } else { "" })
}
/// Parameters of an instance from static values (the tracker's settings are not animated).
pub fn static_params(g: &PropGroup) -> effectcraft_effects::Params {
effectcraft_effects::flatten_params(g, &mut |p| p.value.clone())
}
fn clear(g: &mut PropGroup, all: bool) {
let mut ids = vec![SOLVE, SOLVE_KEY, METHOD_USED];
if all {
ids.extend([TRACKS, TRACKS_KEY]);
}
for m in ids {
if let Some(p) = g.prop_mut(m) {
p.value = Value::Str(String::new());
p.keys.clear();
}
}
if let Some(p) = g.prop_mut(AVERAGE_ERROR) {
p.value = Value::Scalar(0.0);
}
}
/// Clear analyses that no longer match, after an edit: tracks made from other frames (and their
/// solve), and solves made with other settings. Returns the trackers that need (re-)analysis:
/// cleared ones and newly applied, unanalysed ones.
pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
let mut todo = vec![];
let mut clears = vec![];
for (cid, it) in &after.items {
let ItemKind::Comp(c) = &it.kind else { continue };
for l in &c.layers {
let Some(fx) = l.effects() else { continue };
if !fx.groups().any(is_tracker) {
continue;
}
let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id));
for g in fx.groups().filter(|g| is_tracker(g)) {
let tracked = !str_of(g, TRACKS).is_empty();
if tracked && str_of(g, TRACKS_KEY) != tracks_key(after, *cid, l, g) {
clears.push((*cid, l.id, g.uid, true));
todo.push((*cid, l.id, g.uid));
} else if tracked && str_of(g, SOLVE_KEY) != ct::solve_key(&ct::settings(&static_params(g))) {
clears.push((*cid, l.id, g.uid, false));
todo.push((*cid, l.id, g.uid));
} else if !tracked && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() {
todo.push((*cid, l.id, g.uid));
}
}
}
}
for (cid, lid, uid, all) in clears {
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
clear(g, all);
}
}
todo
}
fn input_frame(r: &Renderer, ctx: &EvalCtx, layer: &Layer, effect_index: usize) -> Option<(Arc<effectcraft_raster::Image>, [f64; 2])> {
let buf = r.layer_input(ctx, layer, effect_index)?;
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32;
let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32;
let off = [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale];
return Some((Arc::new(effectcraft_raster::resample(&buf.img, w, h)), off));
}
Some((Arc::new(buf.img.clone()), buf.offset))
}
fn run_work(w: CamWork, shared: &CameraShared) {
let t0 = web_time::Instant::now();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
s.finished = true;
};
let cancelled = || {
let mut s = lock(&shared.state);
s.cancelled = true;
s.finished = true;
};
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
let lt0 = layer.layer_time(w.times[0]).seconds();
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
let (tracks, fresh) = match w.tracks.clone() {
Some(t) => (t, false),
None => {
let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() });
r.expr = w.expr.as_deref();
r.cache = Some(&w.cache);
let r = &r;
let ctx0 = EvalCtx::new(&w.project, w.comp, comp, Tick::ZERO);
let ctx0 = EvalCtx { expr: w.expr.as_deref(), ..ctx0 };
let frame_at = |t: Tick| input_frame(r, &ctx0.at(t), layer, w.effect_index);
{
let mut s = lock(&shared.state);
s.step = 1;
s.total = w.times.len() as u64;
}
let mut an = TrackAnalyzer::new(w.size, AnalyzeOpts { detailed: w.detailed });
let mut next = frame_at(w.times[0]);
for k in 0..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
return cancelled();
}
let Some(cur) = next.take() else { return fail(&format!("no frame at {:.3} s", w.times[k].seconds())) };
let ((), nf) = rayon::join(
|| an.push(&effectcraft_track::Frame { img: &cur.0, offset: cur.1 }),
|| if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None },
);
next = nf;
let mut s = lock(&shared.state);
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
crate::offload::report(s.done, s.total);
}
(Arc::new(an.finish(lt0, fd)), true)
}
};
lock(&shared.state).step = 2;
let solve = effectcraft_track::camtrack::solve(&tracks, &w.settings, Some(&shared.cancel)).map_err(|e| e.0);
if shared.cancel.load(Ordering::Relaxed) {
return cancelled();
}
*lock(&shared.result) = Some(CamResult { tracks: fresh.then(|| (*tracks).clone()), solve });
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
impl Session {
pub fn is_camera_analyzing(&self) -> bool {
self.camera_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Camera).is_some()
}
/// Progress of the running (or just finished) analysis.
pub fn camera_progress(&self) -> Option<CameraProgress> {
self.camera_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::Camera).map(|j| {
let p = &j.progress;
CameraProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: crate::offload::rate(p.done, p.elapsed), ..Default::default() }
})
})
}
/// The tracker being analysed: (comp, layer, effect uid).
pub fn camera_target(&self) -> Option<(ItemId, LayerId, Uid)> {
self.camera_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Camera).map(|j| (j.comp, j.layer, j.uid)))
}
/// Cancel the running analysis (nothing is written).
pub fn stop_camera(&mut self) -> bool {
if self.cancel_offloaded(JobKind::Camera) {
return true;
}
match &self.camera_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
/// Start analysing 3D Camera Tracker `effect` on `layer` (re-solving the stored tracks when
/// they are still valid). `wait`: block until done (wasm32 always does). Returns the number
/// of frames.
pub fn start_camera(&mut self, comp: ItemId, layer: LayerId, effect: Uid, wait: bool) -> Result<usize, String> {
if self.is_camera_analyzing() {
return Err("a 3D Camera Tracker analysis is already running".into());
}
self.poll_camera_job();
self.camera_pending.retain(|x| *x != (comp, layer, effect));
let c = self.project.comp(comp).ok_or("no composition")?;
let l = c.layer(layer).ok_or("no layer")?;
let fx = l.effects().ok_or("the layer has no effects")?;
let index = fx.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
let g = fx.groups().nth(index).filter(|g| is_tracker(g)).ok_or("not a 3D Camera Tracker")?;
if l.switches.adjustment {
return Err("3D Camera Tracker needs footage, not an adjustment layer".into());
}
let times = analysis_times(c, l);
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
let (w, h) = effectcraft_render::source_size(&self.project, l);
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
let params = static_params(g);
let key = tracks_key(&self.project, comp, l, g);
let settings = ct::settings(&params);
let solve_key = ct::solve_key(&settings);
let tracks = ct::tracks(&params).filter(|_| str_of(g, TRACKS_KEY) == key);
let n = times.len();
let work = CamWork {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
cache: self.layer_cache.clone(),
comp,
layer,
effect_index: index,
size,
detailed: params.values.iter().any(|(k, v)| k.ends_with("detailedAnalysis") && v.as_bool()),
times,
tracks: tracks.clone(),
settings,
};
if !wait && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::Camera { comp, layer, effect })?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(CameraShared::default());
{
let mut st = lock(&shared.state);
st.solve_only = tracks.is_some();
st.step = if tracks.is_some() { 2 } else { 1 };
st.total = n as u64;
}
let thread = if wait {
run_work(work, &shared);
None
} else {
let sh = shared.clone();
Some(std::thread::Builder::new().name("camera-track".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
};
self.camera_job = Some(CameraJob { shared, thread, comp, layer, effect, key, solve_key });
self.events.push(Event::ProjectChanged { revision: self.revision });
self.poll_camera_job();
Ok(n)
}
/// Write a finished analysis into its effect (one undo step) and drop the job.
fn poll_camera_job(&mut self) -> bool {
let Some(job) = &self.camera_job else { return false };
if !job.is_finished() {
return false;
}
let mut job = self.camera_job.take().expect("job");
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
let result = lock(&job.shared.result).take();
match (result, &st.error) {
(Some(res), None) if !st.cancelled => {
let (cid, lid, uid) = (job.comp, job.layer, job.effect);
let (key, skey) = (job.key.clone(), job.solve_key.clone());
let tracks_json = res.tracks.as_ref().map(CameraTracks::to_json);
let (solve_json, method, avg, msg, failed) = match &res.solve {
Ok(s) => (
s.to_json(),
s.method_used.label().to_string(),
s.average_error,
format!(
"3D Camera Tracker: solved {} frame(s), {} points, average error {:.2} px ({:.1} s)",
s.frames.len(),
s.points.len(),
s.average_error,
st.elapsed
),
false,
),
Err(e) => (String::new(), String::new(), 0.0, format!("3D Camera Tracker: {e}"), true),
};
let r = self.edit(if st.solve_only { "3D Camera Tracker Solve" } else { "3D Camera Tracker Analysis" }, None, |p, _| {
let g = p
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the 3D Camera Tracker was removed during analysis".into()))?;
let mut set = |m: &str, v: Value| {
if let Some(pr) = g.prop_mut(m) {
pr.value = v;
}
};
if let Some(t) = &tracks_json {
set(TRACKS, Value::Str(t.clone()));
set(TRACKS_KEY, Value::Str(key.clone()));
}
set(SOLVE, Value::Str(solve_json.clone()));
set(SOLVE_KEY, Value::Str(skey.clone()));
set(METHOD_USED, Value::Str(method.clone()));
set(AVERAGE_ERROR, Value::Scalar(avg));
Ok(())
});
let (msg, failed) = match r {
Ok(()) => (msg, failed),
Err(e) => (format!("3D Camera Tracker: {e}"), true),
};
self.events.push(Event::Toast { message: msg, error: failed });
}
_ => {
let msg = match &st.error {
Some(e) => format!("3D Camera Tracker analysis failed: {e}"),
None => "3D Camera Tracker analysis cancelled".into(),
};
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
self.bump();
}
}
true
}
/// Frontends call this every frame: finishes analyses and (with `auto`) starts queued
/// (re-)analyses in the background, like After Effects.
pub fn poll_camera(&mut self, auto: bool) -> bool {
let changed = self.poll_camera_job();
if auto && self.camera_job.is_none() && self.offloaded(JobKind::Camera).is_none() {
while let Some((c, l, e)) = self.camera_pending.first().copied() {
self.camera_pending.remove(0);
if self.start_camera(c, l, e, false).is_ok() {
return true;
}
}
}
changed
}
}
// ---------------------------------------------------------------- solve → composition
/// A tracker's solve placed in its composition.
pub struct Placed {
pub solve: Arc<CameraSolve>,
/// Canonical → comp world.
pub world: Similarity,
/// Comp pixels per layer pixel.
pub scale: f64,
/// Comp times of the analysed frames.
pub times: Vec<Tick>,
pub comp_size: [f64; 2],
}
impl Placed {
/// Comp-world position of a canonical point.
pub fn to_world(&self, x: V3) -> V3 {
self.world.apply(x)
}
/// Canonical position of a comp-world point.
pub fn from_world(&self, x: V3) -> V3 {
let r = &self.world;
linalg::scale(linalg::mtv(&r.r, linalg::sub(x, r.t)), 1.0 / r.s)
}
/// The 3D Tracker Camera on frame `k`: (position, orientation degrees, zoom).
pub fn camera(&self, k: usize) -> (V3, V3, f64) {
let c = &self.solve.frames[k];
let m = linalg::mm(&self.world.r, &linalg::mt(&c.r()));
(self.to_world(c.center), linalg::euler_xyz(&m), c.focal * self.scale)
}
/// Analysed frame at comp time `t`.
pub fn frame_at(&self, t: Tick) -> usize {
self.times.iter().enumerate().min_by_key(|(_, x)| (x.0 - t.0).abs()).map(|(i, _)| i).unwrap_or(0).min(self.solve.frames.len().saturating_sub(1))
}
}
/// Comp pixels per layer pixel of `layer` (its 2D transform at its In point).
fn layer_scale(project: &Project, cid: ItemId, layer: &Layer) -> f64 {
let Some(comp) = project.comp(cid) else { return 1.0 };
let ctx = EvalCtx::new(project, cid, comp, layer.in_point.max(Tick::ZERO));
let m = ctx.world_matrix(layer).0;
let s = m[0][0].hypot(m[1][0]);
if s.is_finite() && s > 1e-9 { s } else { 1.0 }
}
/// The solve of tracker `uid` on `lid`, placed in the comp.
pub fn placed(project: &Project, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Placed> {
let comp = project.comp(cid)?;
let layer = comp.layer(lid)?;
let g = layer.props.find_group(uid).filter(|g| is_tracker(g))?;
let solve = ct::solve(&static_params(g))?;
let scale = layer_scale(project, cid, layer);
let k0 = solve.first_solved()?;
let comp_size = [comp.width as f64, comp.height as f64];
let world = solve.world(comp_size, solve.frames[k0].focal * scale);
let mut times = analysis_times(comp, layer);
times.truncate(solve.frames.len());
Some(Placed { solve, world, scale, times, comp_size })
}
/// What to create from a target (`camera.createFromSolve`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CreateKind {
Text,
Solid,
Null,
ShadowCatcher,
/// Only the camera.
Camera,
}
impl CreateKind {
pub fn from_name(s: &str) -> Option<CreateKind> {
match s.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"text" => Some(CreateKind::Text),
"solid" => Some(CreateKind::Solid),
"null" => Some(CreateKind::Null),
"shadowcatcher" | "shadow" => Some(CreateKind::ShadowCatcher),
"camera" => Some(CreateKind::Camera),
_ => None,
}
}
}
/// Rotation (layer → world) of a layer lying on a plane with unit `normal` (towards the camera),
/// its x axis along `right` projected on the plane: the layer's front (−z) faces the camera.
pub fn plane_orientation(normal: V3, right: V3) -> V3 {
let z = linalg::scale(normal, -1.0);
let mut x = linalg::sub(right, linalg::scale(z, linalg::dot(right, z)));
if linalg::norm(x) < 1e-9 {
x = if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
x = linalg::sub(x, linalg::scale(z, linalg::dot(x, z)));
}
let x = linalg::normalize(x);
let y = linalg::cross(z, x);
let m = linalg::from_cols(x, y, z);
norm_angles(linalg::euler_xyz(&m))
}
fn norm_angles(a: V3) -> V3 {
a.map(|v| {
let r = v.rem_euclid(360.0);
if (r - 360.0).abs() < 1e-9 { 0.0 } else { r }
})
}
/// Key `path` on `l` at every `(time, value)`.
pub(crate) fn key_all(l: &mut Layer, path: &str, keys: &[(Tick, Value)]) {
if let Some(pr) = l.props.prop_mut(path) {
pr.keys = keys.iter().map(|(t, v)| Keyframe::new(*t, v.clone())).collect();
if let Some((_, v)) = keys.first() {
pr.value = v.clone();
}
}
}
/// Write the solved camera path into camera layer `l` (one-node, keyed on every analysed frame;
/// Zoom keyed for Variable Zoom).
pub(crate) fn write_camera(l: &mut Layer, pl: &Placed) {
l.auto_orient = AutoOrient::Off;
let n = pl.times.len().min(pl.solve.frames.len());
let mut pos = Vec::with_capacity(n);
let mut ori = Vec::with_capacity(n);
let mut zoom = Vec::with_capacity(n);
let mut prev: Option<V3> = None;
for k in 0..n {
let (p, o, z) = pl.camera(k);
// Unwrap the angles so per-frame keys interpolate the short way.
let o = match prev {
None => norm_angles(o),
Some(q) => [0, 1, 2].map(|i| q[i] + (o[i] - q[i] + 180.0).rem_euclid(360.0) - 180.0),
};
prev = Some(o);
let t = l.layer_time(pl.times[k]);
pos.push((t, Value::Vec3(p)));
ori.push((t, Value::Vec3(o)));
zoom.push((t, Value::Scalar(z)));
}
key_all(l, "transform/position", &pos);
key_all(l, "transform/orientation", &ori);
let variable = pl.solve.frames.iter().any(|f| (f.focal - pl.solve.frames[0].focal).abs() > 1e-6 * f.focal);
if variable {
key_all(l, "cameraOptions/zoom", &zoom);
} else if let Some((_, z)) = zoom.first()
&& let Some(pr) = l.props.prop_mut("cameraOptions/zoom")
{
pr.keys.clear();
pr.value = z.clone();
}
if let Some((_, z)) = zoom.first()
&& let Some(pr) = l.props.prop_mut("cameraOptions/focusDistance")
{
pr.value = z.clone();
}
// A one-node camera has no Point of Interest.
if let Some(tr) = l.props.sub_mut("transform") {
tr.children.retain(|c| !matches!(c, effectcraft_project::Node::Prop(p) if p.match_id == "poi"));
}
for rot in ["transform/rotationX", "transform/rotationY", "transform/rotation"] {
if let Some(pr) = l.props.prop_mut(rot) {
pr.keys.clear();
pr.value = Value::Scalar(0.0);
}
}
}
/// The target for `ids` on frame `k`: when fewer than three points are given, the nearest
/// solved neighbours (in 3D) complete the plane.
pub fn target_for(solve: &CameraSolve, ids: &[u32], k: usize) -> Option<Target> {
let mut use_ids: Vec<u32> = ids.iter().copied().filter(|i| solve.point(*i).is_some()).collect();
if use_ids.is_empty() {
return None;
}
if use_ids.len() < 3 {
let c = solve.point(use_ids[0])?.pos;
let mut others: Vec<(f64, u32)> = solve.visible(k).filter(|p| !use_ids.contains(&p.id)).map(|p| (linalg::norm(linalg::sub(p.pos, c)), p.id)).collect();
others.sort_by(|a, b| a.0.total_cmp(&b.0));
for (_, id) in others.into_iter().take(6usize.saturating_sub(use_ids.len())) {
use_ids.push(id);
}
let mut t = solve.target(&use_ids, k)?;
// The target sits on the chosen point(s).
let pts: Vec<V3> = ids.iter().filter_map(|i| solve.point(*i)).map(|p| p.pos).collect();
t.center = linalg::scale(pts.iter().fold([0.0; 3], |a, b| linalg::add(a, *b)), 1.0 / pts.len() as f64);
return Some(t);
}
solve.target(&use_ids, k)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plane_orientation_faces_the_normal() {
let n = linalg::normalize([0.2, -0.9, -0.3]);
let o = plane_orientation(n, [1.0, 0.0, 0.0]);
let m = linalg::from_euler_xyz(o);
// Layer −z (its front) is the normal.
let front = linalg::mv(&m, [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(front, n)) < 1e-9);
}
}
+574
View File
@@ -0,0 +1,574 @@
//! 3D Camera Tracker commands: Animation ▸ Track Camera (`track.camera`), the effect's Analyze /
//! Cancel / Create Camera buttons, the viewer's track point selection and right-click menu
//! (Set Ground Plane and Origin, Create Text / Solid / Null / Shadow Catcher … and Camera,
//! Create Multiple …, Delete Selected Points) and the status / points queries.
use effectcraft_color::Label;
use effectcraft_effects::camera_tracker::{self as ct, CAMERA, DELETED, SOLVE};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build;
use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project, Solid, Uid};
use effectcraft_track::camtrack::linalg::{self, V3};
use effectcraft_track::camtrack::{ShotType, SolveMethod, Target, point_color};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, layer_p, str_p};
use crate::camera_track::{CreateKind, Placed, is_tracker, placed, plane_orientation, static_params, target_for, write_camera};
use crate::{EngineError, Result, Session, cmd, query};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"track.camera",
"Track Camera",
["Animation"],
None,
"{layer?, shotType?: fixed|variable|specify, aov?: degrees, solveMethod?: auto|typical|flat|tripod, detailed?, wait?}",
can_track,
track_camera
),
cmd!("camera.analyze", "Analyze", [], None, "{layer?, effect?: uid|name|index, wait?: block until done}", has_comp, analyze),
cmd!("camera.cancel", "Cancel", [], None, "{}", is_analyzing, |s, _| Ok(json!({"stopped": s.stop_camera()}))),
query!("camera.solveStatus", "3D Camera Tracker Status", "{layer?, effect?}", status),
query!("camera.points", "3D Camera Tracker Points", "{layer?, effect?, time?: comp seconds} → visible solved points", points),
cmd!("camera.selectPoints", "Select Track Points", [], None, "{points: [id], add?, toggle?}", has_comp, select_points),
cmd!(
"camera.createFromSolve",
"Create from Camera Solve",
[],
None,
"{kind: text|solid|null|shadowCatcher|camera, points?: [id] (default: selected), target?: {center, normal, size?} (comp world), multiple?, layer?, effect?}",
has_comp,
create_from_solve
),
cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))),
cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground),
cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points),
]
}
fn with_kind(p: &Value, kind: &str) -> Value {
let mut q = p.clone();
if !q.is_object() {
q = json!({});
}
q["kind"] = json!(kind);
q
}
fn can_track(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
let c = s.active_comp().ok_or("no composition is open")?;
match s.state.selected_layers.first().and_then(|l| c.layer(*l)) {
Some(l) if l.has_video() && !l.switches.adjustment && !l.is_camera() && !l.is_light() => Ok(()),
Some(_) => Err("Track Camera needs a footage or composition layer".into()),
None => Err("select a layer to track".into()),
}
}
fn is_analyzing(s: &Session) -> std::result::Result<(), String> {
if s.is_camera_analyzing() { Ok(()) } else { Err("no 3D Camera Tracker analysis is running".into()) }
}
/// (comp, layer, effect uid) of a 3D Camera Tracker: `layer` / `effect` (uid, name or 1-based
/// index), else a selected one, else the first in the comp.
fn ct_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let candidates: Vec<LayerId> = match p.get("layer") {
Some(v) => vec![super::resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?],
None => {
let mut v: Vec<LayerId> = s.state.selected_props.iter().map(|(l, _)| *l).collect();
v.extend(s.state.selected_layers.iter().copied());
v.extend(comp.layers.iter().map(|l| l.id));
v
}
};
for lid in candidates {
let Some(fx) = comp.layer(lid).and_then(Layer::effects) else { continue };
let g = match p.get("effect") {
Some(Value::Number(n)) => {
let n = n.as_u64().unwrap_or(0);
fx.groups().find(|g| g.uid == n).or_else(|| fx.groups().nth((n as usize).saturating_sub(1)))
}
Some(Value::String(name)) => fx.groups().find(|g| &g.name == name),
_ => s
.state
.selected_props
.iter()
.find_map(|(l, u)| (*l == lid).then(|| fx.groups().find(|g| g.uid == *u && is_tracker(g))).flatten())
.or_else(|| fx.groups().find(|g| is_tracker(g))),
};
if let Some(g) = g.filter(|g| is_tracker(g)) {
return Ok((cid, lid, g.uid));
}
if p.get("layer").is_some() {
break;
}
}
Err(bad(cmd, "no 3D Camera Tracker found (apply Animation ▸ Track Camera first)"))
}
fn shot_p(p: &Value) -> Result<Option<ShotType>> {
match p.get("shotType") {
None => Ok(None),
Some(Value::Number(n)) => ShotType::ALL.get(n.as_u64().unwrap_or(99) as usize).copied().map(Some).ok_or_else(|| bad("track.camera", "shotType: 0..2")),
Some(Value::String(v)) => ShotType::from_name(v).map(Some).ok_or_else(|| bad("track.camera", "shotType: fixed|variable|specify")),
Some(_) => Err(bad("track.camera", "shotType: fixed|variable|specify")),
}
}
fn method_p(p: &Value) -> Result<Option<SolveMethod>> {
match p.get("solveMethod") {
None => Ok(None),
Some(Value::Number(n)) => {
SolveMethod::ALL.get(n.as_u64().unwrap_or(99) as usize).copied().map(Some).ok_or_else(|| bad("track.camera", "solveMethod: 0..3"))
}
Some(Value::String(v)) => SolveMethod::from_name(v).map(Some).ok_or_else(|| bad("track.camera", "solveMethod: auto|typical|flat|tripod")),
Some(_) => Err(bad("track.camera", "solveMethod: auto|typical|flat|tripod")),
}
}
/// Animation ▸ Track Camera / the Tracker panel button: apply the 3D Camera Tracker to the layer
/// (reusing one it already has), set the given options and start the analysis.
fn track_camera(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "track.camera")?;
let shot = shot_p(p)?;
let method = method_p(p)?;
let aov = f_p(p, "aov").or_else(|| f_p(p, "angleOfView"));
if aov.is_some_and(|a| !(1.0..=170.0).contains(&a)) {
return Err(bad("track.camera", "aov: 1..170 degrees"));
}
let detailed = b_p(p, "detailed");
let existing = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(Layer::effects).and_then(|fx| fx.groups().find(|g| is_tracker(g)).map(|g| g.uid));
let uid = match existing {
Some(u) => u,
None => {
let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0], "comp": cid.0}))?;
r["effects"].get(0).and_then(Value::as_u64).ok_or_else(|| EngineError::Other("the effect could not be applied".into()))?
}
};
if shot.is_some() || method.is_some() || aov.is_some() || detailed.is_some() {
s.edit("3D Camera Tracker Settings", None, |proj, _| {
let g = proj
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
let mut set = |m: &str, v: KV| {
if let Some(pr) = g.prop_mut(m) {
pr.keys.clear();
pr.value = v;
}
};
if let Some(sh) = shot {
set("shotType", KV::Enum(ShotType::ALL.iter().position(|x| *x == sh).unwrap_or(0) as u32));
}
if let Some(a) = aov {
set("horizontalAngleOfView", KV::Scalar(a));
if shot.is_none() {
set("shotType", KV::Enum(2));
}
}
if let Some(m) = method {
set("advanced/solveMethod", KV::Enum(SolveMethod::ALL.iter().position(|x| *x == m).unwrap_or(0) as u32));
}
if let Some(d) = detailed {
set("advanced/detailedAnalysis", KV::Bool(d));
}
Ok(())
})?;
}
// Select the effect so the viewer shows its track points.
s.state.selected_props = vec![(lid, uid)];
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_camera_analyzing(), "progress": s.camera_progress()});
if wait {
out["status"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
}
Ok(out)
}
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = ct_p(s, p, "camera.analyze")?;
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_camera_analyzing(), "progress": s.camera_progress()});
if wait {
out["status"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
}
Ok(out)
}
fn status(s: &mut Session, p: &Value) -> Result<Value> {
s.poll_camera(false);
let mut out = json!({"running": s.is_camera_analyzing(), "progress": s.camera_progress(), "banner": s.camera_progress().map(|p| p.banner())});
let Ok((cid, lid, uid)) = ct_p(s, p, "camera.solveStatus") else { return Ok(out) };
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let g = layer.props.find_group(uid).ok_or(EngineError::NoComp)?;
let params = static_params(g);
out["layer"] = json!(lid.0);
out["effect"] = json!(uid);
out["pending"] = json!(s.camera_pending.contains(&(cid, lid, uid)));
let tracks = ct::tracks(&params);
out["analyzed"] = json!(tracks.is_some());
if let Some(t) = &tracks {
out["tracks"] = json!(t.tracks.len());
out["frames"] = json!(t.frames);
}
let settings = ct::settings(&params);
out["shotType"] = json!(settings.shot.label());
out["solveMethod"] = json!(settings.method.label());
out["deleted"] = json!(settings.deleted.len());
let solve = ct::solve(&params);
out["solved"] = json!(solve.is_some());
if let Some(sv) = &solve {
let f = sv.focal();
out["methodUsed"] = json!(sv.method_used.label());
out["averageError"] = json!(sv.average_error);
out["points"] = json!(sv.points.len());
out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count());
out["focalLength"] = json!(f);
out["horizontalAngleOfView"] = json!(sv.hfov(f));
out["groundPlane"] = json!(sv.ground.is_some());
if let Some(pl) = placed(&s.project, cid, lid, uid) {
let k = pl.frame_at(s.time_of(cid));
let (pos, ori, zoom) = pl.camera(k);
out["frame"] = json!(k);
out["camera"] = json!({"position": pos, "orientation": ori, "zoom": zoom});
out["visible"] = json!(sv.visible(k).count());
}
}
let cam = params.get(CAMERA).map(|v| v.as_f64() as u64).unwrap_or(0);
out["cameraLayer"] = if cam > 0 && comp.layer(LayerId(cam)).is_some_and(Layer::is_camera) { json!(cam) } else { Value::Null };
out["selected"] = json!(s.state.camera_points);
Ok(out)
}
/// The visible solved points at the current (or given) time, with their comp positions.
fn points(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = ct_p(s, p, "camera.points")?;
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad("camera.points", "the camera is not solved yet"))?;
let t = f_p(p, "time").map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let k = pl.frame_at(t);
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
let m = ctx.world_matrix(layer);
let list: Vec<Value> = ct::projected(&pl.solve, k)
.into_iter()
.map(|(id, q, z)| {
let c = m.apply(effectcraft_geom::vec3(q[0], q[1], 0.0));
let sp = pl.solve.point(id).copied().unwrap_or_default();
let col = point_color(id);
json!({"id": id, "comp": [c.x, c.y], "layer": q, "depth": z, "world": pl.to_world(sp.pos), "error": sp.error, "color": col, "selected": s.state.camera_points.contains(&id)})
})
.collect();
Ok(json!({"frame": k, "points": list}))
}
fn ids_p(s: &Session, p: &Value) -> Vec<u32> {
match p.get("points") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(|v| v as u32).collect(),
Some(Value::Number(n)) => n.as_u64().map(|v| vec![v as u32]).unwrap_or_default(),
_ => s.state.camera_points.clone(),
}
}
fn select_points(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<u32> = match p.get("points") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(|v| v as u32).collect(),
_ => vec![],
};
if b_p(p, "toggle").unwrap_or(false) {
for id in ids {
match s.state.camera_points.iter().position(|x| *x == id) {
Some(i) => {
s.state.camera_points.remove(i);
}
None => s.state.camera_points.push(id),
}
}
} else if b_p(p, "add").unwrap_or(false) {
for id in ids {
if !s.state.camera_points.contains(&id) {
s.state.camera_points.push(id);
}
}
} else {
s.state.camera_points = ids;
}
s.bump();
Ok(json!({"selected": s.state.camera_points}))
}
/// The target of a create / ground command in canonical solve coordinates.
fn target_p(s: &Session, p: &Value, pl: &Placed, k: usize, cmd: &str) -> Result<Target> {
if let Some(t) = p.get("target") {
let v3 = |k: &str| -> Option<V3> {
let a = t.get(k)?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
};
let (c, n) = (v3("center").ok_or_else(|| bad(cmd, "target.center: [x, y, z]"))?, v3("normal").unwrap_or([0.0, -1.0, 0.0]));
let size = t.get("size").and_then(Value::as_f64).unwrap_or(100.0);
let nc = linalg::normalize(linalg::mtv(&pl.world.r, n));
return Ok(Target { center: pl.from_world(c), normal: nc, size: size / pl.world.s });
}
let ids = ids_p(s, p);
if ids.is_empty() {
return Err(bad(cmd, "select track points (or give `points` / `target`)"));
}
target_for(&pl.solve, &ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))
}
fn next_name(c: &effectcraft_project::Comp, base: &str) -> String {
let n = (1..).find(|i| !c.layers.iter().any(|l| l.name == format!("{base} {i}"))).unwrap_or(1);
format!("{base} {n}")
}
fn place(l: &mut Layer, pos: V3, ori: V3, src: &Layer) {
l.switches.three_d = true;
l.in_point = src.in_point;
l.out_point = src.out_point;
if let Some(pr) = l.props.prop_mut("transform/position") {
pr.value = KV::Vec3(pos);
}
if let Some(pr) = l.props.prop_mut("transform/orientation") {
pr.value = KV::Vec3(ori);
}
}
/// Insert `l` above the tracked layer.
fn insert_above(proj: &mut Project, cid: ItemId, above: LayerId, l: Layer) -> Result<LayerId> {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = comp.layers.iter().position(|x| x.id == above).unwrap_or(0);
let id = l.id;
comp.layers.insert(at, l);
Ok(id)
}
fn add_solid(proj: &mut Project, comp: &effectcraft_project::Comp, name: &str, size: u32, color: [f32; 3]) -> Layer {
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
let sid = proj.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: size, height: size, pixel_aspect: 1.0 }));
build::layer(proj, comp, name, LayerSource::Solid { item: sid }, (size, size), None)
}
/// Create Text / Solid / Null / Shadow Catcher (and Light) on the target, plus the 3D Tracker
/// Camera when the tracker has none yet — one undo step.
fn create_from_solve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.createFromSolve";
let (cid, lid, uid) = ct_p(s, p, cmd)?;
let kind = CreateKind::from_name(str_p(p, "kind").unwrap_or("null")).ok_or_else(|| bad(cmd, "kind: text|solid|null|shadowCatcher|camera"))?;
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad(cmd, "the camera is not solved yet (analysis still running or failed)"))?;
let k = pl.frame_at(s.time_of(cid));
let multiple = b_p(p, "multiple").unwrap_or(false);
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let src = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
let g = src.props.find_group(uid).ok_or(EngineError::NoComp)?;
let params = static_params(g);
let size_pct = effectcraft_effects::warp_stab::param(&params, "targetSize").map(|v| v.as_f64()).unwrap_or(100.0) / 100.0;
let existing_cam = params.get(CAMERA).map(|v| v.as_f64() as u64).filter(|id| *id > 0 && comp.layer(LayerId(*id)).is_some_and(Layer::is_camera));
// Targets: one, or one per selected point (Create Multiple …) sharing the selection's plane.
let targets: Vec<Target> = if kind == CreateKind::Camera {
vec![]
} else {
let t = target_p(s, p, &pl, k, cmd)?;
if multiple {
let ids = ids_p(s, p);
ids.iter().filter_map(|i| pl.solve.point(*i)).map(|sp| Target { center: sp.pos, ..t }).collect()
} else {
vec![t]
}
};
// Camera right vector on this frame (world), to align created layers with the view.
let cam = &pl.solve.frames[k];
let right = linalg::mv(&pl.world.r, linalg::mtv(&cam.r(), [1.0, 0.0, 0.0]));
let label = match (kind, multiple) {
(CreateKind::Text, false) => "Create Text and Camera",
(CreateKind::Solid, false) => "Create Solid and Camera",
(CreateKind::Null, false) => "Create Null and Camera",
(CreateKind::ShadowCatcher, _) => "Create Shadow Catcher, Camera and Light",
(CreateKind::Text, true) => "Create Multiple Text Layers and Camera",
(CreateKind::Solid, true) => "Create Multiple Solids and Camera",
(CreateKind::Null, true) => "Create Multiple Nulls and Camera",
(CreateKind::Camera, _) => "Create Camera",
};
let (camera, created) = s.edit(label, None, |proj, st| {
let mut created = vec![];
// The camera: reuse the tracker's, else create "3D Tracker Camera" at the top.
let camera = match existing_cam {
Some(id) if kind != CreateKind::Camera => LayerId(id),
Some(id) => {
let l = proj.comp_mut(cid).and_then(|c| c.layer_mut(LayerId(id))).ok_or(EngineError::NoComp)?;
write_camera(l, &pl);
LayerId(id)
}
None => {
let mut l = build::layer(proj, &comp, "3D Tracker Camera", LayerSource::Camera, (comp.width, comp.height), None);
l.in_point = src.in_point;
l.out_point = src.out_point;
write_camera(&mut l, &pl);
let id = l.id;
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(0, l);
if let Some(pr) = proj.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)).and_then(|g| g.prop_mut(CAMERA)) {
pr.value = KV::Scalar(id.0 as f64);
}
id
}
};
for t in &targets {
let center = pl.to_world(t.center);
let normal = linalg::normalize(linalg::mv(&pl.world.r, t.normal));
let ori = plane_orientation(normal, right);
let size = (t.size * pl.world.s * size_pct).max(1.0);
let cur = proj.comp(cid).ok_or(EngineError::NoComp)?.clone();
match kind {
CreateKind::Text => {
let mut l = build::layer(proj, &cur, "Text", LayerSource::Text, (comp.width, comp.height), None);
let doc = TextDoc { text: "Text".into(), justify: Justify::Center, size: (size * 0.35).clamp(4.0, 2000.0), ..Default::default() };
if let Some(pr) = l.props.prop_mut("text/sourceText") {
pr.value = KV::Text(Box::new(doc));
}
place(&mut l, center, ori, &src);
created.push(insert_above(proj, cid, lid, l)?);
}
CreateKind::Solid => {
let name = next_name(&cur, "Track Solid");
let c = point_color((created.len() as u32).wrapping_add(7));
let mut l = add_solid(proj, &cur, &name, size.round().max(1.0) as u32, c);
place(&mut l, center, ori, &src);
created.push(insert_above(proj, cid, lid, l)?);
}
CreateKind::Null => {
let name = next_name(&cur, "Track Null");
let mut l = build::layer(proj, &cur, &name, LayerSource::Null, (comp.width, comp.height), None);
place(&mut l, center, ori, &src);
if let Some(pr) = l.props.prop_mut("transform/scale") {
pr.value = KV::Vec3([size, size, size]);
}
created.push(insert_above(proj, cid, lid, l)?);
}
CreateKind::ShadowCatcher => {
let name = next_name(&cur, "Shadow Catcher");
let mut l = add_solid(proj, &cur, &name, (size * 4.0).round().max(1.0) as u32, [1.0, 1.0, 1.0]);
place(&mut l, center, ori, &src);
if let Some(pr) = l.props.prop_mut("materialOptions/acceptsShadows") {
pr.value = KV::Enum(2);
}
if let Some(pr) = l.props.prop_mut("materialOptions/castsShadows") {
pr.value = KV::Enum(0);
}
let sc = created.len();
created.push(insert_above(proj, cid, lid, l)?);
let cur = proj.comp(cid).ok_or(EngineError::NoComp)?.clone();
let lname = next_name(&cur, "Light");
let mut light = build::layer(proj, &cur, &lname, LayerSource::Light { kind: LightKind::Point }, (comp.width, comp.height), None);
light.in_point = src.in_point;
light.out_point = src.out_point;
let lp = linalg::add(center, linalg::add(linalg::scale(normal, size * 2.0), linalg::scale(right, size)));
if let Some(pr) = light.props.prop_mut("transform/position") {
pr.value = KV::Vec3(lp);
}
if let Some(pr) = light.props.prop_mut("lightOptions/castsShadows") {
pr.value = KV::Bool(true);
}
let _ = sc;
created.push(insert_above(proj, cid, created[created.len() - 1], light)?);
}
CreateKind::Camera => {}
}
}
st.selected_layers = if created.is_empty() { vec![camera] } else { created.clone() };
st.selected_props.clear();
Ok((camera, created))
})?;
let t0 = targets.first().map(
|t| json!({"center": pl.to_world(t.center), "normal": linalg::normalize(linalg::mv(&pl.world.r, t.normal)), "size": t.size * pl.world.s * size_pct}),
);
Ok(json!({"camera": camera.0, "layers": created.iter().map(|l| l.0).collect::<Vec<_>>(), "target": t0}))
}
fn set_ground(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.setGroundPlane";
let (cid, lid, uid) = ct_p(s, p, cmd)?;
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad(cmd, "the camera is not solved yet"))?;
let k = pl.frame_at(s.time_of(cid));
let ids = ids_p(s, p);
if ids.is_empty() {
return Err(bad(cmd, "select track points (or give `points`)"));
}
let t = target_for(&pl.solve, &ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))?;
let mut sv = (*pl.solve).clone();
let mut g = sv.ground_from(&ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))?;
g.origin = t.center;
sv.ground = Some(g);
let json = sv.to_json();
s.edit("Set Ground Plane and Origin", None, |proj, _| {
let pr = proj
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.and_then(|g| g.prop_mut(SOLVE))
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
pr.value = KV::Str(json);
Ok(())
})?;
Ok(json!({"origin": g.origin, "normal": g.normal}))
}
/// Delete points (and, with Auto-delete Points Across Time, the points of other tracks on the
/// same 3D feature), then re-solve.
fn delete_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.deletePoints";
let (cid, lid, uid) = ct_p(s, p, cmd)?;
let ids = ids_p(s, p);
if ids.is_empty() {
return Err(bad(cmd, "select track points (or give `points`)"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let g = comp.layer(lid).and_then(|l| l.props.find_group(uid)).ok_or(EngineError::NoComp)?;
let params = static_params(g);
let mut del = ct::deleted(&params);
let auto = effectcraft_effects::warp_stab::param(&params, "advanced/autoDeletePoints").is_some_and(KV::as_bool);
let mut add = ids.clone();
if auto && let Some(sv) = ct::solve(&params) {
// Same feature tracked again at other times: solved within 1 % of its depth.
let cam0 = sv.first_solved().map(|k| sv.frames[k].center).unwrap_or([0.0; 3]);
for id in &ids {
let Some(pt) = sv.point(*id) else { continue };
let r = linalg::norm(linalg::sub(pt.pos, cam0)) * 0.01;
for q in &sv.points {
let disjoint = q.last < pt.first || q.first > pt.last;
if q.id != *id && disjoint && linalg::norm(linalg::sub(q.pos, pt.pos)) < r {
add.push(q.id);
}
}
}
}
for id in add {
if !del.contains(&id) {
del.push(id);
}
}
del.sort_unstable();
let json = serde_json::to_string(&del).unwrap_or_default();
s.edit("Delete Track Points", None, |proj, _| {
let pr = proj
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.and_then(|g| g.prop_mut(DELETED))
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
pr.value = KV::Str(json);
Ok(())
})?;
s.state.camera_points.retain(|x| !del.contains(x));
// Re-solve now (After Effects re-solves after deleting points).
let wait = b_p(p, "wait").unwrap_or(false);
if !s.is_camera_analyzing() {
s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
}
Ok(json!({"deleted": del, "running": s.is_camera_analyzing()}))
}
+139
View File
@@ -0,0 +1,139 @@
//! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text,
//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage).
use effectcraft_color::Label;
use effectcraft_project::{ItemKind, LayerSource, Solid};
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector";
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?;
let (cid, lids) = layers_p(s, p)?;
if lids.is_empty() {
return Err(bad("layer.create", "select a layer first"));
}
let time = s.time();
let mut created = vec![];
let mut skipped = vec![];
for lid in lids {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let Some(src) = comp.layer(lid).cloned() else { continue };
let footage = src.source.item().and_then(|i| match &s.project.item(i)?.kind {
ItemKind::Footage(f) => Some(f.clone()),
_ => None,
});
match op {
"shapesFromVector" => {
let Some(f) = footage.filter(|f| f.codec == "SVG" || f.path.to_ascii_lowercase().ends_with(".svg")) else {
skipped.push(format!("{}: not a vector (SVG) footage layer", src.name));
continue;
};
let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?;
let doc = effectcraft_svg::parse(&bytes).map_err(|e| EngineError::Other(format!("{}: {e}", f.path)))?;
let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| {
let l = crate::vector::shapes_from_vector(proj, &comp, &src, &doc);
let id = l.id;
insert_above(proj, cid, lid, l, true)?;
st.selected_layers = vec![id];
Ok(id)
})?;
created.push(id.0);
}
"shapesFromText" => {
if !matches!(src.source, LayerSource::Text) {
skipped.push(format!("{}: not a text layer", src.name));
continue;
}
let snapshot = s.project.clone();
let id = s.edit("Create Shapes from Text", None, |proj, st| {
let l = crate::vector::shapes_from_text(proj, &comp, &snapshot, cid, &src, time)
.ok_or_else(|| bad("layer.create", "the text layer has no glyphs"))?;
let id = l.id;
insert_above(proj, cid, lid, l, true)?;
st.selected_layers = vec![id];
Ok(id)
})?;
created.push(id.0);
}
"masksFromText" => {
if !matches!(src.source, LayerSource::Text) {
skipped.push(format!("{}: not a text layer", src.name));
continue;
}
let outlines = crate::vector::text_outlines_in_comp(&s.project, cid, &comp, &src, time);
if outlines.is_empty() {
skipped.push(format!("{}: no glyphs", src.name));
continue;
}
let id = s.edit("Create Masks from Text", None, |proj, st| {
let name = comp.unique_layer_name(&format!("{} Outlines", src.name));
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
let sid = proj.add_item(
&name,
Label::Red,
Some(folder),
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
);
let mut l = crate::vector::masks_layer(proj, &comp, &name, outlines, sid);
l.in_point = src.in_point;
l.out_point = src.out_point;
let id = l.id;
insert_above(proj, cid, lid, l, true)?;
st.selected_layers = vec![id];
Ok(id)
})?;
created.push(id.0);
}
"editableText" => {
let Some((f, sl)) = footage.and_then(|f| f.layer.clone().map(|l| (f, l))) else {
skipped.push(format!("{}: not a Photoshop layer", src.name));
continue;
};
let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?;
let psd = effectcraft_psd::Psd::parse(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let id = s.edit("Convert to Editable Text", None, |proj, st| {
let l = crate::psd_import::editable_text(proj, &comp, &src, &psd, sl.index as usize)
.ok_or_else(|| bad("layer.create", format!("{}: the Photoshop layer has no text", src.name)))?;
let id = l.id;
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = c.layers.iter().position(|x| x.id == lid).ok_or(EngineError::NoComp)?;
c.layers[at] = l;
st.selected_layers = vec![id];
Ok(id)
})?;
created.push(id.0);
}
other => return Err(bad("layer.create", format!("`{other}` is not available yet (supported: {OPS})"))),
}
}
if created.is_empty() && !skipped.is_empty() {
return Err(bad("layer.create", skipped.join("; ")));
}
Ok(json!({"layers": created, "skipped": skipped}))
}
/// Insert `l` directly above layer `below`; optionally switch the source's video off (as After
/// Effects does after converting).
fn insert_above(
proj: &mut effectcraft_project::Project,
cid: effectcraft_project::ItemId,
below: effectcraft_project::LayerId,
l: effectcraft_project::Layer,
hide_source: bool,
) -> Result<()> {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = c.layers.iter().position(|x| x.id == below).unwrap_or(0);
if hide_source && let Some(src) = c.layers.get_mut(at) {
src.switches.video = false;
}
c.layers.insert(at, l);
Ok(())
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)]
}
+31 -1
View File
@@ -525,12 +525,41 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
s.snapshot = None;
s.state.viewer.show_snapshot = false;
}
let memory = matches!(what.as_str(), "all" | "memoryAndDisk" | "memory" | "image");
let disk = matches!(what.as_str(), "all" | "memoryAndDisk" | "disk");
if memory {
s.layer_cache.clear();
}
let mut disk_entries = 0;
if disk && let Some(dc) = &s.disk_cache {
disk_entries = dc.stats().entries;
dc.clear();
}
if matches!(what.as_str(), "all" | "image" | "memory" | "memoryAndDisk") {
effectcraft_effects::roto::purge();
}
s.events.push(crate::Event::PurgeCaches);
s.toast(format!("Purged {what} cache"));
Ok(json!({"purged": what}))
Ok(json!({"purged": what, "diskEntries": disk_entries}))
}
/// `cache.diskStats`: the disk cache's folder, limit and contents.
pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result<Value> {
let Some(dc) = &s.disk_cache else { return Ok(json!({"enabled": false})) };
let st = dc.stats();
Ok(json!({
"enabled": true,
"folder": dc.folder().to_string_lossy(),
"entries": st.entries,
"frames": dc.keys(effectcraft_render::disk_cache::Kind::Frame).len(),
"layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(),
"bytes": st.bytes,
"maxBytes": st.max_bytes,
"hits": st.hits,
"misses": st.misses,
"writes": st.writes,
"evictions": st.evictions,
}))
}
/// Path of the footage behind the selected layer or project item.
@@ -599,6 +628,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group),
cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge),
cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches),
crate::query!("cache.diskStats", "Disk Cache Statistics", "{}", disk_stats),
cmd!("edit.editOriginal", "Edit Original...", ["Edit"], Some("Cmd+E"), "{}", has_selected_footage, edit_original),
]
}
+780
View File
@@ -0,0 +1,780 @@
//! Essential Graphics (Window ▸ Essential Graphics) and master properties.
//!
//! - **Authoring**: a comp's controls ([`EssentialGraphics`]): Add Property to Essential
//! Graphics, Media Replacement for footage layers, groups, comments, renaming, reordering, the
//! template name and the panel's Primary composition.
//! - **Instances**: every precomp layer of such a comp shows the controls under **Essential
//! Properties**; changing one overrides it for that instance only (`essential.set`, or any
//! property edit of the master property); Push to Comp writes the override into the source
//! comp, Revert drops it.
//! - **Templates**: `essential.exportTemplate` writes an open `.ectemplate` (a ZIP holding
//! `manifest.json` with the exposed controls, `project.ecproj` with the comp and everything it
//! uses, the media files and a poster frame); `essential.importTemplate` merges one into the
//! project and adds an instance to the active comp.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::essential::{self, ControlType, EgControl, EgKind, EssentialGraphics};
use effectcraft_project::{GroupKind, ItemId, ItemKind, LayerId, LayerSource, ParamUi, Project, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, comp_id, has_comp, layer_p, selected_leaf_props, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// Format id written into template manifests.
pub const TEMPLATE_FORMAT: &str = "effectcraft-template";
pub const TEMPLATE_VERSION: u32 = 1;
fn control_p(p: &Value, cmd: &str) -> Result<u64> {
p.get("control").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `control` (control id)"))
}
/// Comp whose Essential Graphics a command edits: `comp`, else the panel's Primary comp, else
/// the active comp.
fn eg_comp(s: &Session, p: &Value) -> Result<ItemId> {
if p.get("comp").is_some() {
return comp_id(s, p);
}
if let Some(c) = s.state.essential_primary.filter(|c| s.project.comp(*c).is_some()) {
return Ok(c);
}
comp_id(s, p)
}
fn eg_of(proj: &mut Project, cid: ItemId) -> Result<&mut EssentialGraphics> {
let name = proj.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
Ok(c.essential.get_or_insert_with(|| EssentialGraphics { name, controls: vec![] }))
}
/// Insert `ctl` into `group` (or the top level) at `index` (or the end).
fn insert(eg: &mut EssentialGraphics, ctl: EgControl, group: Option<u64>, index: Option<usize>, cmd: &str) -> Result<()> {
let list = eg.list_mut(group).ok_or_else(|| bad(cmd, "`group` is not a group control"))?;
let i = index.unwrap_or(list.len()).min(list.len());
list.insert(i, ctl);
Ok(())
}
fn set_primary(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.state.essential_primary = Some(cid);
s.bump();
Ok(json!({"comp": cid.0}))
}
fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
let cid = eg_comp(s, p)?;
let name = str_p(p, "name").ok_or_else(|| bad("essential.setName", "missing `name`"))?.to_string();
s.edit("Essential Graphics Name", None, |proj, st| {
st.essential_primary.get_or_insert(cid);
eg_of(proj, cid)?.name = name.clone();
Ok(())
})?;
Ok(json!({"comp": cid.0, "name": name}))
}
/// Add Property to Essential Graphics: `{layer, path|prop}` or the selected properties.
fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.addProperty";
let targets: Vec<(ItemId, LayerId, Uid)> = if p.get("path").is_some() || p.get("prop").is_some() {
vec![super::prop::prop_ref(s, p, C)?]
} else {
let cid = comp_id(s, p)?;
selected_leaf_props(s).into_iter().map(|(l, u)| (cid, l, u)).collect()
};
if targets.is_empty() {
return Err(bad(C, "select a property in the timeline (or give `layer` and `path`)"));
}
let group = p.get("group").and_then(Value::as_u64);
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
let rename = str_p(p, "name").map(str::to_string);
let mut added = vec![];
for (cid, lid, uid) in &targets {
let l = s.project.comp(*cid).and_then(|c| c.layer(*lid)).ok_or(EngineError::NoComp)?;
let pr = l.props.find(*uid).ok_or_else(|| bad(C, "no such property"))?;
if control_type(pr).is_none() {
return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)));
}
if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for(*lid, *uid).is_some()) {
continue;
}
added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone())));
}
let ids = s.edit("Add Property to Essential Graphics", None, |proj, st| {
if let Some((cid, ..)) = added.first() {
st.essential_primary.get_or_insert(*cid);
}
let mut ids = vec![];
for (k, (cid, lid, uid, name)) in added.iter().enumerate() {
let id = proj.alloc();
let eg = eg_of(proj, *cid)?;
insert(eg, EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid } }, group, index.map(|i| i + k), C)?;
ids.push(id);
}
Ok(ids)
})?;
Ok(json!({"controls": ids}))
}
fn control_type(pr: &effectcraft_project::Property) -> Option<ControlType> {
essential::control_type(pr)
}
/// Media Replacement for a footage layer.
fn add_media(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.addMedia";
let (cid, lid) = layer_p(s, p, C)?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
if !matches!(l.source, LayerSource::Footage { .. }) {
return Err(bad(C, "Media Replacement needs a footage layer"));
}
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("{} (Media)", l.name));
let group = p.get("group").and_then(Value::as_u64);
let id = s.edit("Add Media Replacement", None, |proj, st| {
st.essential_primary.get_or_insert(cid);
let id = proj.alloc();
let eg = eg_of(proj, cid)?;
if eg.flat().iter().any(|c| matches!(c.kind, EgKind::Media { layer } if layer == lid)) {
return Err(bad(C, "this layer already has a Media Replacement control"));
}
insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid } }, group, None, C)?;
Ok(id)
})?;
Ok(json!({"control": id}))
}
fn add_group(s: &mut Session, p: &Value) -> Result<Value> {
let cid = eg_comp(s, p)?;
let name = str_p(p, "name").unwrap_or("Group").to_string();
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
let id = s.edit("Add Essential Graphics Group", None, |proj, st| {
st.essential_primary.get_or_insert(cid);
let id = proj.alloc();
insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] } }, None, index, "essential.addGroup")?;
Ok(id)
})?;
Ok(json!({"control": id}))
}
fn add_comment(s: &mut Session, p: &Value) -> Result<Value> {
let cid = eg_comp(s, p)?;
let text = str_p(p, "text").unwrap_or("Comment").to_string();
let group = p.get("group").and_then(Value::as_u64);
let id = s.edit("Add Essential Graphics Comment", None, |proj, st| {
st.essential_primary.get_or_insert(cid);
let id = proj.alloc();
insert(eg_of(proj, cid)?, EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text } }, group, None, "essential.addComment")?;
Ok(id)
})?;
Ok(json!({"control": id}))
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.rename";
let cid = eg_comp(s, p)?;
let id = control_p(p, C)?;
let name = str_p(p, "name").or(str_p(p, "text")).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
s.edit("Rename Essential Graphics Control", None, |proj, _| {
let c = eg_of(proj, cid)?.find_mut(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
match &mut c.kind {
// A comment's name is its text.
EgKind::Comment { text } => *text = name.clone(),
_ => c.name = name.clone(),
}
Ok(())
})?;
Ok(json!({"control": id, "name": name}))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.remove";
let cid = eg_comp(s, p)?;
let id = control_p(p, C)?;
s.edit("Remove Essential Graphics Control", None, |proj, _| {
eg_of(proj, cid)?.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
Ok(())
})?;
Ok(json!({"control": id}))
}
fn move_control(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.move";
let cid = eg_comp(s, p)?;
let id = control_p(p, C)?;
let group = p.get("group").and_then(Value::as_u64);
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
if group == Some(id) {
return Err(bad(C, "a group can't contain itself"));
}
s.edit("Move Essential Graphics Control", None, |proj, _| {
let eg = eg_of(proj, cid)?;
let ctl = eg.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
if matches!(ctl.kind, EgKind::Group { .. }) && group.is_some() {
return Err(bad(C, "groups can't be nested"));
}
insert(eg, ctl, group, index, C)
})?;
Ok(json!({"control": id}))
}
/// Dropdown Menu Control ▸ Edit: the menu items of a dropdown (popup) property.
fn edit_dropdown(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.editDropdown";
let items: Vec<String> = match p.get("items") {
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).filter(|x| !x.is_empty()).collect(),
_ => return Err(bad(C, "missing `items` (array of strings)")),
};
if items.is_empty() {
return Err(bad(C, "a dropdown needs at least one item"));
}
let q = if p.get("path").is_none() && p.get("prop").is_none() {
// The first Dropdown Menu Control of the layer.
let (cid, lid) = layer_p(s, p, C)?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let fx = l.effects().and_then(|fx| fx.groups().find(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.control.dropdown")));
let uid = fx.and_then(|g| g.get("menu")).map(|pr| pr.uid).ok_or_else(|| bad(C, "the layer has no Dropdown Menu Control"))?;
let mut q = p.clone();
q["prop"] = json!(uid);
q["layer"] = json!(lid.0);
q
} else {
p.clone()
};
let (cid, lid, uid) = super::prop::prop_ref(s, &q, C)?;
let n = items.len() as u32;
s.edit("Edit Dropdown", None, |proj, _| {
let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?;
if !matches!(pr.ui, ParamUi::Popup { .. }) {
return Err(bad(C, "not a dropdown property"));
}
pr.ui = ParamUi::Popup { options: items.clone() };
let clamp = |v: &mut KV| {
if let KV::Enum(i) = v {
*i = (*i).min(n - 1);
}
};
clamp(&mut pr.value);
for k in &mut pr.keys {
clamp(&mut k.value);
}
Ok(())
})?;
Ok(json!({"prop": uid, "items": items}))
}
fn solo_supported(s: &mut Session, p: &Value) -> Result<Value> {
let on = b_p(p, "on").unwrap_or(!s.state.essential_solo);
s.state.essential_solo = on;
s.bump();
Ok(json!({"on": on}))
}
/// Properties of a comp's layers that Essential Graphics can expose (Solo Supported
/// Properties): `[{layer, uid, path, name, type}]`.
pub fn supported_properties(project: &Project, cid: ItemId) -> Vec<Value> {
let Some(c) = project.comp(cid) else { return vec![] };
let mut out = vec![];
for l in &c.layers {
l.props.walk("", &mut |_, pr| {
if pr.three_d_only && !l.is_3d() {
return;
}
if let Some(t) = essential::control_type(pr) {
let path = super::match_path_of(&l.props, pr.uid).unwrap_or_default();
if path.starts_with(essential::GROUP) {
return;
}
out.push(json!({"layer": l.id.0, "layerName": l.name, "uid": pr.uid, "path": path, "name": l.props.name_path_of(pr.uid), "type": t}));
}
});
}
out
}
fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value {
match &c.kind {
EgKind::Property { layer, prop } => {
let src = essential::source_prop(project, cid, c);
json!({
"id": c.id, "name": c.name, "kind": "property", "layer": layer.0, "prop": prop,
"layerName": src.map(|(l, _)| l.name.clone()),
"path": src.and_then(|(l, _)| super::match_path_of(&l.props, *prop)),
"type": src.and_then(|(_, pr)| essential::control_type(pr)),
"value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()),
"missing": src.is_none(),
})
}
EgKind::Media { layer } => json!({
"id": c.id, "name": c.name, "kind": "media", "type": "media", "layer": layer.0,
"item": essential::source_media(project, cid, c).map(|i| i.0),
}),
EgKind::Comment { text } => json!({"id": c.id, "name": c.name, "kind": "comment", "text": text}),
EgKind::Group { children } => json!({
"id": c.id, "name": c.name, "kind": "group",
"children": children.iter().map(|x| control_json(project, cid, x)).collect::<Vec<_>>(),
}),
}
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let cid = eg_comp(s, p)?;
let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default();
let name = if eg.name.is_empty() { s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default() } else { eg.name.clone() };
Ok(json!({
"comp": cid.0,
"primary": s.state.essential_primary.map(|c| c.0),
"name": name,
"solo": s.state.essential_solo,
"controls": eg.controls.iter().map(|c| control_json(&s.project, cid, c)).collect::<Vec<_>>(),
"supported": if b_p(p, "supported").unwrap_or(false) { json!(supported_properties(&s.project, cid)) } else { Value::Null },
}))
}
// ------------------------------------------------------------------------------ instances
/// The precomp layer (an instance) and its source comp.
fn instance(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, ItemId)> {
let (cid, lid) = layer_p(s, p, cmd)?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
match l.source {
LayerSource::Comp { item } if essential::group(l).is_some() => Ok((cid, lid, item)),
_ => Err(bad(cmd, "the layer is not a precomp with Essential Properties")),
}
}
/// The master property (instance child) of a control.
fn master_uid(s: &Session, cid: ItemId, lid: LayerId, control: u64) -> Option<Uid> {
let l = s.project.comp(cid)?.layer(lid)?;
let g = essential::group(l)?;
let m = essential::match_id(control);
let mut found = None;
g.walk("", &mut |_, pr| {
if pr.match_id == m {
found = Some(pr.uid);
}
});
found
}
fn instance_json(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, src) = instance(s, p, "essential.instance")?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let over = essential::overridden(l);
let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default();
let t = s.time();
let g = essential::group(l).cloned();
let mut out = vec![];
for c in eg.flat() {
let m = essential::match_id(c.id);
let mut pr = None;
if let Some(g) = &g {
g.walk("", &mut |_, x| {
if x.match_id == m {
pr = Some(x.clone());
}
});
}
let kind = match c.kind {
EgKind::Property { .. } => "property",
EgKind::Media { .. } => "media",
EgKind::Comment { .. } => "comment",
EgKind::Group { .. } => "group",
};
out.push(json!({
"control": c.id, "name": c.name, "kind": kind,
"uid": pr.as_ref().map(|x| x.uid),
"path": pr.as_ref().and_then(|x| super::match_path_of(&l.props, x.uid)),
"value": pr.as_ref().map(|x| x.value_at(l.layer_time(t)).to_json()),
"type": pr.as_ref().and_then(essential::control_type).or((kind == "media").then_some(ControlType::Media)),
"overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)),
}));
}
Ok(json!({"layer": lid.0, "comp": src.0, "name": eg.name, "controls": out}))
}
/// Set an instance's value for a control (`value`, or `item` for Media Replacement).
fn set_override(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.set";
let (cid, lid, _) = instance(s, p, C)?;
let control = control_p(p, C)?;
let uid = master_uid(s, cid, lid, control).ok_or_else(|| bad(C, format!("no control {control} on this instance")))?;
if let Some(item) = p.get("item") {
let item = match item {
Value::Number(n) => ItemId(n.as_u64().unwrap_or(0)),
Value::String(name) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(C, format!("no item `{name}`")))?,
_ => return Err(bad(C, "`item` is an item id or name")),
};
if !matches!(s.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_video) {
return Err(bad(C, "replacement media must be footage with video"));
}
s.edit("Replace Media", None, |proj, _| {
let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?;
pr.value = KV::Scalar(item.0 as f64);
Ok(())
})?;
return Ok(json!({"control": control, "item": item.0}));
}
let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?});
if let Some(m) = p.get("merge") {
q["merge"] = m.clone();
}
s.execute("prop.set", q)
}
/// Controls of an instance to act on: `control` or every overridden one.
fn targets(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Vec<(u64, Uid)> {
let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) else { return vec![] };
let over = essential::overridden(l);
let Some(g) = essential::group(l) else { return vec![] };
let want = p.get("control").and_then(Value::as_u64);
let mut out = vec![];
g.walk("", &mut |_, pr| {
if let Some(c) = essential::control_of(&pr.match_id)
&& want.is_none_or(|w| w == c)
&& (want.is_some() || over.contains(&pr.uid))
{
out.push((c, pr.uid));
}
});
out
}
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.revert";
let (cid, lid, _) = instance(s, p, C)?;
let ts = targets(s, cid, lid, p);
if ts.is_empty() {
return Ok(json!({"reverted": []}));
}
s.edit("Revert Essential Property", None, |proj, _| {
let l = super::layer_mut(proj, cid, lid)?;
let g = l.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?;
if let GroupKind::Essential { overridden } = &mut g.kind {
overridden.retain(|u| !ts.iter().any(|(_, x)| x == u));
}
for (_, uid) in &ts {
if let Some(pr) = g.find_mut(*uid) {
// The sync below brings back the source value.
pr.keys.clear();
pr.expr = None;
}
}
Ok(())
})?;
// Edits that only clear an override must not be taken for a new override: values are
// refreshed by the sync inside `edit`, which ran after the override list was trimmed.
Ok(json!({"reverted": ts.iter().map(|(c, _)| c).collect::<Vec<_>>()}))
}
/// Push to Comp: write the instance's override values into the source comp's properties (and
/// replaced media into the source layer), then drop the overrides.
fn push_to_comp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.pushToComp";
let (cid, lid, src) = instance(s, p, C)?;
let ts = targets(s, cid, lid, p);
let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default();
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?;
let mut writes = vec![];
for (control, uid) in &ts {
let (Some(ctl), Some(pr)) = (eg.find(*control), l.props.find(*uid)) else { continue };
writes.push((ctl.kind.clone(), pr.clone()));
}
s.edit("Push Override Values to Source", None, |proj, _| {
let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?;
for (kind, pr) in &writes {
match kind {
EgKind::Property { layer, prop } => {
let Some(inner) = sc.layer_mut(*layer) else { continue };
// Instance keys are in the precomp layer's time (= the source comp's time);
// the source property's keys are in the inner layer's time.
let keys: Vec<_> = pr
.keys
.iter()
.map(|k| {
let mut k = k.clone();
k.time = inner.layer_time(k.time);
k
})
.collect();
if let Some(target) = inner.props.find_mut(*prop) {
target.value = pr.value.clone();
target.keys = keys;
}
}
EgKind::Media { layer } => {
if let Some(inner) = sc.layer_mut(*layer) {
inner.source = LayerSource::Footage { item: ItemId(pr.value.as_f64().max(0.0) as u64) };
}
}
_ => {}
}
}
let g = super::layer_mut(proj, cid, lid)?.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?;
if let GroupKind::Essential { overridden } = &mut g.kind {
overridden.retain(|u| !ts.iter().any(|(_, x)| x == u));
}
for (_, uid) in &ts {
if let Some(pr) = g.find_mut(*uid) {
pr.keys.clear();
pr.expr = None;
}
}
Ok(())
})?;
Ok(json!({"pushed": ts.iter().map(|(c, _)| c).collect::<Vec<_>>()}))
}
// ------------------------------------------------------------------------------ templates
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
/// The manifest's control list (flattened, with the group each belongs to).
fn manifest_controls(project: &Project, cid: ItemId, eg: &EssentialGraphics) -> Vec<Value> {
fn go(project: &Project, cid: ItemId, v: &[EgControl], group: Option<u64>, out: &mut Vec<Value>) {
for c in v {
let mut j = control_json(project, cid, c);
if let Some(o) = j.as_object_mut() {
o.remove("children");
o.insert("group".into(), json!(group));
}
out.push(j);
if let EgKind::Group { children } = &c.kind {
go(project, cid, children, Some(c.id), out);
}
}
}
let mut out = vec![];
go(project, cid, &eg.controls, None, &mut out);
out
}
fn export_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.exportTemplate";
let cid = eg_comp(s, p)?;
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let eg = comp.essential.clone().filter(|e| !e.controls.is_empty()).ok_or_else(|| bad(C, "the composition has no Essential Graphics controls"))?;
let comp_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() });
// The project subset: the comp and everything it uses, at the root.
let deps = essential::dependencies(&s.project, cid);
let mut sub = Project { settings: s.project.settings.clone(), next_id: s.project.next_id, ..Project::default() };
let mut entries: Vec<(String, Vec<u8>)> = vec![];
let mut media = vec![];
for id in &deps {
let Some(mut it) = s.project.item(*id).cloned() else { continue };
it.parent = None;
it.proxy = None;
if let ItemKind::Footage(f) = &mut it.kind
&& f.data.is_none()
{
let files: Vec<String> = if f.sequence.is_empty() { vec![f.path.clone()] } else { f.sequence.clone() };
let mut stored = vec![];
for (k, file) in files.iter().enumerate() {
if let Ok(bytes) = s.services.read_file(file) {
let entry = format!("media/{}/{}", id.0, file_name(file));
entries.push((entry.clone(), bytes));
if k == 0 {
f.path = entry.clone();
}
stored.push(entry);
}
}
if !stored.is_empty() {
if !f.sequence.is_empty() {
f.sequence = stored.clone();
}
media.push(json!({"item": id.0, "files": stored}));
}
}
sub.items.insert(*id, it);
}
let manifest = json!({
"format": TEMPLATE_FORMAT,
"version": TEMPLATE_VERSION,
"generator": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")),
"name": name,
"comp": cid.0,
"compName": comp_name,
"width": comp.width,
"height": comp.height,
"frameRate": comp.frame_rate.as_f64(),
"duration": comp.duration.seconds(),
"controls": manifest_controls(&s.project, cid, &eg),
"media": media,
"poster": "poster.png",
});
// A poster frame (longest side ≤ 320 px).
if let Ok((w, h, rgba)) = s.render_rgba8(cid, comp.poster_time, 320)
&& let Ok(png) = super::comp_more::encode_png(&rgba, w, h)
{
entries.push(("poster.png".into(), png));
}
entries.insert(0, ("project.ecproj".into(), sub.to_json().into_bytes()));
entries.insert(0, ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()));
let bytes = effectcraft_lottie::zip::store(&entries);
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Exported template “{name}” to {path}"));
Ok(json!({"path": path, "name": name, "bytes": bytes.len(), "controls": eg.flat().len(), "items": deps.len()}))
}
/// Read a template's manifest and project (with media paths still archive-relative).
pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(String, Vec<u8>)>), String> {
let entries = effectcraft_lottie::zip::read_stored(bytes);
let get = |n: &str| entries.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
let manifest: Value =
serde_json::from_slice(&get("manifest.json").ok_or("not an EffectCraft template (no manifest.json)")?).map_err(|e| format!("manifest.json: {e}"))?;
if manifest.get("format").and_then(Value::as_str) != Some(TEMPLATE_FORMAT) {
return Err("not an EffectCraft template".into());
}
if manifest.get("version").and_then(Value::as_u64).unwrap_or(0) > TEMPLATE_VERSION as u64 {
return Err("the template was made by a newer EffectCraft".into());
}
let text = String::from_utf8(get("project.ecproj").ok_or("template has no project.ecproj")?).map_err(|e| e.to_string())?;
let project = Project::from_json(&text).map_err(|e| e.to_string())?;
Ok((manifest, project, entries))
}
fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.importTemplate";
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let (manifest, mut sub, entries) = read_template(&bytes).map_err(|e| bad(C, e))?;
let name = manifest.get("name").and_then(Value::as_str).unwrap_or("Template").to_string();
let tcomp = manifest.get("comp").and_then(Value::as_u64).map(ItemId).ok_or_else(|| bad(C, "manifest has no `comp`"))?;
// Media next to the template: `<dir>/<stem> Media/<item>/<file>`.
let pp = std::path::Path::new(&path);
let dir = pp.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let stem = pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Template".into());
let media_dir = dir.join(format!("{stem} Media"));
let mut written = std::collections::HashMap::new();
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
let rel = k.trim_start_matches("media/");
let out = media_dir.join(rel);
if let Some(parent) = out.parent() {
let _ = std::fs::create_dir_all(parent);
}
let out_s = out.to_string_lossy().to_string();
if s.services.write_file(&out_s, v).is_ok() {
written.insert(k.clone(), out_s);
}
}
for it in sub.items.values_mut() {
if let ItemKind::Footage(f) = &mut it.kind {
if let Some(w) = written.get(&f.path) {
f.path = w.clone();
}
for x in &mut f.sequence {
if let Some(w) = written.get(x) {
*x = w.clone();
}
}
}
}
let target = if b_p(p, "addToComp") == Some(false) { None } else { comp_id(s, p).ok() };
let base = s.project.next_id;
essential::offset_ids(&mut sub, base);
let new_comp = ItemId(tcomp.0 + base);
let ids = s.edit("Import Template", None, |proj, st| {
let folder = proj.add_item(&name, effectcraft_color::Label::Yellow, None, ItemKind::Folder);
let mut ids = vec![];
for (id, mut it) in std::mem::take(&mut sub.items) {
if it.parent.is_none() {
it.parent = Some(folder);
}
ids.push(id.0);
proj.items.insert(id, it);
}
proj.next_id = proj.next_id.max(sub.next_id);
proj.fix_next_id();
st.project_selection = vec![new_comp];
Ok(ids)
})?;
let mut layer = Value::Null;
if let Some(cid) = target
&& s.project.comp(cid).is_some()
{
let r = s.execute("layer.addItem", json!({"comp": cid.0, "item": new_comp.0, "time": 0.0}))?;
layer = r["layer"].clone();
}
s.toast(format!("Imported template “{name}”"));
Ok(json!({"comp": new_comp.0, "items": ids, "layer": layer, "name": name}))
}
/// Read a template's manifest without importing it (Essential Graphics / agents).
fn template_info(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("essential.templateInfo", "missing `path`"))?;
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let (manifest, _, _) = read_template(&bytes).map_err(|e| bad("essential.templateInfo", e))?;
Ok(manifest)
}
fn has_instance(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
let ok = s.active_comp().is_some_and(|c| s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| essential::group(l).is_some())));
if ok { Ok(()) } else { Err("select a precomp layer with Essential Properties".into()) }
}
fn has_eg(s: &Session) -> std::result::Result<(), String> {
let cid = s.state.essential_primary.or(s.state.active_comp).ok_or("no composition is open")?;
if s.project.comp(cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| !e.controls.is_empty()) {
Ok(())
} else {
Err("add properties to Essential Graphics first".into())
}
}
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.selected_props.is_empty() { Err("select a property in the timeline".into()) } else { Ok(()) }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("essential.setPrimary", "Primary Composition", [], None, "{comp}", has_comp, set_primary),
cmd!("essential.setName", "Essential Graphics Name", [], None, "{comp?, name}", has_comp, set_name),
cmd!(
"essential.addProperty",
"Add Property to Essential Graphics",
["Animation"],
None,
"{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?}",
has_selected_props,
add_property
),
cmd!("essential.addMedia", "Add Media Replacement", [], None, "{layer, name?, group?}", has_comp, add_media),
cmd!("essential.addGroup", "Add Group", [], None, "{comp?, name?, index?}", has_comp, add_group),
cmd!("essential.addComment", "Add Comment", [], None, "{comp?, text?, group?}", has_comp, add_comment),
cmd!("essential.rename", "Rename Control", [], None, "{comp?, control, name}", has_comp, rename),
cmd!("essential.remove", "Remove Control", [], None, "{comp?, control}", has_comp, remove),
cmd!("essential.move", "Move Control", [], None, "{comp?, control, group?: id (null = top level), index?}", has_comp, move_control),
cmd!("essential.soloSupported", "Solo Supported Properties", [], None, "{on?}", has_comp, solo_supported),
cmd!(
"essential.exportTemplate",
"Essential Graphics Template...",
["File", "Export"],
None,
"{comp?, path (.ectemplate), name?}",
has_eg,
export_template
),
cmd!(
"essential.importTemplate",
"Essential Graphics Template...",
["File", "Import"],
None,
"{path (.ectemplate), comp?, addToComp?: bool}",
super::always,
import_template
),
cmd!("essential.set", "Set Essential Property", [], None, "{layer, control, value | item (media)}", has_instance, set_override),
cmd!("essential.pushToComp", "Push Override Values to Source", [], None, "{layer, control? (default: all overridden)}", has_instance, push_to_comp),
cmd!("essential.revert", "Revert", [], None, "{layer, control? (default: all overridden)}", has_instance, revert),
cmd!("effect.editDropdown", "Edit Dropdown Menu", [], None, "{layer, path?|prop?, items: [string]}", has_comp, edit_dropdown),
query!("essential.list", "Essential Graphics controls", "{comp?, supported?: bool}", list),
query!("essential.instance", "Essential Properties of a precomp layer", "{layer}", instance_json),
query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info),
]
}
+375
View File
@@ -0,0 +1,375 @@
//! Expression tools: the error list behind the expression error bar, and the Expression
//! Language menu (the ⓕ button next to an expression), written from the public Expression
//! Language Reference.
use effectcraft_project::{ItemId, Layer, Property};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
use serde::Serialize;
use serde_json::{Value, json};
use super::{CommandSpec, comp_id, f_p};
use crate::{EngineError, Result, Session, query};
/// One failing expression.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ExprError {
pub comp: u64,
pub layer: u64,
#[serde(rename = "layerIndex")]
pub layer_index: usize,
#[serde(rename = "layerName")]
pub layer_name: String,
pub prop: u64,
/// Match path (`transform/opacity`).
pub path: String,
/// Property name path as shown in the timeline (`Transform/Opacity`).
pub name: String,
pub message: String,
/// The expression is disabled (it failed to compile).
pub disabled: bool,
}
impl ExprError {
/// The error bar's text: `Error at line 1 in property 'Opacity' of layer 1 ('Solid') in comp 'Main'. …`
pub fn describe(&self, comp_name: &str) -> String {
let prop = self.name.rsplit('/').next().unwrap_or(&self.name);
format!("{} — property '{}' of layer {} ('{}') in comp '{}'", self.message, prop, self.layer_index, self.layer_name, comp_name)
}
}
/// Every failing expression of comp `cid` at comp time `t`: evaluation errors of enabled
/// expressions and the syntax errors that disabled others.
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
let mut out = vec![];
for (i, l) in comp.layers.iter().enumerate() {
let mut props: Vec<&Property> = vec![];
l.props.walk("", &mut |_, p| {
if p.expr.as_ref().is_some_and(|e| !e.text.trim().is_empty()) {
props.push(p);
}
});
for p in props {
let e = p.expr.as_ref().expect("filtered");
let msg = if e.enabled {
match &ctx.expr {
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
None => None,
}
} else {
s.expr_check.and_then(|check| check(&e.text).err())
};
if let Some(message) = msg {
out.push(error_of(cid, i, l, p, message, !e.enabled));
}
}
}
out
}
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
ExprError {
comp: cid.0,
layer: l.id.0,
layer_index: i + 1,
layer_name: l.name.clone(),
prop: p.uid,
path: super::match_path_of(&l.props, p.uid).unwrap_or_default(),
name: l.props.name_path_of(p.uid).unwrap_or_else(|| p.name.clone()),
message,
disabled,
}
}
fn errors_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let name = s.project.item(cid).map(|i| i.name.clone()).ok_or(EngineError::NoComp)?;
let list = errors(s, cid, t);
let text: Vec<String> = list.iter().map(|e| e.describe(&name)).collect();
Ok(json!({"comp": cid.0, "time": t.seconds(), "count": list.len(), "errors": list, "text": text}))
}
/// The Expression Language menu: (category, [(label, inserted text)]). Submenus are nested
/// with `▸` in the category name.
pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
vec![
(
"Global",
vec![
("comp(name)", "comp(\"name\")"),
("footage(name)", "footage(\"name\")"),
("thisComp", "thisComp"),
("thisLayer", "thisLayer"),
("thisProperty", "thisProperty"),
("thisProject", "thisProject"),
("time", "time"),
("colorDepth", "colorDepth"),
("posterizeTime(framesPerSecond)", "posterizeTime(framesPerSecond)"),
("value", "value"),
],
),
(
"Vector Math",
vec![
("add(vec1, vec2)", "add(vec1, vec2)"),
("sub(vec1, vec2)", "sub(vec1, vec2)"),
("mul(vec, amount)", "mul(vec, amount)"),
("div(vec, amount)", "div(vec, amount)"),
("clamp(value, limit1, limit2)", "clamp(value, limit1, limit2)"),
("dot(vec1, vec2)", "dot(vec1, vec2)"),
("cross(vec1, vec2)", "cross(vec1, vec2)"),
("normalize(vec)", "normalize(vec)"),
("length(vec)", "length(vec)"),
("length(point1, point2)", "length(point1, point2)"),
("lookAt(fromPoint, atPoint)", "lookAt(fromPoint, atPoint)"),
],
),
(
"Random Numbers",
vec![
("seedRandom(offset, timeless=false)", "seedRandom(offset, timeless=false)"),
("random()", "random()"),
("random(maxValOrArray)", "random(maxValOrArray)"),
("random(minValOrArray, maxValOrArray)", "random(minValOrArray, maxValOrArray)"),
("gaussRandom()", "gaussRandom()"),
("gaussRandom(maxValOrArray)", "gaussRandom(maxValOrArray)"),
("gaussRandom(minValOrArray, maxValOrArray)", "gaussRandom(minValOrArray, maxValOrArray)"),
("noise(valOrArray)", "noise(valOrArray)"),
],
),
(
"Interpolation",
vec![
("linear(t, value1, value2)", "linear(t, value1, value2)"),
("linear(t, tMin, tMax, value1, value2)", "linear(t, tMin, tMax, value1, value2)"),
("ease(t, value1, value2)", "ease(t, value1, value2)"),
("ease(t, tMin, tMax, value1, value2)", "ease(t, tMin, tMax, value1, value2)"),
("easeIn(t, value1, value2)", "easeIn(t, value1, value2)"),
("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"),
("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"),
("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"),
],
),
(
"Color Conversion",
vec![
("rgbToHsl(rgbaArray)", "rgbToHsl(rgbaArray)"),
("hslToRgb(hslaArray)", "hslToRgb(hslaArray)"),
("hexToRgb(hexString)", "hexToRgb(hexString)"),
],
),
("Other Math", vec![("degreesToRadians(degrees)", "degreesToRadians(degrees)"), ("radiansToDegrees(radians)", "radiansToDegrees(radians)")]),
(
"JavaScript Math",
vec![
("Math.cos(value)", "Math.cos(value)"),
("Math.sin(value)", "Math.sin(value)"),
("Math.atan2(y, x)", "Math.atan2(y, x)"),
("Math.sqrt(value)", "Math.sqrt(value)"),
("Math.abs(value)", "Math.abs(value)"),
("Math.round(value)", "Math.round(value)"),
("Math.floor(value)", "Math.floor(value)"),
("Math.min(value1, value2)", "Math.min(value1, value2)"),
("Math.max(value1, value2)", "Math.max(value1, value2)"),
("Math.PI", "Math.PI"),
],
),
(
"Comp",
vec![
("layer(index)", "layer(index)"),
("layer(name)", "layer(\"name\")"),
("layer(otherLayer, relIndex)", "layer(otherLayer, relIndex)"),
("marker", "marker"),
("numLayers", "numLayers"),
("activeCamera", "activeCamera"),
("width", "width"),
("height", "height"),
("duration", "duration"),
("frameDuration", "frameDuration"),
("pixelAspect", "pixelAspect"),
("name", "name"),
("bgColor", "bgColor"),
("displayStartTime", "displayStartTime"),
],
),
(
"Footage",
vec![
("width", "width"),
("height", "height"),
("duration", "duration"),
("frameDuration", "frameDuration"),
("pixelAspect", "pixelAspect"),
("name", "name"),
("sourceText", "sourceText"),
("sourceData", "sourceData"),
("dataValue(dataPath)", "dataValue(dataPath)"),
("dataKeyCount", "dataKeyCount"),
],
),
(
"Layer > Sub-objects",
vec![
("source", "source"),
("effect(name)", "effect(\"name\")"),
("effect(index)", "effect(index)"),
("mask(name)", "mask(\"name\")"),
("mask(index)", "mask(index)"),
],
),
(
"Layer > General",
vec![
("width", "width"),
("height", "height"),
("index", "index"),
("parent", "parent"),
("hasParent", "hasParent"),
("inPoint", "inPoint"),
("outPoint", "outPoint"),
("startTime", "startTime"),
("hasVideo", "hasVideo"),
("enabled", "enabled"),
("active", "active"),
],
),
(
"Layer > Properties",
vec![
("anchorPoint", "anchorPoint"),
("position", "position"),
("scale", "scale"),
("rotation", "rotation"),
("opacity", "opacity"),
("marker", "marker"),
("name", "name"),
],
),
(
"Layer > Space Transforms",
vec![
("toComp(point, t = time)", "toComp(point, t = time)"),
("fromComp(point, t = time)", "fromComp(point, t = time)"),
("toWorld(point, t = time)", "toWorld(point, t = time)"),
("fromWorld(point, t = time)", "fromWorld(point, t = time)"),
("toCompVec(vec, t = time)", "toCompVec(vec, t = time)"),
("fromCompVec(vec, t = time)", "fromCompVec(vec, t = time)"),
("toWorldVec(vec, t = time)", "toWorldVec(vec, t = time)"),
("fromWorldVec(vec, t = time)", "fromWorldVec(vec, t = time)"),
],
),
(
"Layer > Other",
vec![
("sourceRectAtTime(t = time, includeExtents = false)", "sourceRectAtTime(t = time, includeExtents = false)"),
("sampleImage(point, radius = [.5, .5], postEffect = true, t = time)", "sampleImage(point, radius = [.5, .5], postEffect = true, t = time)"),
],
),
(
"Property",
vec![
("value", "value"),
("valueAtTime(t)", "valueAtTime(t)"),
("velocity", "velocity"),
("velocityAtTime(t)", "velocityAtTime(t)"),
("speed", "speed"),
("speedAtTime(t)", "speedAtTime(t)"),
("wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
("temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
("smooth(width = .2, samples = 5, t = time)", "smooth(width = .2, samples = 5, t = time)"),
("loopIn(type = \"cycle\", numKeyframes = 0)", "loopIn(type = \"cycle\", numKeyframes = 0)"),
("loopOut(type = \"cycle\", numKeyframes = 0)", "loopOut(type = \"cycle\", numKeyframes = 0)"),
("loopInDuration(type = \"cycle\", duration = 0)", "loopInDuration(type = \"cycle\", duration = 0)"),
("loopOutDuration(type = \"cycle\", duration = 0)", "loopOutDuration(type = \"cycle\", duration = 0)"),
("key(index)", "key(index)"),
("key(markerName)", "key(markerName)"),
("nearestKey(t)", "nearestKey(t)"),
("numKeys", "numKeys"),
("name", "name"),
("propertyIndex", "propertyIndex"),
],
),
(
"Path Property",
vec![
("points(t = time)", "points(t = time)"),
("inTangents(t = time)", "inTangents(t = time)"),
("outTangents(t = time)", "outTangents(t = time)"),
("isClosed()", "isClosed()"),
(
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
),
],
),
(
"Text > Style",
vec![
("text.sourceText.style", "text.sourceText.style"),
("getStyleAt(charIndex, t = time)", "getStyleAt(charIndex, t = time)"),
("createStyle()", "createStyle()"),
("setFontSize(value)", "setFontSize(value)"),
("setFillColor(value)", "setFillColor(value)"),
("setText(value)", "setText(value)"),
],
),
("Text > Expression Selector", vec![("textIndex", "textIndex"), ("textTotal", "textTotal"), ("selectorValue", "selectorValue")]),
("Key", vec![("value", "value"), ("time", "time"), ("index", "index")]),
(
"Marker Key",
vec![
("duration", "duration"),
("comment", "comment"),
("chapter", "chapter"),
("url", "url"),
("frameTarget", "frameTarget"),
("eventCuePoint", "eventCuePoint"),
("cuePointName", "cuePointName"),
("parameters", "parameters"),
("protectedRegion", "protectedRegion"),
],
),
(
"Time Conversion",
vec![
(
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
),
("framesToTime(frames, fps = 1.0 / thisComp.frameDuration)", "framesToTime(frames, fps = 1.0 / thisComp.frameDuration)"),
(
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
),
(
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
),
(
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
),
],
),
]
}
fn menu_cmd(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!(
language_menu()
.into_iter()
.map(|(c, items)| json!({"category": c, "items": items.into_iter().map(|(l, t)| json!({"label": l, "text": t})).collect::<Vec<_>>()}))
.collect::<Vec<_>>()
))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("expr.errors", "Expression errors (the error bar)", "{comp?, time? (s)}", errors_cmd),
query!("expr.languageMenu", "Expression Language menu", "{}", menu_cmd),
]
}
+127 -4
View File
@@ -69,7 +69,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
let next = crate::autosave::increment_path(&cur, |p| s.file_ops().is_file(std::path::Path::new(p)));
save_to(s, &next)
}
@@ -84,12 +84,61 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::String(x)) => vec![x.clone()],
_ => return Err(bad("file.import", "missing `paths`")),
};
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
// Photoshop documents as compositions (Import As: Composition / – Retain Layer Sizes).
let import_as = str_p(p, "importAs").unwrap_or("footage");
let retain = match import_as {
"footage" => None,
"composition" | "comp" => Some(false),
"compositionLayerSizes" | "compositionRetainLayerSizes" | "layerSizes" => Some(true),
other => return Err(bad("file.import", format!("importAs: footage|composition|compositionLayerSizes, not `{other}`"))),
};
let mut paths = paths;
let mut out_comps = vec![];
let mut ids = vec![];
let mut errors = vec![];
if let Some(retain) = retain {
let mut rest = vec![];
for path in paths {
let bytes = match s.services.read_file(&path) {
Ok(b) if effectcraft_psd::is_psd(&b) => b,
_ => {
rest.push(path);
continue;
}
};
match import_psd_comp(s, &path, bytes, retain) {
Ok((comp, items, warnings)) => {
out_comps.push(comp);
ids.extend(items);
errors.extend(warnings);
}
Err(e) => errors.push(format!("{path}: {e}")),
}
}
paths = rest;
if paths.is_empty() {
if let Some(c) = out_comps.first() {
s.open_comp(effectcraft_project::ItemId(*c));
}
return Ok(json!({"items": ids, "comps": out_comps, "errors": errors}));
}
}
let mut probed = vec![];
let psd_layer = p.get("layer").cloned();
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
for path in &paths {
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
if let Some(f) = data_footage(s, path) {
match f {
Ok(f) => probed.push((path.clone(), f)),
Err(e) => errors.push(format!("{path}: {e}")),
}
continue;
}
let Some(importer) = s.importer.clone() else {
errors.push(format!("{path}: media import is not available in this build"));
continue;
};
match importer.probe(path) {
Ok(mut f) => {
// Settings ▸ Import ▸ Sequence Footage frames per second.
@@ -99,6 +148,18 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
f.frame_rate = r;
f.duration = r.tick_of(frames.max(1));
}
// Choose Layer: one layer of a Photoshop document (document-sized).
if let Some(sel) = &psd_layer
&& f.codec == "PSD"
{
match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) {
Some((index, name)) => f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false }),
None => {
errors.push(format!("{path}: no layer {sel}"));
continue;
}
}
}
probed.push((path.clone(), f))
}
Err(e) => errors.push(format!("{path}: {e}")),
@@ -107,10 +168,15 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Import", None, |proj, st| {
for (path, f) in probed {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
let name = match &f.layer {
Some(l) => format!("{}/{name}", l.name),
None => name,
};
let label = match f.kind {
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
FootageKind::Audio => Label::SeaFoam,
FootageKind::Video | FootageKind::Model => Label::Aqua,
FootageKind::Data => Label::Sandstone,
};
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
ids.push(id.0);
@@ -118,7 +184,56 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({"items": ids, "errors": errors}))
if let Some(c) = out_comps.first() {
s.open_comp(effectcraft_project::ItemId(*c));
}
Ok(json!({"items": ids, "comps": out_comps, "errors": errors}))
}
/// A Photoshop layer by index or name (pixel layers only).
fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, String)> {
let l = match sel {
Value::Number(n) => d.layers.get(n.as_u64()? as usize)?,
Value::String(name) => d.layers.iter().find(|l| &l.name == name)?,
_ => return None,
};
Some((l.index, l.name.clone()))
}
/// Import a Photoshop document as a composition (one undo step). Returns (comp, items, warnings).
fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retain: bool) -> Result<(u64, Vec<u64>, Vec<String>)> {
let psd = effectcraft_psd::Psd::parse(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Photoshop".into());
let rate = effectcraft_time::FrameRate::FPS_29_97;
let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs));
let r = s.edit("Import", None, |proj, st| {
let r = crate::psd_import::import(proj, &psd, path, &name, retain, rate, duration);
st.project_selection = vec![r.comp];
Ok(r)
})?;
let mut items = vec![r.folder.0];
items.extend(r.items.iter().map(|i| i.0));
Ok((r.comp.0, items, r.warnings))
}
/// Extensions imported as data footage.
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(s: &Session, 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();
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
Some((|| {
let bytes = s.services.read_file(path).map_err(|e| e.to_string())?;
let text = String::from_utf8(bytes).map_err(|_| "data files must be UTF-8 text".to_string())?;
if ext == "json" {
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
}
Ok(effectcraft_project::Footage { path: path.to_string(), kind: FootageKind::Data, codec: ext.to_uppercase(), data: Some(text), ..Default::default() })
})())
}
fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
@@ -224,7 +339,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("file.saveAs", "Save As...", ["File", "Save As"], Some("Cmd+Shift+S"), "{path}", always, save_as),
cmd!("file.incrementAndSave", "Increment and Save", ["File"], Some("Cmd+Alt+Shift+S"), "{}", has_path, increment_save),
cmd!("file.revert", "Revert", ["File"], None, "{}", has_path, revert),
cmd!("file.import", "File...", ["File", "Import"], Some("Cmd+I"), "{paths: [string]}", always, import),
cmd!(
"file.import",
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer)}",
always,
import
),
cmd!(
"file.projectSettings",
"Project Settings...",
+168 -40
View File
@@ -107,6 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage {
missing: true,
sequence: vec![],
color_profile: None,
..Default::default()
}
}
@@ -442,56 +443,179 @@ fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------------------------------------------------------- footage
/// Interpret Footage settings parsed from command parameters (every field optional).
#[derive(Clone, Debug, Default)]
pub(crate) struct Interpretation {
rate: Option<FrameRate>,
/// Back to the file's own rate (Use frame rate from file).
native: bool,
alpha: Option<AlphaMode>,
/// Alpha ▸ Guess: decide Straight / Premultiplied (and the matte colour) from a frame.
guess: bool,
matte: Option<[f32; 3]>,
invert_alpha: Option<bool>,
loops: Option<u32>,
par: Option<f64>,
fields: Option<effectcraft_project::FieldOrder>,
profile: Option<Option<effectcraft_project::ColorSpace>>,
linear: Option<bool>,
}
impl Interpretation {
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Interpretation> {
let mut it = Interpretation { rate: f_p(p, "frameRate").map(FrameRate::from_f64), ..Default::default() };
if str_p(p, "frameRate") == Some("file") {
it.native = true;
}
match str_p(p, "alpha") {
Some("straight") => it.alpha = Some(AlphaMode::Straight),
Some("premultiplied") => it.alpha = Some(AlphaMode::Premultiplied),
Some("ignore") => it.alpha = Some(AlphaMode::Ignore),
Some("guess") => it.guess = true,
Some(a) => return Err(bad(cmd, format!("alpha: straight|premultiplied|ignore|guess, not `{a}`"))),
None => {}
}
it.guess |= super::b_p(p, "guessAlpha").unwrap_or(false);
it.matte = match p.get("matteColor").or(p.get("premulColor")) {
None => None,
Some(v) => Some(
effectcraft_keyframe::Value::Color([0.0, 0.0, 0.0, 1.0])
.coerce_json(v)
.map(|c| {
let c = c.as_color();
[c[0], c[1], c[2]]
})
.ok_or_else(|| bad(cmd, "matteColor: #hex or [r, g, b] (0..1)"))?,
),
};
it.invert_alpha = super::b_p(p, "invertAlpha");
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
it.fields = match str_p(p, "fields") {
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),
None => None,
};
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
it.profile = match str_p(p, "colorProfile") {
Some("auto" | "none" | "") => Some(None),
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
Some(cs) => Some(Some(cs)),
None => return Err(bad(cmd, format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
},
None => None,
};
it.linear = super::b_p(p, "linearLight");
Ok(it)
}
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
if self.native {
if let Some(n) = f.native_rate.take() {
f.frame_rate = n;
}
} else if let Some(r) = self.rate {
if f.native_rate.is_none() {
f.native_rate = Some(f.frame_rate);
}
f.frame_rate = r;
}
if let Some(a) = self.alpha {
f.alpha = a;
}
if let Some((a, m)) = guessed {
f.alpha = a;
f.premul_color = m;
}
if let Some(m) = self.matte {
f.premul_color = m;
}
if let Some(v) = self.invert_alpha {
f.invert_alpha = v;
}
if let Some(l) = self.loops {
f.loop_count = l;
}
if let Some(x) = self.par {
f.pixel_aspect = x;
}
if let Some(x) = self.fields {
f.fields = x;
}
if let Some(c) = self.profile {
f.color_profile = c;
}
if let Some(v) = self.linear {
f.linear_light = v;
}
}
}
/// Interpret Footage ▸ Alpha ▸ Guess: read the first frame as straight colour and decide
/// whether the file is premultiplied (every partly transparent pixel's colour within its alpha
/// of a black or white matte) or straight. `None` when the frame has no partial alpha.
pub(crate) fn guess_alpha(s: &Session, item: ItemId, f: &Footage) -> Option<(AlphaMode, [f32; 3])> {
let probe = Footage { alpha: AlphaMode::Straight, invert_alpha: false, ..f.clone() };
let img = s.footage.frame(item, &probe, Tick::ZERO)?;
let (mut partial, mut black, mut white) = (0usize, true, true);
let eps = 1.5 / 255.0;
for px in img.data.iter() {
let a = px[3];
if a <= eps || a >= 1.0 - eps {
continue;
}
partial += 1;
for c in &px[..3] {
// The file's colour (the decoder premultiplied it as straight).
let file = c / a;
black &= file <= a + eps;
white &= file >= 1.0 - a - eps;
}
}
if partial == 0 {
return None;
}
Some(if black {
(AlphaMode::Premultiplied, [0.0; 3])
} else if white {
(AlphaMode::Premultiplied, [1.0; 3])
} else {
(AlphaMode::Straight, f.premul_color)
})
}
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
let items = items_p(s, p);
if items.is_empty() {
return Err(bad("file.interpretFootage", "select footage"));
}
let rate = f_p(p, "frameRate").map(FrameRate::from_f64);
let alpha = match str_p(p, "alpha") {
Some("straight") => Some(AlphaMode::Straight),
Some("premultiplied") => Some(AlphaMode::Premultiplied),
Some("ignore") => Some(AlphaMode::Ignore),
Some(a) => return Err(bad("file.interpretFootage", format!("alpha: straight|premultiplied|ignore, not `{a}`"))),
None => None,
};
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let par = f_p(p, "pixelAspect");
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
let profile = match str_p(p, "colorProfile") {
Some("auto" | "none" | "") => Some(None),
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
Some(cs) => Some(Some(cs)),
None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
},
None => None,
};
let it = Interpretation::parse(p, "file.interpretFootage")?;
let guesses: Vec<Option<(AlphaMode, [f32; 3])>> = items
.iter()
.map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {
(true, Some(ItemKind::Footage(f))) => guess_alpha(s, *i, f),
_ => None,
})
.collect();
s.edit("Interpret Footage", None, |proj, _| {
for i in &items {
for (i, g) in items.iter().zip(&guesses) {
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
if let Some(r) = rate {
if f.native_rate.is_none() {
f.native_rate = Some(f.frame_rate);
}
f.frame_rate = r;
}
if let Some(a) = alpha {
f.alpha = a;
}
if let Some(l) = loops {
f.loop_count = l;
}
if let Some(x) = par {
f.pixel_aspect = x;
}
if let Some(c) = profile {
f.color_profile = c;
}
it.apply(f, *g);
}
}
Ok(())
})?;
Ok(Value::Null)
let out: Vec<Value> = items
.iter()
.filter_map(|i| match s.project.item(*i).map(|x| &x.kind) {
Some(ItemKind::Footage(f)) => Some(json!({
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
"linearLight": f.linear_light,
})),
_ => None,
})
.collect();
Ok(json!({"items": out}))
}
fn remember_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
@@ -512,6 +636,10 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
f.premul_color = src.premul_color;
f.pixel_aspect = src.pixel_aspect;
f.loop_count = src.loop_count;
f.fields = src.fields;
f.invert_alpha = src.invert_alpha;
f.linear_light = src.linear_light;
f.color_profile = src.color_profile;
if src.native_rate.is_some() {
if f.native_rate.is_none() {
f.native_rate = Some(f.frame_rate);
@@ -699,7 +827,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}",
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}",
has_footage_selection,
interpret
),
+6 -2
View File
@@ -53,7 +53,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
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: 1.0 }));
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: comp.pixel_aspect }));
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
if adjustment {
l.switches.adjustment = true;
@@ -239,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
(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 super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
}
@@ -519,7 +522,8 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
// 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 ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref() };
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)
};
+1 -1
View File
@@ -126,7 +126,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
}
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref() };
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
.layers
.iter()
+16 -1
View File
@@ -4,12 +4,16 @@
mod anim;
pub mod anim_tools;
mod animation;
mod camera_cmds;
mod comp;
mod comp_more;
mod create;
mod edit;
mod effect;
pub mod essential;
pub mod expr_tools;
mod file;
mod file_more;
pub(crate) mod file_more;
mod focus;
mod frontend;
mod help;
@@ -30,6 +34,7 @@ mod paths;
mod project_items;
mod prop;
mod prop_groups;
mod proxy;
pub mod puppet;
mod query;
mod render_queue;
@@ -127,6 +132,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(help::specs());
v.extend(query::specs());
v.extend(layer_menu::specs());
v.extend(create::specs());
v.extend(animation::specs());
v.extend(view::specs());
v.extend(viewer_cmds::specs());
@@ -141,16 +147,25 @@ pub fn command_specs() -> &'static [CommandSpec] {
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(essential::specs());
v.extend(expr_tools::specs());
v.extend(proxy::specs());
v.extend(stubs::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()
+2 -1
View File
@@ -557,7 +557,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
let fd = comp.frame_duration();
let mut t = layer.in_point;
while t < layer.out_point {
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref() };
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
t += fd;
}
+223
View File
@@ -0,0 +1,223 @@
//! Proxies (File ▸ Create Proxy / Set Proxy / Interpret Footage ▸ Proxy): a stand-in file for
//! a footage item or a composition. The Project panel's proxy switch (`file.useProxy`) turns
//! it on or off per item; renders follow Render Settings ▸ Proxy Use (the viewer uses each
//! item's switch).
use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan};
use effectcraft_project::{Footage, ItemId, ItemKind, Proxy};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_project_selection, str_p};
use crate::{EngineError, Result, Session, cmd};
/// Footage items and comps a proxy command acts on: `items`/`item`, else the Project panel
/// selection.
fn targets(s: &Session, p: &Value) -> Vec<ItemId> {
let ids: Vec<ItemId> = match p.get("items").or(p.get("item")) {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(ItemId).collect(),
Some(Value::Number(n)) => n.as_u64().map(ItemId).into_iter().collect(),
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).into_iter().collect(),
_ => s.state.project_selection.clone(),
};
ids.into_iter().filter(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_) | ItemKind::Comp(_)))).collect()
}
fn has_targets(s: &Session) -> std::result::Result<(), String> {
has_project_selection(s)?;
if targets(s, &Value::Null).is_empty() { Err("select footage or a composition in the Project panel".into()) } else { Ok(()) }
}
fn has_proxy(s: &Session) -> std::result::Result<(), String> {
has_targets(s)?;
if targets(s, &Value::Null).iter().any(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())) {
Ok(())
} else {
Err("the selected items have no proxy".into())
}
}
/// Probe a proxy file through the importer.
fn probe(s: &Session, path: &str, cmd: &str) -> Result<Footage> {
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
if !f.has_video {
return Err(bad(cmd, format!("{path} has no video to use as a proxy")));
}
Ok(f)
}
/// Attach `footage` as the proxy of `items` (Use Proxy on). One undo step.
pub(crate) fn set_proxy_footage(s: &mut Session, items: &[ItemId], footage: Footage) -> Result<()> {
s.edit("Set Proxy", None, |proj, _| {
for i in items {
if let Some(it) = proj.item_mut(*i) {
it.proxy = Some(Box::new(Proxy { footage: footage.clone(), enabled: true }));
}
}
Ok(())
})
}
fn set_proxy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.setProxy";
let items = targets(s, p);
if items.is_empty() {
return Err(bad(C, "select footage or a composition"));
}
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path`"))?.to_string();
let f = probe(s, &path, C)?;
let (w, h) = (f.width, f.height);
set_proxy_footage(s, &items, f)?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "path": path, "width": w, "height": h}))
}
fn set_proxy_none(s: &mut Session, p: &Value) -> Result<Value> {
let items = targets(s, p);
s.edit("Set Proxy None", None, |proj, _| {
for i in &items {
if let Some(it) = proj.item_mut(*i) {
it.proxy = None;
}
}
Ok(())
})?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// The Project panel's proxy switch: use the proxy (filled box) or the full-resolution source.
fn use_proxy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.useProxy";
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
if items.is_empty() {
return Err(bad(C, "the selected items have no proxy"));
}
let first = s.project.item(items[0]).and_then(|x| x.proxy.as_ref()).is_some_and(|px| px.enabled);
let on = b_p(p, "on").unwrap_or(!first);
s.edit(if on { "Use Proxy" } else { "Use Full Resolution" }, None, |proj, _| {
for i in &items {
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
px.enabled = on;
}
}
Ok(())
})?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "on": on}))
}
/// File ▸ Interpret Footage ▸ Proxy: Interpret Footage settings for the proxy file.
fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.interpretProxy";
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
if items.is_empty() {
return Err(bad(C, "the selected items have no proxy"));
}
let it = super::file_more::Interpretation::parse(p, C)?;
let guesses: Vec<_> = items
.iter()
.map(|i| {
let px = s.project.item(*i).and_then(|x| x.proxy.as_ref());
match (p.get("alpha").and_then(Value::as_str) == Some("guess") || b_p(p, "guessAlpha") == Some(true), px) {
(true, Some(px)) => super::file_more::guess_alpha(s, *i, &px.footage),
_ => None,
}
})
.collect();
s.edit("Interpret Proxy", None, |proj, _| {
for (i, g) in items.iter().zip(&guesses) {
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
it.apply(&mut px.footage, *g);
}
}
Ok(())
})?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// File ▸ Create Proxy ▸ Still / Movie: queue a half-resolution render of the composition whose
/// post-render action sets the result as its proxy.
fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.createProxy";
let cid = match p.get("comp").or(p.get("item")) {
Some(_) => {
let q = json!({"comp": p.get("comp").or(p.get("item")).cloned()});
super::comp_id(s, &q)?
}
None => s
.state
.project_selection
.iter()
.copied()
.find(|i| s.project.comp(*i).is_some())
.or(s.active_comp_id())
.ok_or_else(|| bad(C, "select a composition"))?,
};
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let still = match str_p(p, "kind").unwrap_or("movie") {
"still" => true,
"movie" => false,
k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))),
};
let mut it = RenderQueueItem::new(0, cid);
it.settings.quality = RenderQuality::Draft;
it.settings.resolution = p.get("resolution").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.05, 1.0);
if still {
// One frame at the current time (the comp's poster frame when not the active comp).
let t = if s.active_comp_id() == Some(cid) { s.time() } else { comp.poster_time };
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = OutputModule::for_format(OutputFormat::PngSequence);
it.output.output = "[compName]_proxy_[#####].png".into();
} else {
it.settings.time_span = TimeSpan::LengthOfComp;
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
it.output.output = "[compName]_proxy.[fileExtension]".into();
}
it.output.channels = Channels::Rgba;
if let Some(o) = str_p(p, "path").or(str_p(p, "output")) {
it.output.output = o.to_string();
}
it.post_render = PostRenderAction::SetProxy;
let id = s.edit("Create Proxy", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
let id = it.id;
proj.render_queue.push(it);
Ok(id)
})?;
let path = s.project.render_queue.last().and_then(|i| s.resolve_output(i));
Ok(json!({"item": id, "comp": cid.0, "kind": if still { "still" } else { "movie" }, "output": path, "time": Tick::ZERO.seconds()}))
}
fn has_comp_selection(s: &Session) -> std::result::Result<(), String> {
if s.state.project_selection.iter().any(|i| s.project.comp(*i).is_some()) || s.active_comp_id().is_some() {
Ok(())
} else {
Err("select a composition".into())
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.createProxy",
"Create Proxy",
[],
None,
"{kind: still|movie, comp?|item?, path?|output? (template), resolution? (default 0.5)}",
has_comp_selection,
create_proxy
),
cmd!("file.setProxy", "File...", ["File", "Set Proxy"], None, "{path, items?|item?}", has_targets, set_proxy),
cmd!("file.setProxyNone", "None", ["File", "Set Proxy"], None, "{items?|item?}", has_proxy, set_proxy_none),
cmd!("file.useProxy", "Use Proxy", [], None, "{items?|item?, on?}", has_proxy, use_proxy),
cmd!(
"file.interpretProxy",
"Proxy...",
["File", "Interpret Footage"],
None,
"{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",
has_proxy,
interpret_proxy
),
]
}
+1 -1
View File
@@ -29,7 +29,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
let fx = layer.effects()?;
let index = fx.groups().position(|g| g.uid == fx_uid)?;
let g = fx.groups().nth(index)?;
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
+9 -5
View File
@@ -104,6 +104,10 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
{
return Err(bad(cmd, "end must be after start"));
}
if let Some(u) = str_p(p, "proxyUse") {
rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?;
any = true;
}
match p.get("frameRate") {
Some(Value::Null) => {
rs.frame_rate = None;
@@ -131,7 +135,7 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
om.set_format(f);
any = true;
}
@@ -256,7 +260,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let mut it = RenderQueueItem::new(0, cid);
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
it.output = OutputModule::for_format(f);
}
apply_settings(&mut it.settings, p, "renderQueue.add")?;
@@ -434,7 +438,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let n = it.extra_outputs.len() + 2;
let mut om = it.output.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
om = OutputModule::for_format(f);
}
if str_p(p, "output").is_none() && str_p(p, "path").is_none() {
@@ -509,7 +513,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
"{comp?: id|name, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
can_add,
add
),
@@ -520,7 +524,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Render Settings...",
[],
None,
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool}",
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none}",
has_items,
set_render_settings
),
+1 -1
View File
@@ -239,7 +239,7 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
let root = s.default_autosave_root();
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
Ok(json!({
"lastAutoSave": s.autosave.last_path,
"latestOnDisk": latest,
-8
View File
@@ -17,18 +17,11 @@ macro_rules! stub {
pub fn specs() -> Vec<CommandSpec> {
vec![
// Render queue / export (export milestone).
stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"),
// Proxies (no proxy model yet).
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"),
stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"),
// Layer ▸ New (3D / content-aware fill).
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
// Time (timeline milestone).
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
stub!("layer.create", "Create", [], "{op}"),
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
// Cameras / lights / materials / 3D views (3D milestone).
@@ -46,7 +39,6 @@ pub fn specs() -> Vec<CommandSpec> {
// Keyframes / text / tracking.
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
stub!("track.camera", "Track Camera", ["Animation"], "{}"),
// Workspaces and panels that don't exist yet.
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
]
+2 -2
View File
@@ -393,7 +393,7 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (comp.width as f64, comp.height as f64);
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
let f = cam.forward();
let lights: Vec<Value> = comp
@@ -535,7 +535,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
/// through each layer's world matrix; cameras and lights contribute their position).
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let mut out = vec![];
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
let m = ctx.world_matrix(l);
+49 -4
View File
@@ -1,7 +1,11 @@
//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery
//! sentinel). The engine only sees the [`ConfigStore`] trait; frontends choose where it lives:
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app can back it
//! with `localStorage`, tests and headless runs use [`MemoryConfig`] (or none at all).
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app backs it
//! with the browser's Origin Private File System (IndexedDB fallback), tests and headless runs use
//! [`MemoryConfig`] (or none at all).
//!
//! Auto-saves are project files, not settings, but they live wherever the store says: the store's
//! [`FileOps`] (the file system by default; the browser's storage on the web).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -12,11 +16,52 @@ pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
fn remove(&self, name: &str) -> std::io::Result<()>;
/// A directory on disk next to the configuration (default Auto-Save folder for untitled
/// projects); `None` for stores that aren't on the file system.
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
fn dir(&self) -> Option<PathBuf> {
None
}
/// Where auto-saves are listed, written and found (default: the file system).
fn files(&self) -> &dyn FileOps {
&StdFiles
}
}
/// The file operations auto-save and crash recovery need.
pub trait FileOps: Send + Sync {
/// Files directly in `dir`: (file name, modified time).
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)>;
/// Write `data` to `path` atomically, creating its folder.
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
fn remove(&self, path: &Path) -> std::io::Result<()>;
fn is_file(&self, path: &Path) -> bool;
}
/// [`FileOps`] on the file system.
pub struct StdFiles;
impl FileOps for StdFiles {
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
rd.flatten()
.filter_map(|e| {
let m = e.metadata().ok().filter(|m| m.is_file())?.modified().ok()?;
Some((e.file_name().to_string_lossy().to_string(), m))
})
.collect()
}
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(d)?;
}
atomic_write(path, data)
}
fn remove(&self, path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path)
}
fn is_file(&self, path: &Path) -> bool {
path.is_file()
}
}
/// In-memory store (tests, headless sessions).
+56 -1
View File
@@ -9,18 +9,22 @@
//! (compositions are `Arc`s, so untouched comps are shared).
pub mod autosave;
pub mod camera_track;
pub mod commands;
pub mod config;
pub mod demo;
pub mod links;
pub mod mask_track;
pub mod menus;
pub mod offload;
pub mod prefs;
pub mod psd_import;
pub mod render_queue;
pub mod roto;
mod session_settings;
pub mod shortcuts;
pub mod tracking;
pub mod vector;
pub mod viewer;
pub mod warp;
@@ -172,6 +176,9 @@ pub struct EditorState {
/// Tracker panel ▸ Method in mask mode (Track Mask).
#[serde(default)]
pub mask_track_method: mask_track::MaskMethod,
/// 3D Camera Tracker: the selected track points (ids) in the viewer.
#[serde(default)]
pub camera_points: Vec<u32>,
/// Mask Interpolation panel options.
#[serde(default)]
pub mask_interp: commands::mask_interp::MaskInterpOptions,
@@ -202,6 +209,13 @@ pub struct EditorState {
/// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews).
#[serde(default)]
pub viewer: commands::viewer_cmds::ViewOptions,
/// Essential Graphics panel ▸ Primary composition (`None` = the active comp).
#[serde(default)]
pub essential_primary: Option<ItemId>,
/// Essential Graphics panel ▸ Solo Supported Properties (the timeline shows only properties
/// Essential Graphics can expose).
#[serde(default)]
pub essential_solo: bool,
}
fn one_view() -> u8 {
@@ -268,6 +282,10 @@ pub struct Session {
pub warp_job: Option<warp::WarpJob>,
/// Warp Stabilizers waiting for (re-)analysis: (comp, layer, effect uid).
pub warp_pending: Vec<(ItemId, LayerId, Uid)>,
/// The running (or finished, not yet polled) 3D Camera Tracker analysis.
pub camera_job: Option<camera_track::CameraJob>,
/// 3D Camera Trackers waiting for (re-)analysis: (comp, layer, effect uid).
pub camera_pending: Vec<(ItemId, LayerId, Uid)>,
/// The running (or finished, not yet polled) Roto Brush propagation / Freeze.
pub roto_job: Option<roto::RotoJob>,
/// Roto Brush instances edited since their last propagation: (comp, layer, effect uid).
@@ -277,6 +295,8 @@ pub struct Session {
pub journal: Vec<(String, Value)>,
/// Processed-layer pixels reused across frames and edits (content-keyed, never stale).
pub layer_cache: Arc<LayerCache>,
/// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled.
pub disk_cache: Option<Arc<effectcraft_render::disk_cache::DiskCache>>,
/// Settings (Preferences).
pub prefs: prefs::Prefs,
/// Bumped whenever settings change (frontends re-apply theme, labels…).
@@ -297,6 +317,11 @@ pub struct Session {
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
pub script: Option<ScriptRunner>,
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
/// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32).
pub offload: Option<Arc<dyn offload::Offload>>,
/// Analyses running in the [`Session::offload`] worker.
pub offloaded: Vec<offload::OffloadedJob>,
}
/// A script to run (see [`Session::script`]).
@@ -334,11 +359,14 @@ impl Default for Session {
mask_job: None,
warp_job: None,
warp_pending: vec![],
camera_job: None,
camera_pending: vec![],
roto_job: None,
roto_pending: vec![],
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
disk_cache: None,
prefs: prefs::Prefs::default(),
prefs_revision: 0,
config: None,
@@ -348,6 +376,8 @@ impl Default for Session {
autosave: autosave::AutoSaveState::default(),
snapshot: None,
script: None,
offload: None,
offloaded: vec![],
}
}
}
@@ -362,7 +392,8 @@ impl Session {
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(why) = (spec.enabled)(self) {
// Explicit targets (agents, scripts) don't need a UI selection.
let explicit = ["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok();
let explicit = (["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok())
|| ["item", "items"].iter().any(|k| params.get(k).is_some());
if !explicit {
return Err(EngineError::Disabled(id.to_string(), why));
}
@@ -397,12 +428,20 @@ impl Session {
let r = f(&mut p, &mut st)?;
// Layer styles: one Global Light per comp, whichever layer edited it.
effectcraft_project::styles::sync_global_light(&before, &mut p);
// Essential Properties of precomp layers follow their comps' Essential Graphics.
effectcraft_project::essential::sync_project(&before, &mut p);
// Warp Stabilizer analyses made from other frames are cleared (and queued again).
for w in warp::invalidate(&before, &mut p) {
if !self.warp_pending.contains(&w) {
self.warp_pending.push(w);
}
}
// So are 3D Camera Tracker tracks (and solves made with other settings).
for w in camera_track::invalidate(&before, &mut p) {
if !self.camera_pending.contains(&w) {
self.camera_pending.push(w);
}
}
// Roto Brush Input Keys follow the frames; edited instances propagate again.
for r in roto::sync(&before, &mut p) {
if !self.roto_pending.contains(&r) {
@@ -428,6 +467,9 @@ impl Session {
pub fn bump(&mut self) {
self.revision += 1;
if self.disk_cache.is_some() {
self.layer_cache.set_disk_salt(effectcraft_render::disk_cache::footage_salt(&self.project));
}
self.events.push(Event::ProjectChanged { revision: self.revision });
}
@@ -586,6 +628,9 @@ impl Session {
self.stop_warp();
self.warp_job = None;
self.warp_pending.clear();
self.stop_camera();
self.camera_job = None;
self.camera_pending.clear();
self.stop_roto();
self.roto_job = None;
self.roto_pending.clear();
@@ -612,8 +657,14 @@ mod tests_3d;
#[cfg(test)]
mod tests_anim_tools;
#[cfg(test)]
mod tests_camera_track;
#[cfg(test)]
mod tests_disk_cache;
#[cfg(test)]
mod tests_effects;
#[cfg(test)]
mod tests_essential;
#[cfg(test)]
mod tests_fidelity;
#[cfg(test)]
mod tests_lottie;
@@ -628,6 +679,8 @@ mod tests_model3d;
#[cfg(test)]
mod tests_project_items;
#[cfg(test)]
mod tests_proxy;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stubs;
@@ -644,6 +697,8 @@ mod tests_timeline;
#[cfg(test)]
mod tests_track;
#[cfg(test)]
mod tests_vector_import;
#[cfg(test)]
mod tests_viewer;
/// Font families available to text layers (bundled + scanned system fonts).
+10 -2
View File
@@ -196,6 +196,7 @@ fn run_work(w: MaskWork, shared: &MaskTrackShared) {
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
@@ -214,16 +215,19 @@ fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
impl Session {
/// Whether a mask track is running.
pub fn is_mask_tracking(&self) -> bool {
self.mask_job.as_ref().is_some_and(|j| !j.is_finished())
self.mask_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::MaskTrack).is_some()
}
/// Live progress of the running (or just finished) mask track.
pub fn mask_track_progress(&self) -> Option<TrackProgress> {
self.mask_job.as_ref().map(|j| j.progress())
self.mask_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::MaskTrack).map(|j| j.progress.track()))
}
/// Cancel the running mask track (frames tracked so far are kept).
pub fn stop_mask_track(&mut self) -> bool {
if self.cancel_offloaded(crate::offload::JobKind::MaskTrack) {
return true;
}
match &self.mask_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
@@ -238,6 +242,10 @@ impl Session {
return Err("a track analysis is already running".into());
}
self.poll_mask_track();
if !wait && self.offloads() {
let MaskWork { comp, layer, path, method, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Track Mask".into(), self.project.clone()));
self.history.redo.clear();
+6 -1
View File
@@ -18,7 +18,8 @@
//! An entry's shortcut is the command's default shortcut unless the line gives one (needed for
//! entries with bound parameters). Adobe-service entries (Team Projects, Libraries, Bridge, Media
//! Encoder, Behance, Creative Cloud…) are intentionally absent; the Essential Graphics workspace
//! uses the Properties panel.
//! uses the Properties panel and the Essential Graphics panel, whose templates use EffectCraft's
//! own open `.ectemplate` format.
use std::sync::OnceLock;
@@ -338,9 +339,11 @@ File
Placeholder... | file.importPlaceholder
Solid... | file.importSolid
Lottie... | file.importLottie
Essential Graphics Template... | essential.importTemplate
Export
Add to Render Queue | renderQueue.add
Lottie JSON... | file.exportLottie
Essential Graphics Template... | essential.exportTemplate
---
Find | app.find
---
@@ -819,6 +822,7 @@ Animation
Remove All Text Animators | text.removeAllAnimators
---
Add Expression | prop.setExpression
Add Property to Essential Graphics | essential.addProperty
Separate Dimensions | prop.separateDimensions
Track Camera | track.camera
Warp Stabilizer VFX | track.warpStabilizer
@@ -940,6 +944,7 @@ Window
Character | window.panel {"panel":"character"} | Cmd+6
Content-Aware Fill | window.unavailablePanel {"panel":"contentAwareFill"}
Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5
Essential Graphics | window.panel {"panel":"essentialGraphics"}
Info | window.panel {"panel":"info"} | Cmd+2
Lumetri Scopes | window.unavailablePanel {"panel":"lumetriScopes"}
Mask Interpolation | window.panel {"panel":"maskInterpolation"}
+754
View File
@@ -0,0 +1,754 @@
//! Background jobs where the platform has no threads (the browser).
//!
//! On the desktop, renders and analyses run on threads that share the session's project and
//! footage. A wasm build without atomics has one thread per instance, so the web app runs a
//! second engine instance in a Web Worker instead and talks to it with messages: an
//! [`Offload`] (set as [`Session::offload`]) ships a [`WorkerRequest`] — the serialized project,
//! the footage files it reads and the job — and feeds the worker's [`WorkerReply`]s back into an
//! [`Inbox`] that the session drains every frame ([`Session::poll_render`],
//! [`Session::poll_offload`]). The worker side is [`run_request`]: a plain session fed the
//! project runs the job blocking and reports through a callback.
//!
//! | Job | Worker runs | Reply applied on the UI thread |
//! |---|---|---|
//! | Render Queue | the export of every queued item | item status, progress; files arrive through the host |
//! | Warp Stabilizer analysis | [`Session::start_warp`] | the effect's property group (one undo step) |
//! | Track Motion / Stabilize Motion | the tracker analysis | the tracker group |
//! | Mask tracking | the mask track | the mask group |
//! | Roto Brush Freeze | the freeze | the effect group |
//! | 3D Camera Tracker analysis | [`Session::start_camera`] | the effect's property group |
//!
//! Roto Brush propagation fills the session's segmentation cache rather than the project, so it
//! keeps running on the UI thread.
//!
//! Cancelling terminates the worker: what an analysis tracked so far is dropped and the Render
//! Queue item being rendered becomes "User Stopped".
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_keyframe::ShapePath;
use effectcraft_project::render_queue::{RenderQueueItem, RenderStatus};
use effectcraft_project::tracking::TrackerSettings;
use effectcraft_project::{ItemId, ItemKind, LayerId, Project, PropGroup, Uid};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use crate::mask_track::MaskMethod;
use crate::render_queue::{ItemUpdate, JobShared, JobState};
use crate::tracking::PointValues;
use crate::{Event, Session};
/// One job for a worker.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkerRequest {
pub id: u64,
/// The project ([`Project::to_json`]).
pub project: String,
/// Footage files the job reads; the host ships their bytes before the request.
pub files: Vec<String>,
pub job: WorkerJob,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum WorkerJob {
/// Render Queue: per item, one (item with that output module, resolved output path) per
/// output module.
Render {
items: Vec<Vec<(RenderQueueItem, String)>>,
},
Warp {
comp: ItemId,
layer: LayerId,
effect: Uid,
},
Camera {
comp: ItemId,
layer: LayerId,
effect: Uid,
},
RotoFreeze {
comp: ItemId,
layer: LayerId,
effect: Uid,
},
Track {
comp: ItemId,
layer: LayerId,
tracker: Uid,
direction: crate::tracking::Direction,
settings: TrackerSettings,
points: Vec<PointValues>,
times: Vec<Tick>,
},
MaskTrack {
comp: ItemId,
layer: LayerId,
mask: Uid,
direction: crate::tracking::Direction,
path: ShapePath,
method: MaskMethod,
times: Vec<Tick>,
},
}
impl WorkerJob {
pub fn kind(&self) -> JobKind {
match self {
WorkerJob::Render { .. } => JobKind::Render,
WorkerJob::Warp { .. } => JobKind::Warp,
WorkerJob::Camera { .. } => JobKind::Camera,
WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze,
WorkerJob::Track { .. } => JobKind::Track,
WorkerJob::MaskTrack { .. } => JobKind::MaskTrack,
}
}
/// (comp, layer, property group uid) an analysis writes to.
pub fn target(&self) -> Option<(ItemId, LayerId, Uid)> {
match *self {
WorkerJob::Render { .. } => None,
WorkerJob::Warp { comp, layer, effect } | WorkerJob::Camera { comp, layer, effect } | WorkerJob::RotoFreeze { comp, layer, effect } => {
Some((comp, layer, effect))
}
WorkerJob::Track { comp, layer, tracker, .. } => Some((comp, layer, tracker)),
WorkerJob::MaskTrack { comp, layer, mask, .. } => Some((comp, layer, mask)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum JobKind {
Render,
Warp,
Camera,
RotoFreeze,
Track,
MaskTrack,
}
impl JobKind {
/// The undo step an analysis result becomes.
pub fn label(self) -> &'static str {
match self {
JobKind::Render => "Render",
JobKind::Warp => "Warp Stabilizer Analysis",
JobKind::Camera => "3D Camera Tracker Analysis",
JobKind::RotoFreeze => "Freeze",
JobKind::Track => "Analyze Track",
JobKind::MaskTrack => "Track Mask",
}
}
}
/// What a worker reports.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum WorkerReply {
/// Render Queue progress.
Render {
state: JobState,
},
/// A Render Queue item started or finished.
Item {
update: ItemUpdate,
},
/// Analysis progress (frames).
Progress {
done: u64,
total: u64,
},
/// An analysis finished: its property group after the job, and the job's message.
Group {
comp: ItemId,
layer: LayerId,
group: Box<PropGroup>,
message: String,
},
Failed {
error: String,
},
/// The job ended (always the last reply).
Done,
}
/// Replies waiting for the UI thread.
#[derive(Default)]
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);
}
pub fn take(&self) -> Vec<WorkerReply> {
std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
}
}
/// Runs jobs off the UI thread (the web app: a pool of Web Workers).
pub trait Offload: Send + Sync {
/// Start `req`; replies go to `inbox` (ending with [`WorkerReply::Done`]).
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String>;
/// Stop job `id` now (the worker is terminated; no more replies arrive).
fn cancel(&self, id: u64);
}
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
pub fn next_id() -> u64 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
/// Footage files a project reads (paths, sequence frames).
pub fn footage_files(p: &Project) -> Vec<String> {
let mut v: Vec<String> = p
.items
.values()
.filter_map(|i| match &i.kind {
ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>()),
_ => None,
})
.flatten()
.collect();
v.sort();
v.dedup();
v
}
// ---------------------------------------------------------------- progress hook (worker side)
thread_local! {
static HOOK: RefCell<Option<Box<dyn FnMut(u64, u64)>>> = const { RefCell::new(None) };
}
/// Analyses call this as frames complete; a worker forwards it (no-op elsewhere).
pub fn report(done: u64, total: u64) {
HOOK.with(|h| {
if let Ok(mut h) = h.try_borrow_mut()
&& let Some(f) = h.as_mut()
{
f(done, total);
}
});
}
fn with_hook<R>(hook: Box<dyn FnMut(u64, u64)>, f: impl FnOnce() -> R) -> R {
HOOK.with(|h| *h.borrow_mut() = Some(hook));
let r = f();
HOOK.with(|h| *h.borrow_mut() = None);
r
}
// ---------------------------------------------------------------- worker side
/// Where a worker sends its replies.
pub type Post = Rc<dyn Fn(WorkerReply)>;
/// Run `req` on `s` (a session with footage, expressions and an exporter, but no project yet),
/// blocking, reporting through `post`. Always ends with [`WorkerReply::Done`].
pub fn run_request(s: &mut Session, req: WorkerRequest, post: &Post) {
match Project::from_json(&req.project) {
Ok(p) => {
s.replace_project(p, None);
if let Err(e) = run_job(s, req.job, post.clone()) {
post(WorkerReply::Failed { error: e });
}
}
Err(e) => post(WorkerReply::Failed { error: format!("project: {e}") }),
}
post(WorkerReply::Done);
}
fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
let kind = job.kind();
let target = job.target();
match job {
WorkerJob::Render { items } => {
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
let shared = JobShared::default();
// Item updates and progress go out as they happen (progress at most every 100 ms).
let last = Cell::new(None::<web_time::Instant>);
let notify = |sh: &JobShared, force: bool| {
let ups = std::mem::take(&mut *crate::render_queue::lock(&sh.updates));
for u in ups {
post(WorkerReply::Item { update: u });
}
if force || last.get().is_none_or(|t| t.elapsed().as_millis() >= 100) {
last.set(Some(web_time::Instant::now()));
post(WorkerReply::Render { state: sh.snapshot() });
}
};
crate::render_queue::run_items(s, exporter, items, &shared, &mut |sh| notify(sh, false));
notify(&shared, true);
Ok(())
}
WorkerJob::Warp { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_warp(comp, layer, effect, true).map(|_| ())),
WorkerJob::Camera { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_camera(comp, layer, effect, true).map(|_| ())),
WorkerJob::RotoFreeze { comp, layer, effect } => analysis(s, kind, target, post, |s| {
s.start_roto(comp, layer, effect, crate::roto::RotoTask::Freeze, crate::roto::Direction::Both, true).map(|_| ())
}),
WorkerJob::Track { comp, layer, tracker, direction, settings, points, times } => analysis(s, kind, target, post, |s| {
let work = crate::tracking::Work {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
cache: s.layer_cache.clone(),
comp,
layer,
settings,
points,
times,
};
s.start_track(work, tracker, direction, true)
}),
WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times } => analysis(s, kind, target, post, |s| {
let work = crate::mask_track::MaskWork {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
cache: s.layer_cache.clone(),
comp,
layer,
path,
method,
times,
};
s.start_mask_track(work, mask, direction, true)
}),
}
}
fn analysis(
s: &mut Session,
kind: JobKind,
target: Option<(ItemId, LayerId, Uid)>,
post: Post,
run: impl FnOnce(&mut Session) -> Result<(), String>,
) -> Result<(), String> {
let (comp, layer, uid) = target.ok_or("no target")?;
let rev = s.revision;
// Progress at most every 100 ms.
let p = post.clone();
let mut last = None::<web_time::Instant>;
let hook = Box::new(move |done: u64, total: u64| {
if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 100) {
last = Some(web_time::Instant::now());
p(WorkerReply::Progress { done, total });
}
});
with_hook(hook, || run(s))?;
let message = s
.events
.iter()
.rev()
.find_map(|e| match e {
Event::Toast { message, .. } => Some(message.clone()),
_ => None,
})
.unwrap_or_default();
let failed = s.events.iter().any(|e| matches!(e, Event::Toast { error: true, .. }));
if failed {
return Err(message);
}
if s.revision == rev && kind != JobKind::Track && kind != JobKind::MaskTrack {
// Nothing written (e.g. the analysis was empty).
return Err(if message.is_empty() { "nothing was analysed".into() } else { message });
}
let group = s.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().ok_or("the analysed property is gone")?;
post(WorkerReply::Group { comp, layer, group: Box::new(group), message });
Ok(())
}
// ---------------------------------------------------------------- UI side
/// Analysis progress of an offloaded job.
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OffloadProgress {
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
}
/// Frames per second of `done` frames in `elapsed` seconds.
pub(crate) fn rate(done: u64, elapsed: f64) -> f64 {
if elapsed > 0.0 { done as f64 / elapsed } else { 0.0 }
}
impl OffloadProgress {
pub(crate) fn track(&self) -> crate::tracking::TrackProgress {
crate::tracking::TrackProgress { done: self.done, total: self.total, elapsed: self.elapsed, fps: rate(self.done, self.elapsed), ..Default::default() }
}
}
/// An analysis running in a worker.
pub struct OffloadedJob {
pub id: u64,
pub kind: JobKind,
pub comp: ItemId,
pub layer: LayerId,
pub uid: Uid,
pub inbox: Arc<Inbox>,
pub progress: OffloadProgress,
started: web_time::Instant,
}
/// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]).
pub struct RemoteRender {
pub id: u64,
pub inbox: Arc<Inbox>,
/// The queue items in the job.
pub ids: Vec<u64>,
}
impl Session {
/// Whether jobs started without `wait` go to the [`Offload`].
pub fn offloads(&self) -> bool {
self.offload.is_some()
}
/// The offloaded analysis of `kind`, if one runs.
pub fn offloaded(&self, kind: JobKind) -> Option<&OffloadedJob> {
self.offloaded.iter().find(|j| j.kind == kind && !j.progress.finished)
}
/// Send an analysis to the worker.
pub(crate) fn offload_analysis(&mut self, job: WorkerJob) -> Result<(), String> {
let off = self.offload.clone().ok_or("no background worker")?;
let kind = job.kind();
let (comp, layer, uid) = job.target().ok_or("not an analysis")?;
if self.offloaded(kind).is_some() {
return Err(format!("a {} job is already running", kind.label()));
}
let id = next_id();
let inbox = Arc::new(Inbox::default());
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
off.start(req, inbox.clone())?;
self.offloaded.push(OffloadedJob { id, kind, comp, layer, uid, inbox, progress: OffloadProgress::default(), started: web_time::Instant::now() });
self.events.push(Event::ProjectChanged { revision: self.revision });
Ok(())
}
/// Cancel the offloaded analysis of `kind` (nothing is written).
pub fn cancel_offloaded(&mut self, kind: JobKind) -> bool {
let off = self.offload.clone();
let Some(j) = self.offloaded.iter_mut().find(|j| j.kind == kind && !j.progress.finished) else { return false };
if let Some(off) = off {
off.cancel(j.id);
}
j.progress.cancelled = true;
j.progress.finished = true;
true
}
/// Apply worker replies to offloaded analyses (results become one undo step each) and drop
/// finished jobs. Frontends call this every frame while [`Session::offloaded`] is not empty.
pub fn poll_offload(&mut self) -> bool {
if self.offloaded.is_empty() {
return false;
}
let mut changed = false;
let mut results = vec![];
for j in &mut self.offloaded {
if j.progress.cancelled {
continue;
}
for r in j.inbox.take() {
changed = true;
match r {
WorkerReply::Progress { done, total } => {
j.progress.done = done;
j.progress.total = total;
}
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
WorkerReply::Failed { error } => j.progress.error = Some(error),
WorkerReply::Done => j.progress.finished = true,
WorkerReply::Render { .. } | WorkerReply::Item { .. } => {}
}
}
j.progress.elapsed = j.started.elapsed().as_secs_f64();
}
for (kind, comp, layer, group, message) in results {
let uid = group.uid;
let r = self.edit(kind.label(), None, |p, _| {
let g = p
.comp_mut(comp)
.and_then(|c| c.layer_mut(layer))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?;
*g = *group;
Ok(())
});
match r {
Ok(()) => self.events.push(Event::Toast { message, error: false }),
Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }),
}
}
let mut done = vec![];
self.offloaded.retain(|j| {
if j.progress.finished {
done.push((j.kind, j.progress.clone()));
}
!j.progress.finished
});
for (kind, p) in done {
changed = true;
if let Some(e) = p.error {
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
} else if p.cancelled {
self.events.push(Event::Toast { message: format!("{} stopped", kind.label()), error: false });
}
self.bump();
}
changed
}
/// Feed a remote render's replies into its [`JobShared`]; a cancelled one is wrapped up here
/// (the item being rendered and the ones after it become "User Stopped").
pub(crate) fn drain_remote_render(&mut self) {
let Some(job) = &self.render_job else { return };
let Some(remote) = &job.remote else { return };
let shared = job.shared.clone();
for r in remote.inbox.take() {
match r {
WorkerReply::Render { state } => {
let fin = crate::render_queue::lock(&shared.state).finished;
*crate::render_queue::lock(&shared.state) = JobState { finished: fin, ..state };
}
WorkerReply::Item { update } => crate::render_queue::lock(&shared.updates).push(update),
WorkerReply::Failed { error } => {
log::warn!("render worker: {error}");
let cur = crate::render_queue::lock(&shared.state).current;
if let Some(id) = cur {
crate::render_queue::lock(&shared.updates).push(ItemUpdate::Finished {
id,
status: RenderStatus::Failed(error),
seconds: 0.0,
output: None,
});
}
}
WorkerReply::Done => crate::render_queue::lock(&shared.state).finished = true,
_ => {}
}
}
}
}
#[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
}
#[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::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 },
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)],
},
];
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::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".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();
with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), || {
report(1, 3);
report(2, 3);
});
report(3, 3);
assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]);
}
}
+635
View File
@@ -0,0 +1,635 @@
//! Photoshop import as a composition (File ▸ Import ▸ File… with Import As: Composition or
//! Composition – Retain Layer Sizes), and Layer ▸ Create ▸ Convert to Editable Text.
//!
//! Every pixel layer becomes a footage item that names its layer (`Footage::layer`), placed in
//! a composition the size of the document. Groups become precomps (pass-through groups collapse
//! so their blend modes act on the layers below, like Photoshop); type layers become editable
//! text layers; layer masks are baked into the layer's alpha and vector masks become masks;
//! layer effects map to Layer Styles; adjustment layers become adjustment layers with the
//! matching effect (best effort); clipped layers preserve the transparency underneath.
use std::sync::Arc;
use effectcraft_color::BlendMode;
use effectcraft_color::Label;
use effectcraft_keyframe::{Gradient, Justify, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::styles::{self, STYLE_BLEND_MODES};
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Project, PropGroup, Solid, SourceLayer};
use effectcraft_psd::{Adjustment, DValue, Descriptor, Node, Psd};
use effectcraft_time::{FrameRate, Tick};
/// Photoshop blend mode key → compositor mode (`pass` = pass through, handled by collapsing).
pub fn blend_mode(key: &str) -> BlendMode {
match key {
"diss" => BlendMode::Dissolve,
"dark" => BlendMode::Darken,
"mul " => BlendMode::Multiply,
"idiv" => BlendMode::ColorBurn,
"lbrn" => BlendMode::LinearBurn,
"dkCl" => BlendMode::DarkerColor,
"lite" => BlendMode::Lighten,
"scrn" => BlendMode::Screen,
"div " => BlendMode::ColorDodge,
"lddg" => BlendMode::LinearDodge,
"lgCl" => BlendMode::LighterColor,
"over" => BlendMode::Overlay,
"sLit" => BlendMode::SoftLight,
"hLit" => BlendMode::HardLight,
"vLit" => BlendMode::VividLight,
"lLit" => BlendMode::LinearLight,
"pLit" => BlendMode::PinLight,
"hMix" => BlendMode::HardMix,
"diff" => BlendMode::Difference,
"smud" => BlendMode::Exclusion,
"fsub" => BlendMode::Subtract,
"fdiv" => BlendMode::Divide,
"hue " => BlendMode::Hue,
"sat " => BlendMode::Saturation,
"colr" => BlendMode::Color,
"lum " => BlendMode::Luminosity,
_ => BlendMode::Normal,
}
}
/// Descriptor blend mode enum (`BlnM`) → compositor mode.
fn desc_blend(v: Option<&str>) -> Option<BlendMode> {
Some(match v? {
"Nrml" => BlendMode::Normal,
"Dslv" => BlendMode::Dissolve,
"Drkn" => BlendMode::Darken,
"Mltp" => BlendMode::Multiply,
"CBrn" => BlendMode::ColorBurn,
"linearBurn" => BlendMode::LinearBurn,
"darkerColor" => BlendMode::DarkerColor,
"Lghn" => BlendMode::Lighten,
"Scrn" => BlendMode::Screen,
"CDdg" => BlendMode::ColorDodge,
"linearDodge" => BlendMode::LinearDodge,
"lighterColor" => BlendMode::LighterColor,
"Ovrl" => BlendMode::Overlay,
"SftL" => BlendMode::SoftLight,
"HrdL" => BlendMode::HardLight,
"vividLight" => BlendMode::VividLight,
"linearLight" => BlendMode::LinearLight,
"pinLight" => BlendMode::PinLight,
"hardMix" => BlendMode::HardMix,
"Dfrn" => BlendMode::Difference,
"Xclu" => BlendMode::Exclusion,
"blendSubtraction" => BlendMode::Subtract,
"blendDivide" => BlendMode::Divide,
"H " => BlendMode::Hue,
"Strt" => BlendMode::Saturation,
"Clr " => BlendMode::Color,
"Lmns" => BlendMode::Luminosity,
_ => return None,
})
}
/// What the import created.
pub struct PsdImport {
pub comp: ItemId,
pub folder: ItemId,
pub items: Vec<ItemId>,
pub warnings: Vec<String>,
}
struct Ctx<'a> {
psd: &'a Psd,
path: &'a str,
retain: bool,
folder: ItemId,
rate: FrameRate,
duration: Tick,
items: Vec<ItemId>,
warnings: Vec<String>,
}
/// Import `psd` (read from `path`) as a composition named `name`.
pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: bool, rate: FrameRate, duration: Tick) -> PsdImport {
let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder);
let mut cx = Ctx { psd, path, retain, folder, rate, duration, items: vec![], warnings: vec![] };
let comp = build_comp(proj, &mut cx, name, &psd.tree(), None);
PsdImport { comp, folder, items: cx.items, warnings: cx.warnings }
}
fn new_comp(cx: &Ctx) -> Comp {
Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration)
}
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {
let cid = proj.add_item(name, Label::Sandstone, parent, ItemKind::Comp(Arc::new(new_comp(cx))));
cx.items.push(cid);
let mut comp = new_comp(cx);
for n in nodes {
if let Some(l) = build_layer(proj, cx, &comp, n) {
comp.layers.push(l);
}
}
styles::sync_comp(&mut comp);
if let Some(item) = proj.item_mut(cid) {
item.kind = ItemKind::Comp(Arc::new(comp));
}
cid
}
fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (ItemId, (u32, u32)) {
let l = &cx.psd.layers[index];
let retain = cx.retain;
let (w, h) = if retain && !l.rect.is_empty() { (l.rect.width(), l.rect.height()) } else { (cx.psd.width, cx.psd.height) };
let file = std::path::Path::new(cx.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let f = Footage {
path: cx.path.to_string(),
kind: FootageKind::Still,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: cx.rate,
native_rate: None,
duration: Tick::ZERO,
has_video: true,
has_audio: false,
alpha: AlphaMode::Straight,
premul_color: [0.0; 3],
loop_count: 1,
codec: "PSD".into(),
missing: false,
sequence: vec![],
color_profile: None,
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty() }),
..Default::default()
};
let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
cx.items.push(id);
(id, (w, h))
}
fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Option<Layer> {
let (index, children) = match n {
Node::Layer(i) => (*i, None),
Node::Group { layer, children } => (*layer, Some(children)),
};
let pl = cx.psd.layers[index].clone();
let name = if pl.name.is_empty() { format!("Layer {}", index + 1) } else { pl.name.clone() };
let mut layer = if let Some(children) = children {
let sub = build_comp(proj, cx, &name, children, Some(cx.folder));
let mut l = build::layer(proj, comp, &name, LayerSource::Comp { item: sub }, (comp.width, comp.height), None);
// Pass-through groups composite their layers straight into the parent.
l.switches.collapse = pl.blend_key() == "pass";
l
} else if let Some(t) = &pl.text {
text_layer(proj, comp, &name, t)
} else if let Some(adj) = &pl.adjustment {
let mut l = adjustment_layer(proj, comp, &name);
add_adjustment_effect(proj, &mut l, adj, comp, &mut cx.warnings, &name);
l
} else if pl.has_pixels() || pl.fill.is_some() {
let (item, size) = footage_item(proj, cx, index, &name);
let mut l = build::layer(proj, comp, &name, LayerSource::Footage { item }, size, None);
if cx.retain && !pl.rect.is_empty() {
set_vec2(&mut l, "transform/position", [pl.rect.left as f64 + size.0 as f64 / 2.0, pl.rect.top as f64 + size.1 as f64 / 2.0]);
}
l
} else {
// Empty layers have nothing to show.
return None;
};
layer.name = comp.unique_layer_name(&name);
layer.blend_mode = blend_mode(pl.blend_key());
layer.switches.video = !pl.hidden;
layer.preserve_transparency = pl.clipping;
set_scalar(&mut layer, "transform/opacity", pl.opacity as f64 / 255.0 * 100.0);
// Vector mask → a mask in layer space.
let origin = layer_origin(&pl, cx.retain, &layer);
if let Some(vm) = pl.vector_mask.as_ref().filter(|v| !v.disabled)
&& let Some(masks) = layer.props.sub_mut("masks")
{
let mut ids = Ids(&mut proj.next_id);
for (k, sp) in vm.subpaths.iter().enumerate() {
let n = sp.knots.len();
let mut path = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: sp.closed };
for kn in &sp.knots {
let v = [kn[1][0] - origin[0], kn[1][1] - origin[1]];
path.vertices.push(v);
path.in_tangents.push([kn[0][0] - kn[1][0], kn[0][1] - kn[1][1]]);
path.out_tangents.push([kn[2][0] - kn[1][0], kn[2][1] - kn[1][1]]);
}
if n == 0 {
continue;
}
let mode = if vm.inverted { MaskMode::Subtract } else { MaskMode::Add };
masks.children.push(build::mask(&mut ids, &format!("Mask {}", k + 1), path, mode, [255, 255, 0]).into());
}
}
// Layer effects → Layer Styles; Fill Opacity → Advanced Blending.
let gl = comp.global_light.clone();
if let Some(fx) = &pl.effects
&& fx.bool("masterFXSwitch") != Some(false)
&& layer.can_have_styles()
{
apply_effects(proj, &mut layer, fx, &gl);
}
if pl.fill_opacity < 255 && layer.can_have_styles() {
let mut ids = Ids(&mut proj.next_id);
if layer.props.sub(styles::GROUP).is_none() {
let g = styles::layer_styles(&mut ids, &gl);
let at = layer.props.children.iter().position(|c| c.match_id() == "transform").map(|i| i + 1).unwrap_or(layer.props.children.len());
layer.props.children.insert(at, g.into());
}
if let Some(p) = layer.props.prop_mut("layerStyles/blendingOptions/advancedBlending/fillOpacity") {
p.value = Value::Scalar(pl.fill_opacity as f64 / 255.0 * 100.0);
}
}
Some(layer)
}
/// Where layer space's origin sits in document pixels.
fn layer_origin(pl: &effectcraft_psd::Layer, retain: bool, layer: &Layer) -> [f64; 2] {
if let Some(t) = &pl.text {
return [t.transform[4], t.transform[5]];
}
if matches!(layer.source, LayerSource::Footage { .. }) && retain && !pl.rect.is_empty() {
return [pl.rect.left as f64, pl.rect.top as f64];
}
[0.0, 0.0]
}
fn set_scalar(l: &mut Layer, path: &str, v: f64) {
if let Some(p) = l.props.prop_mut(path) {
p.value = Value::Scalar(v);
}
}
fn set_vec2(l: &mut Layer, path: &str, v: [f64; 2]) {
if let Some(p) = l.props.prop_mut(path) {
p.value = match p.value {
Value::Vec3(o) => Value::Vec3([v[0], v[1], o[2]]),
_ => Value::Vec2(v),
};
}
}
/// Split a PostScript font name into family and style (`Inter-Bold` → `Inter`, `Bold`).
fn font_names(ps: &str) -> (String, String) {
match ps.split_once('-') {
Some((fam, style)) => (fam.to_string(), style.to_string()),
None => (ps.to_string(), "Regular".to_string()),
}
}
fn text_doc(t: &effectcraft_psd::TextInfo) -> TextDoc {
let mut d = TextDoc::default();
d.set_text(&t.text);
let [xx, xy, yx, yy, _, _] = t.transform;
let scale = (xx * yy - xy * yx).abs().sqrt().max(1e-6);
if let Some(s) = t.style.size {
d.size = s * scale;
}
if let Some(f) = &t.style.font {
let (fam, style) = font_names(f);
d.font = fam;
d.style = style;
}
if let Some(c) = t.style.color {
d.fill = [c[0] as f32, c[1] as f32, c[2] as f32, 1.0];
}
d.justify = match t.style.justification {
Some(1) => Justify::Right,
Some(2) => Justify::Center,
Some(3) => Justify::JustifyLastLeft,
Some(4) => Justify::JustifyLastRight,
Some(5) => Justify::JustifyLastCenter,
Some(6) => Justify::JustifyAll,
_ => Justify::Left,
};
if let Some(tr) = t.style.tracking {
d.tracking = tr;
}
if let Some(l) = t.style.leading {
d.leading = Some(l * scale);
}
d.faux_bold = t.style.faux_bold;
d.faux_italic = t.style.faux_italic;
if t.style.box_text
&& let Some([l, top, r, b]) = t.bounds
{
d.box_size = Some([(r - l) * scale, (b - top) * scale]);
d.box_pos = [l * scale, top * scale];
}
d
}
fn text_layer(proj: &mut Project, comp: &Comp, name: &str, t: &effectcraft_psd::TextInfo) -> Layer {
let mut l = build::layer(proj, comp, name, LayerSource::Text, (0, 0), None);
if let Some(p) = l.props.prop_mut("text/sourceText") {
p.value = Value::Text(Box::new(text_doc(t)));
}
set_vec2(&mut l, "transform/position", [t.transform[4], t.transform[5]]);
let [xx, xy, _, _, _, _] = t.transform;
let rot = xy.atan2(xx).to_degrees();
if rot.abs() > 1e-6 {
set_scalar(&mut l, "transform/rotation", rot);
}
l
}
fn adjustment_layer(proj: &mut Project, comp: &Comp, name: &str) -> Layer {
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
let sid = proj.add_item(
name,
Label::Red,
Some(folder),
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
);
let mut l = build::layer(proj, comp, name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);
l.switches.adjustment = true;
l.label = Label::Purple;
l
}
fn add_effect(proj: &mut Project, l: &mut Layer, effect: &str, comp: &Comp, params: &[(&str, Value)]) {
let Some(spec) = effectcraft_effects::lookup(effect) else { return };
let mut ids = Ids(&mut proj.next_id);
let mut g = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [comp.width as f64, comp.height as f64]);
for (k, v) in params {
if let Some(p) = g.get_mut(k) {
p.value = v.clone();
}
}
if let Some(fx) = l.props.sub_mut("effects") {
fx.children.push(g.into());
}
}
fn add_adjustment_effect(proj: &mut Project, l: &mut Layer, adj: &Adjustment, comp: &Comp, warnings: &mut Vec<String>, name: &str) {
match adj {
Adjustment::Invert => add_effect(proj, l, "Invert", comp, &[]),
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
add_effect(proj, l, "Brightness & Contrast", comp, &[("brightness", Value::Scalar(*brightness)), ("contrast", Value::Scalar(*contrast))])
}
Adjustment::HueSaturation { hue, saturation, lightness, colorize } => {
let mut p = vec![("hue", Value::Scalar(*hue)), ("saturation", Value::Scalar(*saturation)), ("lightness", Value::Scalar(*lightness))];
if *colorize {
p = vec![
("colorize", Value::Bool(true)),
("colorizeHue", Value::Scalar(*hue)),
("colorizeSaturation", Value::Scalar(*saturation)),
("colorizeLightness", Value::Scalar(*lightness)),
];
}
add_effect(proj, l, "Hue/Saturation", comp, &p)
}
Adjustment::Levels { in_black, in_white, gamma, out_black, out_white } => add_effect(
proj,
l,
"Levels",
comp,
&[
("inBlack", Value::Scalar(in_black / 255.0)),
("inWhite", Value::Scalar(in_white / 255.0)),
("gamma", Value::Scalar(*gamma)),
("outBlack", Value::Scalar(out_black / 255.0)),
("outWhite", Value::Scalar(out_white / 255.0)),
],
),
Adjustment::Exposure { exposure, offset, gamma } => add_effect(
proj,
l,
"Exposure",
comp,
&[("exposure", Value::Scalar(*exposure)), ("offset", Value::Scalar(*offset)), ("gamma", Value::Scalar(*gamma))],
),
Adjustment::Other(k) => warnings.push(format!("{name}: adjustment `{k}` is not converted (layer kept as an empty adjustment layer)")),
}
}
// ---------------------------------------------------------------- layer effects → styles
fn style_mode(m: BlendMode) -> Value {
Value::Enum(STYLE_BLEND_MODES.iter().position(|x| x.1 == m).unwrap_or(0) as u32)
}
fn set(g: &mut PropGroup, k: &str, v: Value) {
if let Some(p) = g.get_mut(k) {
p.value = v;
}
}
fn color4(c: [f64; 3]) -> Value {
Value::Color([c[0], c[1], c[2], 1.0])
}
/// Copy the common style settings (`Md `, `Clr `, `Opct`, angle, distance, size…).
fn common(g: &mut PropGroup, d: &Descriptor) {
if let Some(m) = desc_blend(d.enum_value("Md ")) {
set(g, "blendMode", style_mode(m));
}
if let Some(c) = d.color("Clr ") {
set(g, "color", color4(c));
}
for (k, prop) in [("Opct", "opacity"), ("Nose", "noise"), ("Dstn", "distance"), ("blur", "size"), ("lagl", "angle"), ("ShdN", "jitter"), ("Inpr", "range")]
{
if let Some(v) = d.num(k) {
set(g, prop, Value::Scalar(v));
}
}
if let Some(b) = d.bool("uglg") {
set(g, "useGlobalLight", Value::Bool(b));
}
}
fn gradient_of(d: &Descriptor) -> Option<Gradient> {
let g = d.obj("Grad")?;
let list = |k: &str| match g.get(k) {
Some(DValue::List(v)) => v.clone(),
_ => vec![],
};
let colors: Vec<(f64, [f32; 4])> = list("Clrs")
.iter()
.filter_map(|c| match c {
DValue::Descriptor(s) => {
let col = s.color("Clr ")?;
Some((s.num("Lctn").unwrap_or(0.0) / 4096.0, [col[0] as f32, col[1] as f32, col[2] as f32, 1.0]))
}
_ => None,
})
.collect();
let opacities: Vec<(f64, f32)> = list("Trns")
.iter()
.filter_map(|c| match c {
DValue::Descriptor(s) => Some((s.num("Lctn").unwrap_or(0.0) / 4096.0, (s.num("Opct").unwrap_or(100.0) / 100.0) as f32)),
_ => None,
})
.collect();
(!colors.is_empty()).then(|| Gradient { colors, opacities: if opacities.is_empty() { vec![(0.0, 1.0), (1.0, 1.0)] } else { opacities } })
}
/// The effect descriptor for `key`, or the first enabled one of its `…Multi` list.
fn effect<'a>(fx: &'a Descriptor, key: &str, multi: &str) -> Option<&'a Descriptor> {
fx.obj(key).or_else(|| match fx.get(multi) {
Some(DValue::List(v)) => v.iter().find_map(|x| match x {
DValue::Descriptor(d) if d.bool("enab") != Some(false) => Some(d),
_ => None,
}),
_ => None,
})
}
fn apply_effects(proj: &mut Project, layer: &mut Layer, fx: &Descriptor, gl: &styles::GlobalLight) {
const MAP: &[(&str, &str, &str)] = &[
("DrSh", "dropShadowMulti", "dropShadow"),
("IrSh", "innerShadowMulti", "innerShadow"),
("OrGl", "outerGlowMulti", "outerGlow"),
("IrGl", "innerGlowMulti", "innerGlow"),
("ebbl", "bevelEmbossMulti", "bevelEmboss"),
("ChFX", "satinMulti", "satin"),
("SoFi", "solidFillMulti", "colorOverlay"),
("GrFl", "gradientFillMulti", "gradientOverlay"),
("FrFX", "frameFXMulti", "stroke"),
];
for (key, multi, id) in MAP {
let Some(d) = effect(fx, key, multi) else { continue };
let enabled = d.bool("enab").unwrap_or(true);
let mut ids = Ids(&mut proj.next_id);
let Some(uid) = styles::add_style(layer, &mut ids, id, gl, enabled) else { continue };
let Some(g) = layer.props.find_group_mut(uid) else { continue };
common(g, d);
match *id {
"dropShadow" => {
if let Some(v) = d.num("Ckmt") {
set(g, "spread", Value::Scalar(v));
}
if let Some(b) = d.bool("layerConceals") {
set(g, "knocksOut", Value::Bool(b));
}
}
"innerShadow" | "innerGlow" => {
if let Some(v) = d.num("Ckmt") {
set(g, "choke", Value::Scalar(v));
}
if d.enum_value("glwS") == Some("SrcC") {
set(g, "source", Value::Enum(0));
}
}
"outerGlow" => {
if let Some(v) = d.num("Ckmt") {
set(g, "spread", Value::Scalar(v));
}
}
"bevelEmboss" => {
let style = match d.enum_value("bvlS") {
Some("OtrB") => 0,
Some("Embs") => 2,
Some("PlEb") => 3,
Some("strokeEmboss") => 4,
_ => 1,
};
set(g, "style", Value::Enum(style));
let tech = match d.enum_value("bvlT") {
Some("PrBL") => 1,
Some("Slmt") => 2,
_ => 0,
};
set(g, "technique", Value::Enum(tech));
if let Some(v) = d.num("srgR") {
set(g, "depth", Value::Scalar(v));
}
set(g, "direction", Value::Enum(u32::from(d.enum_value("bvlD") == Some("Out "))));
if let Some(v) = d.num("Sftn") {
set(g, "soften", Value::Scalar(v));
}
if let Some(v) = d.num("Lald") {
set(g, "altitude", Value::Scalar(v));
}
for (k, p) in [("hglM", "highlightMode"), ("sdwM", "shadowMode")] {
if let Some(m) = desc_blend(d.enum_value(k)) {
set(g, p, style_mode(m));
}
}
for (k, p) in [("hglC", "highlightColor"), ("sdwC", "shadowColor")] {
if let Some(c) = d.color(k) {
set(g, p, color4(c));
}
}
for (k, p) in [("hglO", "highlightOpacity"), ("sdwO", "shadowOpacity")] {
if let Some(v) = d.num(k) {
set(g, p, Value::Scalar(v));
}
}
}
"satin" => {
if let Some(b) = d.bool("Invr") {
set(g, "invert", Value::Bool(b));
}
}
"gradientOverlay" => {
if let Some(gr) = gradient_of(d) {
set(g, "colors", Value::Gradient(gr));
}
if let Some(a) = d.num("Angl") {
set(g, "angle", Value::Scalar(a));
}
let st = match d.enum_value("Type") {
Some("Rdl ") => 1,
Some("Angl") => 2,
Some("Rflc") => 3,
Some("Dmnd") => 4,
_ => 0,
};
set(g, "style", Value::Enum(st));
if let Some(b) = d.bool("Rvrs") {
set(g, "reverse", Value::Bool(b));
}
if let Some(b) = d.bool("Algn") {
set(g, "alignWithLayer", Value::Bool(b));
}
if let Some(v) = d.num("Scl ") {
set(g, "scale", Value::Scalar(v));
}
}
"stroke" => {
if let Some(v) = d.num("Sz ") {
set(g, "size", Value::Scalar(v));
}
let pos = match d.enum_value("Styl") {
Some("InsF") => 1,
Some("CtrF") => 2,
_ => 0,
};
set(g, "position", Value::Enum(pos));
}
_ => {}
}
}
}
/// Layer ▸ Create ▸ Convert to Editable Text: a footage layer showing a Photoshop type layer
/// becomes a text layer (`None` when the layer's source has no text data).
pub fn editable_text(proj: &mut Project, comp: &Comp, layer: &Layer, psd: &Psd, index: usize) -> Option<Layer> {
let t = psd.layers.get(index)?.text.as_ref()?;
let mut l = text_layer(proj, comp, &layer.name, t);
l.name = layer.name.clone();
l.blend_mode = layer.blend_mode;
l.switches.video = layer.switches.video;
l.in_point = layer.in_point;
l.out_point = layer.out_point;
l.start_time = layer.start_time;
if let (Some(o), Some(p)) = (layer.props.prop("transform/opacity"), l.props.prop_mut("transform/opacity")) {
*p = effectcraft_project::Property { uid: p.uid, ..o.clone() };
}
Some(l)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blend_keys() {
assert_eq!(blend_mode("mul "), BlendMode::Multiply);
assert_eq!(blend_mode("smud"), BlendMode::Exclusion);
assert_eq!(blend_mode("pass"), BlendMode::Normal);
assert_eq!(desc_blend(Some("linearDodge")), Some(BlendMode::LinearDodge));
assert_eq!(font_names("Inter-Bold"), ("Inter".into(), "Bold".into()));
}
}
+81 -18
View File
@@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{ExprHost, FootageSource};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::{Event, Session};
@@ -54,7 +54,7 @@ pub trait Exporter: Send + Sync {
pub const CANCELLED: &str = "cancelled";
/// Live progress of a render job (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct JobState {
/// Item being rendered.
pub current: Option<u64>,
@@ -73,7 +73,8 @@ pub struct JobState {
}
/// Per-item status changes from the job thread, applied to the project by `poll_render`.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "camelCase")]
pub enum ItemUpdate {
Started { id: u64, unix: u64 },
Finished { id: u64, status: RenderStatus, seconds: f64, output: Option<String> },
@@ -86,7 +87,7 @@ pub struct JobShared {
pub updates: Mutex<Vec<ItemUpdate>>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
@@ -100,6 +101,8 @@ impl JobShared {
pub struct RenderJob {
pub shared: Arc<JobShared>,
thread: Option<std::thread::JoinHandle<()>>,
/// Rendering in a worker ([`crate::offload`]).
pub remote: Option<crate::offload::RemoteRender>,
}
impl RenderJob {
@@ -132,7 +135,19 @@ fn unix_now() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn run_work(w: Work, shared: &JobShared) {
/// Render `items` with the session's footage, expressions and GPU (a worker's render).
pub(crate) fn run_items(
s: &Session,
exporter: Arc<dyn Exporter>,
items: Vec<Vec<(RenderQueueItem, String)>>,
shared: &JobShared,
notify: &mut dyn FnMut(&JobShared),
) {
let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items };
run_work(work, shared, notify);
}
fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
let t0 = web_time::Instant::now();
{
let mut s = lock(&shared.state);
@@ -154,18 +169,22 @@ fn run_work(w: Work, shared: &JobShared) {
s.items_done = k;
s.remaining = None;
}
notify(shared);
// Every output module encodes the same frames (Composition ▸ Add Output Module); the
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
let rr = w.exporter.export(&job, &mut |done, total| {
let mut s = lock(&shared.state);
s.done = done;
s.total = total;
s.elapsed = t0.elapsed().as_secs_f64();
s.item_elapsed = t1.elapsed().as_secs_f64();
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
{
let mut s = lock(&shared.state);
s.done = done;
s.total = total;
s.elapsed = t0.elapsed().as_secs_f64();
s.item_elapsed = t1.elapsed().as_secs_f64();
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
}
notify(shared);
!shared.cancel.load(Ordering::Relaxed)
});
match rr {
@@ -187,6 +206,7 @@ fn run_work(w: Work, shared: &JobShared) {
}
};
lock(&shared.updates).push(up);
notify(shared);
}
let mut s = lock(&shared.state);
s.items_done = w.items.len();
@@ -245,10 +265,12 @@ impl Session {
}
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
/// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered.
/// wasm32 has no threads: the render always runs to completion before this returns.
/// renders, or the [`Session::offload`] worker; call [`Session::poll_render`] regularly).
/// Returns the ids being rendered. Without threads or an offload (wasm32) the render always
/// runs to completion before this returns.
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
let wait = wait || cfg!(target_arch = "wasm32");
let offload = self.offload.clone().filter(|_| !wait);
let wait = wait || (cfg!(target_arch = "wasm32") && offload.is_none());
if self.is_rendering() {
return Err("a render is already in progress".into());
}
@@ -291,15 +313,31 @@ impl Session {
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
}
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
if let Some(off) = offload {
let id = crate::offload::next_id();
let inbox = Arc::new(crate::offload::Inbox::default());
let req = crate::offload::WorkerRequest {
id,
project: self.project.to_json(),
files: crate::offload::footage_files(&self.project),
job: crate::offload::WorkerJob::Render { items },
};
off.start(req, inbox.clone())?;
let shared = Arc::new(JobShared::default());
lock(&shared.state).items_total = ids.len();
self.render_job = Some(RenderJob { shared, thread: None, remote: Some(crate::offload::RemoteRender { id, inbox, ids: ids.clone() }) });
self.poll_render();
return Ok(ids);
}
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
let shared = Arc::new(JobShared::default());
if wait {
run_work(work, &shared);
self.render_job = Some(RenderJob { shared, thread: None });
run_work(work, &shared, &mut |_| {});
self.render_job = Some(RenderJob { shared, thread: None, remote: None });
} else {
let sh = shared.clone();
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?;
self.render_job = Some(RenderJob { shared, thread: Some(thread) });
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh, &mut |_| {})).map_err(|e| e.to_string())?;
self.render_job = Some(RenderJob { shared, thread: Some(thread), remote: None });
}
self.poll_render();
Ok(ids)
@@ -310,6 +348,25 @@ impl Session {
match &self.render_job {
Some(j) if !j.is_finished() => {
j.cancel();
if let Some(r) = &j.remote {
// A worker can't be interrupted mid-frame: terminate it and stop what's left.
if let Some(off) = &self.offload {
off.cancel(r.id);
}
self.drain_remote_render();
let j = self.render_job.as_ref().expect("job");
let r = j.remote.as_ref().expect("remote");
let finished: Vec<u64> =
lock(&j.shared.updates).iter().filter_map(|u| if let ItemUpdate::Finished { id, .. } = u { Some(*id) } else { None }).collect();
for id in &r.ids {
let done = finished.contains(id)
|| self.project.render_queue.iter().any(|i| i.id == *id && matches!(i.status, RenderStatus::Done | RenderStatus::Failed(_)));
if !done {
lock(&j.shared.updates).push(ItemUpdate::Finished { id: *id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
}
}
lock(&j.shared.state).finished = true;
}
true
}
_ => false,
@@ -319,6 +376,7 @@ impl Session {
/// Apply job updates to the queue items; drop the job once it finished. Returns whether
/// anything changed. Frontends call this every frame while rendering.
pub fn poll_render(&mut self) -> bool {
self.drain_remote_render();
let Some(job) = &self.render_job else { return false };
let updates = std::mem::take(&mut *lock(&job.shared.updates));
let finished = job.is_finished();
@@ -388,6 +446,11 @@ impl Session {
}
let importer = self.importer.clone().ok_or("media import is not available in this build")?;
let footage = importer.probe(path)?;
if item.post_render == PostRenderAction::SetProxy {
crate::commands::proxy_set(self, item.comp, footage).map_err(|e| e.to_string())?;
self.toast(format!("Proxy set from {path}"));
return Ok(None);
}
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let replace = item.post_render == PostRenderAction::ImportAndReplace;
let comp = item.comp;
+29 -4
View File
@@ -10,6 +10,7 @@
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
//! mattes made from other frames.
use crate::offload::JobKind;
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -62,7 +63,7 @@ impl Default for RotoOptions {
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
/// What a job does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RotoTask {
Propagate,
@@ -378,6 +379,7 @@ fn run_work(w: Work, shared: &RotoShared) {
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
crate::offload::report(s.done, s.total);
}
if w.task == RotoTask::Freeze {
*lock(&shared.frozen) = Some(frozen);
@@ -402,19 +404,37 @@ pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction)
impl Session {
pub fn is_roto_running(&self) -> bool {
self.roto_job.as_ref().is_some_and(|j| !j.is_finished())
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::RotoFreeze).is_some()
}
pub fn roto_progress(&self) -> Option<RotoProgress> {
self.roto_job.as_ref().map(|j| j.progress())
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::RotoFreeze).map(|j| {
let p = &j.progress;
RotoProgress {
task: Some(RotoTask::Freeze),
done: p.done,
total: p.total,
elapsed: p.elapsed,
fps: crate::offload::rate(p.done, p.elapsed),
..Default::default()
}
})
})
}
/// (comp, layer, effect uid, task) of the running job.
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task))
self.roto_job
.as_ref()
.map(|j| (j.comp, j.layer, j.effect, j.task))
.or_else(|| self.offloaded(JobKind::RotoFreeze).map(|j| (j.comp, j.layer, j.uid, RotoTask::Freeze)))
}
pub fn stop_roto(&mut self) -> bool {
if self.cancel_offloaded(JobKind::RotoFreeze) {
return true;
}
match &self.roto_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
@@ -471,6 +491,11 @@ impl Session {
task,
};
let strokes = str_of(g, fx::STROKES);
if !wait && task == RotoTask::Freeze && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::RotoFreeze { comp, layer, effect })?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(RotoShared::default());
let thread = if wait {
+3 -3
View File
@@ -16,8 +16,8 @@ fn demo() -> Session {
}
/// Records exports; fails items whose path contains "fail"; honours cancellation.
struct MockExporter {
log: Mutex<Vec<String>>,
pub(crate) struct MockExporter {
pub(crate) log: Mutex<Vec<String>>,
}
impl Exporter for MockExporter {
@@ -159,6 +159,6 @@ fn without_exporter() {
s.execute("renderQueue.add", json!({})).unwrap();
assert!(!s.is_enabled("renderQueue.render"));
let f = s.execute("renderQueue.formats", json!({})).unwrap();
assert_eq!(f["formats"].as_array().unwrap().len(), 7);
assert_eq!(f["formats"].as_array().unwrap().len(), 10);
assert_eq!(f["formats"][0]["available"], false);
}
+47 -1
View File
@@ -27,6 +27,7 @@ impl Session {
self.prefs.normalize();
self.layer_cache.set_budget(self.prefs.layer_cache_bytes());
self.footage.set_cache_budget(self.prefs.media_cache_bytes());
self.configure_disk_cache();
// Fewer undo levels apply right away.
let levels = self.prefs.general.undo_levels.max(1) as usize;
if self.history.undo.len() > levels {
@@ -36,6 +37,43 @@ impl Session {
self.prefs_revision += 1;
}
/// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache
/// and attach it to the layer cache. Without a folder setting the platform cache folder is
/// used, but only by frontends with a settings store (headless sessions stay off disk).
pub fn configure_disk_cache(&mut self) {
use effectcraft_render::disk_cache::{DiskCache, default_folder, footage_salt};
let d = &self.prefs.disk;
let folder = if d.disk_cache_folder.trim().is_empty() {
(self.config.is_some() && !cfg!(target_arch = "wasm32")).then(default_folder)
} else {
Some(std::path::PathBuf::from(d.disk_cache_folder.trim()))
};
let max = d.disk_cache_max_gb.max(1) as u64 * (1 << 30);
// (no file system in the browser: the disk cache stays off there)
let Some(folder) = folder.filter(|_| d.disk_cache_enabled && !cfg!(target_arch = "wasm32")) else {
self.disk_cache = None;
self.layer_cache.set_disk(None);
return;
};
let dc = match &self.disk_cache {
Some(c) if c.folder() == folder.as_path() => {
c.set_max_bytes(max);
c.clone()
}
_ => match DiskCache::open(&folder, max) {
Ok(c) => c,
Err(e) => {
log::warn!("disk cache at {}: {e}", folder.display());
self.disk_cache = None;
self.layer_cache.set_disk(None);
return;
}
},
};
self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project))));
self.disk_cache = Some(dc);
}
/// Write settings to the config store.
pub fn save_prefs(&self) {
if let Some(c) = &self.config
@@ -89,11 +127,19 @@ impl Session {
self.config.as_ref().and_then(|c| c.dir())
}
/// Where auto-saves live: the config store's [`crate::config::FileOps`], else the file system.
pub fn file_ops(&self) -> &dyn crate::config::FileOps {
match &self.config {
Some(c) => c.files(),
None => &crate::config::StdFiles,
}
}
/// Write an auto-save now (whether or not the project is dirty). Returns its path.
pub fn autosave_now(&mut self) -> Result<String> {
let json = self.project.to_json();
let root = self.default_autosave_root();
let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
let (path, slot) = autosave::write_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
.map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
let p = path.to_string_lossy().to_string();
self.autosave.last_slot = Some(slot);
+307
View File
@@ -0,0 +1,307 @@
//! The 3D Camera Tracker end to end through the commands, on synthetic footage rendered in-test:
//! textured planes (a floor, a back wall and a box face) seen through a known moving camera.
use effectcraft_effects::camera_tracker as ct;
use effectcraft_geom::vec3;
use effectcraft_project::LayerId;
use effectcraft_raster::Image;
use effectcraft_render::EvalCtx;
use effectcraft_render::three_d::camera::active_camera;
use effectcraft_track::camtrack::linalg::{self, M3, V3};
use serde_json::{Value, json};
use crate::Session;
use crate::tests_track::{H, W, frame_time, setup, texture};
const F: f64 = 260.0;
/// The true camera on frame `f`: (world → camera rotation, centre).
fn true_camera(f: u32) -> (M3, V3) {
let t = f as f64 / 24.0;
let c = [-1.0 + 2.0 * t, -0.3 + 0.2 * (t * 3.0).sin(), 0.5 * t];
let fwd = linalg::normalize(linalg::sub([0.2 * t, 0.4, 8.0], c));
let right = linalg::normalize(linalg::cross([0.0, 1.0, 0.0], fwd));
([right, linalg::cross(fwd, right), fwd], c)
}
/// Planes: (point, normal, u axis, v axis, half extent or 0 = infinite).
const PLANES: [(V3, V3, V3, V3, f64); 3] = [
([0.0, 1.5, 0.0], [0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 0.0),
([0.0, 0.0, 10.0], [0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.0),
([-1.2, 0.2, 6.0], [0.3, 0.0, -1.0], [0.957_826, 0.0, 0.287_348], [0.0, 1.0, 0.0], 1.1),
];
fn scene_frame(f: u32) -> Image {
let (r, c) = true_camera(f);
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let d = linalg::mtv(&r, [(x as f64 + 0.5 - W as f64 / 2.0) / F, (y as f64 + 0.5 - H as f64 / 2.0) / F, 1.0]);
let mut best = (f64::INFINITY, 0.2f32);
for (i, (p0, n, ua, va, ext)) in PLANES.iter().enumerate() {
let den = linalg::dot(*n, d);
if den.abs() < 1e-9 {
continue;
}
let tt = linalg::dot(*n, linalg::sub(*p0, c)) / den;
if tt <= 0.1 || tt >= best.0 {
continue;
}
let hit = linalg::add(c, linalg::scale(d, tt));
let (u, v) = (linalg::dot(linalg::sub(hit, *p0), *ua), linalg::dot(linalg::sub(hit, *p0), *va));
if *ext > 0.0 && (u.abs() > *ext || v.abs() > *ext) {
continue;
}
best = (tt, texture(u * 40.0, v * 40.0, 11 + i as u32));
}
img.set(x, y, [best.1, best.1, best.1, 1.0]);
}
}
img
}
/// The comp with the clip tracked (blocking) with `params`.
fn tracked(params: Value) -> (Session, LayerId) {
let (mut s, clip, _) = setup(scene_frame);
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
assert!(s.is_enabled("track.camera"));
let mut p = params;
p["wait"] = json!(true);
let r = s.execute_checked("track.camera", p).unwrap();
assert_eq!(r["frames"], 25, "{r}");
assert!(s.camera_job.is_none());
(s, clip)
}
fn status(s: &mut Session) -> Value {
s.execute("camera.solveStatus", json!({})).unwrap()
}
#[test]
fn track_camera_solves_and_creates_layers_on_the_plane() {
let (mut s, clip) = tracked(json!({}));
let st = status(&mut s);
assert_eq!(st["solved"], true, "{st}");
let err = st["averageError"].as_f64().unwrap();
assert!(err < 0.5, "average error {err}");
let f = st["focalLength"].as_f64().unwrap();
assert!((f - F).abs() / F < 0.05, "focal {f}");
assert_ne!(st["methodUsed"], "Tripod Pan");
// Method Used / Average Error are reported in the effect.
let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty()));
// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
// ones seen below the horizon at the bottom of the frame).
s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap();
let pts = s.execute("camera.points", json!({})).unwrap();
let list = pts["points"].as_array().unwrap();
assert!(list.len() > 30, "{}", list.len());
let floor: Vec<u64> = list.iter().filter(|p| p["comp"][1].as_f64().unwrap() > H as f64 * 0.8).map(|p| p["id"].as_u64().unwrap()).take(8).collect();
assert!(floor.len() >= 3, "{floor:?}");
s.execute("camera.selectPoints", json!({"points": floor})).unwrap();
assert_eq!(s.state.camera_points.len(), floor.len());
// Create Solid and Camera: one undo step, camera + solid.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
let cam = LayerId(r["camera"].as_u64().unwrap());
let solid = LayerId(r["layers"][0].as_u64().unwrap());
let comp = s.active_comp().unwrap().clone();
assert!(comp.layer(cam).unwrap().is_camera());
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
assert_eq!(comp.layers[0].id, cam);
let sl = comp.layer(solid).unwrap();
assert!(sl.switches.three_d);
assert!(sl.name.starts_with("Track Solid"));
// The camera is keyed on every frame.
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
// Every solved point projects through the created camera exactly where the solve puts it,
// and onto its tracked position (the footage fills the comp, so layer pixels are comp
// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
// features on occlusion edges), 0.5 px on average.
let cid = s.active_comp_id().unwrap();
let uid = g.uid;
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap();
let mut errs = vec![];
for k in [0u32, 6, 12, 18, 24] {
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k));
let camst = active_camera(&ctx);
for sp in pl.solve.visible(k as usize) {
let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue };
let w = pl.to_world(sp.pos);
let q = camst.project(W as f64, H as f64, vec3(w[0], w[1], w[2])).unwrap();
let own = pl.solve.frames[k as usize].project(pl.solve.size, sp.pos).unwrap();
assert!((q.x - own[0]).hypot(q.y - own[1]) < 0.01, "camera layer disagrees with the solve");
errs.push((q.x - obs[0]).hypot(q.y - obs[1]));
}
}
assert!(errs.len() > 100, "{}", errs.len());
errs.sort_by(f64::total_cmp);
let p95 = errs[errs.len() * 95 / 100];
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px");
// The solid lies on the selected points' plane.
let tr = sl.props.sub("transform").unwrap();
let pos = tr.get("position").unwrap().value.as_vec3();
let ori = tr.get("orientation").unwrap().value.as_vec3();
let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]);
let size = r["target"]["size"].as_f64().unwrap();
for id in &s.state.camera_points {
let w = pl.to_world(pl.solve.point(*id).unwrap().pos);
let d = linalg::dot(linalg::sub(w, pos), normal).abs();
assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane");
}
// The solid faces the camera.
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12));
let eye = active_camera(&ctx).eye;
assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0);
// Undo removes both layers; redo brings them back.
let before = comp.layers.len();
assert!(s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layers.len(), before);
// A second create reuses the camera: Create Multiple Nulls and Camera.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap();
assert_eq!(r["camera"].as_u64().unwrap(), cam.0);
assert_eq!(r["layers"].as_array().unwrap().len(), floor.len());
// Text and Shadow Catcher (+ light).
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap();
assert_eq!(r["layers"].as_array().unwrap().len(), 1);
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "shadowCatcher"})).unwrap();
let ids: Vec<LayerId> = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect();
let comp = s.active_comp().unwrap();
assert!(comp.layer(ids[0]).unwrap().name.starts_with("Shadow Catcher"));
assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2);
assert!(comp.layer(ids[1]).unwrap().is_light());
assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err());
// Set Ground Plane and Origin: the target becomes the origin; a null created there sits at
// (0, 0, 0) lying on the X-Z plane (its front facing up, −y).
s.execute_checked("camera.setGroundPlane", json!({})).unwrap();
assert_eq!(status(&mut s)["groundPlane"], true);
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap();
let null = LayerId(r["layers"][0].as_u64().unwrap());
let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone();
let pos = tr.get("position").unwrap().value.as_vec3();
assert!(linalg::norm(pos) < 1e-6, "{pos:?}");
let up = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(up, [0.0, -1.0, 0.0])) < 1e-6, "{up:?}");
// An explicit target (comp world) places the layer there, its front facing the normal.
let r = s
.execute_checked("camera.createFromSolve", json!({"kind": "solid", "target": {"center": [10.0, 0.0, 20.0], "normal": [0.0, 0.0, -1.0], "size": 80.0}}))
.unwrap();
let l = s.active_comp().unwrap().layer(LayerId(r["layers"][0].as_u64().unwrap())).unwrap().clone();
let tr = l.props.sub("transform").unwrap();
let pos = tr.get("position").unwrap().value.as_vec3();
assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}");
let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}");
// Serde keeps the analysis.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
let g2 = back.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap();
let sv = ct::solve(&crate::camera_track::static_params(g2)).unwrap();
assert!(sv.ground.is_some());
assert_eq!(sv.points.len(), pl.solve.points.len());
}
#[test]
fn invalidation_resolve_cancel_and_undo() {
let (mut s, clip) = tracked(json!({"shotType": "specify", "aov": 2.0 * (W as f64 / 2.0 / F).atan().to_degrees()}));
let st = status(&mut s);
assert_eq!(st["solved"], true, "{st}");
assert_eq!(st["shotType"], "Specify Angle of View");
assert!((st["focalLength"].as_f64().unwrap() - F).abs() < 0.01 * F);
let cid = s.active_comp_id().unwrap();
let uid = st["effect"].as_u64().unwrap();
// Undo removes the analysis (one step); redo restores it.
assert!(s.undo());
assert_eq!(status(&mut s)["analyzed"], false);
assert!(s.redo());
assert_eq!(status(&mut s)["solved"], true);
// Changing the Solve Method keeps the tracks and only re-solves.
s.execute_checked("prop.set", json!({"layer": clip.0, "path": "effects/#1/advanced/solveMethod", "value": 1})).unwrap();
let st = status(&mut s);
assert_eq!(st["analyzed"], true);
assert_eq!(st["solved"], false);
assert_eq!(st["pending"], true);
s.execute_checked("camera.analyze", json!({"wait": true})).unwrap();
let st = status(&mut s);
assert_eq!(st["solved"], true, "{st}");
assert_eq!(st["methodUsed"], "Typical");
// Deleting points re-solves without them.
let pts = s.execute("camera.points", json!({})).unwrap();
let del: Vec<u64> = pts["points"].as_array().unwrap().iter().take(3).map(|p| p["id"].as_u64().unwrap()).collect();
s.execute_checked("camera.deletePoints", json!({"points": del, "wait": true})).unwrap();
s.poll_camera(false);
let st = status(&mut s);
assert_eq!(st["solved"], true, "{st}");
assert!(st["deleted"].as_u64().unwrap() >= 3);
let g = s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap().clone();
let sv = ct::solve(&crate::camera_track::static_params(&g)).unwrap();
assert!(sv.points.iter().all(|p| !del.contains(&(p.id as u64))));
// Trimming the layer changes its frames: tracks and solve are cleared and queued.
s.edit("trim", None, |p, _| {
p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().in_point = frame_time(2);
Ok(())
})
.unwrap();
let st = status(&mut s);
assert_eq!(st["analyzed"], false);
assert_eq!(st["pending"], true);
// A background analysis can be cancelled: nothing is written.
s.execute_checked("camera.analyze", json!({})).unwrap();
assert!(s.is_camera_analyzing() || s.camera_job.is_some());
let banner = status(&mut s)["banner"].as_str().map(str::to_string);
if s.is_camera_analyzing() {
assert!(banner.unwrap().starts_with("Analyzing in background (step 1 of 2)"));
}
s.execute("camera.cancel", json!({})).ok();
let t0 = std::time::Instant::now();
while s.camera_job.is_some() {
assert!(t0.elapsed().as_secs() < 120);
std::thread::sleep(std::time::Duration::from_millis(5));
s.poll_camera(false);
}
// (When the job finished before the cancel landed it wrote a valid analysis.)
let st = status(&mut s);
assert_eq!(st["running"], false);
assert!(s.execute_checked("track.camera", json!({"layer": clip.0, "shotType": "bogus"})).is_err());
assert!(s.execute_checked("track.camera", json!({"layer": clip.0, "aov": 500})).is_err());
}
#[test]
fn effect_parameters_match_after_effects() {
let spec = effectcraft_effects::registry().iter().find(|e| e.id == ct::ID).unwrap();
assert_eq!(spec.name, "3D Camera Tracker");
assert_eq!(spec.category, "Perspective");
let names: Vec<&str> = spec.params.iter().filter(|p| !matches!(p.ui, effectcraft_project::ParamUi::Hidden)).map(|p| p.name).collect();
for n in [
"Shot Type",
"Horizontal Angle of View",
"Show Track Points",
"Render Track Points",
"Track Point Size",
"Target Size",
"Solve Method",
"Detailed Analysis",
"Auto-delete Points Across Time",
"Hide Warning Banner",
] {
assert!(names.contains(&n), "{n}");
}
// The Animation menu entry is a real command now.
let c = crate::find_command("track.camera").unwrap();
assert_eq!(c.label, "Track Camera");
}
+122
View File
@@ -0,0 +1,122 @@
//! The persistent disk cache (PRV-2) through the session: settings, layer cache persistence
//! across restarts, invalidation on edits, purging.
use serde_json::json;
use crate::Session;
use crate::render::RenderOpts;
fn folder(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-session-disk-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d.to_string_lossy().to_string()
}
fn session(dir: &str) -> Session {
let mut s = Session::default();
s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": dir, "disk.diskCacheEnabled": true}})).unwrap();
// Write every layer buffer, however quick to render.
s.disk_cache.as_ref().unwrap().set_min_layer_ms(0);
s
}
fn project(s: &mut Session) -> effectcraft_project::ItemId {
s.execute("comp.new", json!({"name": "D", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 80, "height": 40})).unwrap();
s.execute("effect.apply", json!({"layer": "#1", "effect": "Gaussian Blur"})).unwrap();
s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap();
s.active_comp_id().unwrap()
}
#[test]
fn disk_cache_settings_persistence_and_invalidation() {
let dir = folder("persist");
let mut s = session(&dir);
let st = s.execute_checked("cache.diskStats", json!({})).unwrap();
assert_eq!(st["enabled"], json!(true));
assert_eq!(st["maxBytes"], json!(100u64 << 30));
let cid = project(&mut s);
let t = s.time();
let first = s.render(cid, t, RenderOpts::default());
let dc = s.disk_cache.clone().unwrap();
dc.flush();
let written = dc.stats().entries;
assert!(written > 0, "layer buffers written");
// "Restart": a new session on the same folder and project renders from disk.
let saved = s.project.clone();
drop(s);
let mut s2 = session(&dir);
s2.project = saved;
s2.bump();
s2.open_comp(cid);
let dc2 = s2.disk_cache.clone().unwrap();
assert_eq!(dc2.stats().entries, written);
let again = s2.render(cid, t, RenderOpts::default());
assert!(dc2.stats().hits > 0, "{:?}", dc2.stats());
assert_eq!(again.data, first.data, "disk-cached pixels are exact");
// An edit changes the keys: a miss, and new entries.
let hits = dc2.stats().hits;
s2.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 20})).unwrap();
let edited = s2.render(cid, t, RenderOpts::default());
assert_ne!(edited.data, first.data);
assert_eq!(dc2.stats().hits, hits);
dc2.flush();
assert!(dc2.stats().entries > written);
// Purge ▸ All Disk Cache empties it; memory purge leaves the disk alone.
let r = s2.execute_checked("edit.purge", json!({"what": "memory"})).unwrap();
assert_eq!(r["diskEntries"], json!(0));
assert!(dc2.stats().entries > 0);
let r = s2.execute_checked("edit.purge", json!({"what": "disk"})).unwrap();
assert!(r["diskEntries"].as_u64().unwrap() > 0);
assert_eq!(dc2.stats().entries, 0);
// Disabling detaches it.
s2.execute_checked("prefs.set", json!({"key": "disk.diskCacheEnabled", "value": false})).unwrap();
assert!(s2.disk_cache.is_none());
assert!(s2.layer_cache.disk().is_none());
}
#[test]
fn footage_changes_change_the_salt() {
let dir = folder("salt");
let mut s = session(&dir);
let foot = std::env::temp_dir().join(format!("effectcraft-salt-{}.png", std::process::id()));
std::fs::write(&foot, b"one").unwrap();
s.edit("add", None, |p, _| {
let f = effectcraft_project::Footage {
path: foot.to_string_lossy().to_string(),
kind: effectcraft_project::FootageKind::Still,
width: 1,
height: 1,
pixel_aspect: 1.0,
frame_rate: effectcraft_time::FrameRate::FPS_30,
native_rate: None,
duration: effectcraft_time::Tick::ZERO,
has_video: true,
has_audio: false,
alpha: effectcraft_project::AlphaMode::Ignore,
premul_color: [0.0; 3],
loop_count: 1,
codec: "PNG".into(),
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
};
p.add_item("f.png", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f));
Ok(())
})
.unwrap();
let a = effectcraft_render::disk_cache::footage_salt(&s.project);
std::fs::write(&foot, b"two, longer").unwrap();
let b = effectcraft_render::disk_cache::footage_salt(&s.project);
assert_ne!(a, b);
let cid = project(&mut s);
let k1 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap();
let k2 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
assert_ne!(k1, k2);
// Unrelated comps do not change it.
s.execute("comp.new", json!({"name": "Other", "width": 10, "height": 10, "frameRate": 30, "duration": 1})).unwrap();
assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k2);
}
+289
View File
@@ -0,0 +1,289 @@
//! Essential Graphics: authoring controls, master properties (per-instance overrides, Push to
//! Comp, Revert), `.ectemplate` export/import, and Responsive Design — Time.
use effectcraft_project::essential;
use effectcraft_project::{ItemId, LayerId};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::Session;
use crate::render::RenderOpts;
fn out_dir(name: &str) -> std::path::PathBuf {
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A "Card" comp (64×64): a white solid with a Fill effect (red) and a text layer; its Fill
/// colour, opacity and text are exposed. Returns (session, card comp, solid layer).
fn card() -> (Session, ItemId, u64, Value) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
let card = s.active_comp_id().unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap();
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color", "name": "Card Color"})).unwrap();
let opacity = s.execute("essential.addProperty", json!({"layer": solid, "path": "transform/opacity"})).unwrap();
let g = s.execute("essential.addGroup", json!({"name": "Words"})).unwrap()["control"].as_u64().unwrap();
let title = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "group": g})).unwrap();
s.execute("essential.addComment", json!({"text": "Pick a colour"})).unwrap();
s.execute("essential.setName", json!({"name": "Lower Card"})).unwrap();
let ids = json!({
"color": color["controls"][0], "opacity": opacity["controls"][0], "group": g, "title": title["controls"][0],
});
(s, card, solid, ids)
}
fn center(s: &Session, comp: ItemId) -> [f32; 4] {
let img = s.render(comp, Tick::ZERO, RenderOpts::default());
img.get(img.width as i64 / 2, img.height as i64 / 2)
}
#[test]
fn controls_are_listed_typed_and_editable() {
let (mut s, card, solid, ids) = card();
let l = s.execute("essential.list", json!({"supported": true})).unwrap();
assert_eq!(l["name"], "Lower Card");
let top: Vec<&str> = l["controls"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap()).collect();
assert_eq!(top, ["property", "property", "group", "comment"]);
assert_eq!(l["controls"][0]["name"], "Card Color");
assert_eq!(l["controls"][0]["type"], "color");
assert_eq!(l["controls"][1]["type"], "slider");
assert_eq!(l["controls"][2]["children"][0]["type"], "text");
assert!(l["supported"].as_array().unwrap().iter().any(|p| p["path"] == "transform/position" && p["type"] == "point"));
assert!(l["supported"].as_array().unwrap().iter().any(|p| p["path"] == "transform/rotation" && p["type"] == "angle"));
// Rename, move into the group, remove; all undoable.
let opacity = ids["opacity"].as_u64().unwrap();
s.execute("essential.rename", json!({"control": opacity, "name": "Fade"})).unwrap();
s.execute("essential.move", json!({"control": opacity, "group": ids["group"], "index": 0})).unwrap();
let l = s.execute("essential.list", json!({})).unwrap();
assert_eq!(l["controls"][1]["children"][0]["name"], "Fade");
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let l = s.execute("essential.list", json!({})).unwrap();
assert_eq!(l["controls"][1]["name"], "Opacity");
s.execute("essential.remove", json!({"control": opacity})).unwrap();
assert_eq!(s.execute("essential.list", json!({})).unwrap()["controls"].as_array().unwrap().len(), 3);
// Unsupported properties are refused; adding the same property twice adds nothing.
s.execute("layer.addMask", json!({"layer": solid})).unwrap();
assert!(s.execute("essential.addProperty", json!({"layer": solid, "path": "masks/#1/path"})).is_err());
let again = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap();
assert_eq!(again["controls"], json!([]));
// Dropdown Menu Control: its items are editable and it becomes a dropdown control.
s.execute("effect.apply", json!({"layer": solid, "effect": "Dropdown Menu Control"})).unwrap();
s.execute("effect.editDropdown", json!({"layer": solid, "items": ["Small", "Medium", "Large", "Huge"]})).unwrap();
let d = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#2/menu"})).unwrap()["controls"][0].as_u64().unwrap();
let l = s.execute("essential.list", json!({})).unwrap();
let c = l["controls"].as_array().unwrap().iter().find(|c| c["id"] == d).unwrap().clone();
assert_eq!(c["type"], "dropdown");
let comp = s.project.comp(card).unwrap();
let menu = comp.layer(LayerId(solid)).unwrap().props.prop("effects/#2/menu").unwrap();
assert!(matches!(&menu.ui, effectcraft_project::ParamUi::Popup { options } if options.len() == 4 && options[3] == "Huge"));
}
#[test]
fn master_properties_override_per_instance_push_and_revert() {
let (mut s, card, solid, ids) = card();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
let main = s.active_comp_id().unwrap();
let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
// Both instances show the controls under Essential Properties.
let inst = s.execute("essential.instance", json!({"layer": a})).unwrap();
let names: Vec<&str> = inst["controls"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Card Color", "Opacity", "Words", "Source Text", "Comment"]);
assert_eq!(inst["controls"][0]["value"], json!([1.0, 0.0, 0.0, 1.0]));
// Override the colour of the top instance (a = layer #2, b on top is #1).
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("essential.set", json!({"layer": b, "control": ids["color"], "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
let px = center(&s, main);
assert!(px[2] > 0.99 && px[0] < 0.01, "instance b is blue: {px:?}");
let inst_b = s.execute("essential.instance", json!({"layer": b})).unwrap();
assert_eq!(inst_b["controls"][0]["overridden"], true);
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][0]["overridden"], false);
// The source comp is untouched; hiding b shows a still red.
assert!(center(&s, card)[0] > 0.99);
s.execute("layer.setSwitch", json!({"layer": b, "switch": "video", "value": false})).unwrap();
let px = center(&s, main);
assert!(px[0] > 0.99 && px[2] < 0.01, "instance a stays red: {px:?}");
s.execute("edit.undo", json!({})).unwrap();
// Editing the master property in the timeline (prop.set) also overrides.
let path = inst_b["controls"][1]["path"].as_str().unwrap().to_string();
assert!(path.starts_with("essential/"), "{path}");
s.execute("prop.set", json!({"layer": a, "path": path, "value": 50})).unwrap();
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][1]["overridden"], true);
// A change to the source shows in instances that don't override it.
s.execute("prop.set", json!({"comp": card.0, "layer": solid, "path": "transform/opacity", "value": 80})).unwrap();
assert_eq!(s.execute("essential.instance", json!({"layer": b})).unwrap()["controls"][1]["value"], json!(80.0));
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][1]["value"], json!(50.0));
// Revert a's opacity.
s.execute("essential.revert", json!({"layer": a, "control": ids["opacity"]})).unwrap();
let inst = s.execute("essential.instance", json!({"layer": a})).unwrap();
assert_eq!(inst["controls"][1]["overridden"], false);
assert_eq!(inst["controls"][1]["value"], json!(80.0));
// Push b's colour to the source comp: the override goes away and every instance is blue.
s.execute("essential.pushToComp", json!({"layer": b})).unwrap();
let src = s.project.comp(card).unwrap().layer(LayerId(solid)).unwrap().props.prop("effects/#1/color").unwrap().value.clone();
assert_eq!(src.to_json(), json!([0.0, 0.0, 1.0, 1.0]));
assert_eq!(s.execute("essential.instance", json!({"layer": b})).unwrap()["controls"][0]["overridden"], false);
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][0]["value"], json!([0.0, 0.0, 1.0, 1.0]));
// Undo restores the override and the red source.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("essential.instance", json!({"layer": b})).unwrap()["controls"][0]["overridden"], true);
let src = s.project.comp(card).unwrap().layer(LayerId(solid)).unwrap().props.prop("effects/#1/color").unwrap().value.clone();
assert_eq!(src.to_json(), json!([1.0, 0.0, 0.0, 1.0]));
// Removing a control removes it from instances.
s.execute("essential.remove", json!({"comp": card.0, "control": ids["color"]})).unwrap();
let l = s.project.comp(main).unwrap().layer(LayerId(b)).unwrap().clone();
assert!(essential::group(&l).unwrap().children.iter().all(|n| n.name() != "Card Color"));
}
#[test]
fn template_export_import_round_trip_with_overrides() {
let dir = out_dir("ectemplate");
let (mut s, card, _solid, ids) = card();
let path = dir.join("Lower Card.ectemplate").to_string_lossy().to_string();
let r = s.execute("essential.exportTemplate", json!({"comp": card.0, "path": path})).unwrap();
assert_eq!(r["name"], "Lower Card");
let info = s.execute("essential.templateInfo", json!({"path": path})).unwrap();
assert_eq!(info["format"], "effectcraft-template");
assert_eq!(info["width"], 64);
let kinds: Vec<&str> = info["controls"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap()).collect();
assert_eq!(kinds, ["property", "property", "group", "property", "comment"]);
assert_eq!(info["controls"][3]["group"], ids["group"]);
let entries = effectcraft_lottie::zip::read_stored(&std::fs::read(&path).unwrap());
let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"manifest.json") && names.contains(&"project.ecproj") && names.contains(&"poster.png"), "{names:?}");
// A fresh project imports it and gets an instance in its active comp.
let mut t = Session::default();
t.execute("comp.new", json!({"name": "Show", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
let show = t.active_comp_id().unwrap();
t.execute("layer.newSolid", json!({"name": "Back", "color": "#00ff00"})).unwrap();
let r = t.execute("essential.importTemplate", json!({"path": path})).unwrap();
let tcomp = ItemId(r["comp"].as_u64().unwrap());
let inst = r["layer"].as_u64().unwrap();
assert_eq!(t.project.item(tcomp).unwrap().name, "Card");
assert!(t.project.items.values().any(|i| i.is_folder() && i.name == "Lower Card"));
// Ids were remapped consistently: the template comp's controls point at its own layers.
let eg = t.project.comp(tcomp).unwrap().essential.clone().unwrap();
for c in eg.flat() {
if let essential::EgKind::Property { .. } = c.kind {
assert!(essential::source_prop(&t.project, tcomp, c).is_some(), "{c:?}");
}
}
let px = center(&t, show);
assert!(px[0] > 0.99 && px[1] < 0.01, "the card renders red over green: {px:?}");
// Override on the imported instance, then save/load keeps it.
let color_ctl = eg.flat().iter().find(|c| c.name == "Card Color").unwrap().id;
t.execute("essential.set", json!({"layer": inst, "control": color_ctl, "value": "#ffff00"})).unwrap();
let px = center(&t, show);
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "yellow: {px:?}");
let saved = effectcraft_project::Project::from_json(&t.project.to_json()).unwrap();
let mut u = Session::default();
u.replace_project(saved, None);
u.open_comp(show);
let px = center(&u, show);
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "override survives save/load: {px:?}");
// Importing again keeps both copies apart (fresh ids).
let r2 = t.execute("essential.importTemplate", json!({"path": path, "addToComp": false})).unwrap();
assert_ne!(r2["comp"], r["comp"]);
assert!(r2["layer"].is_null());
// Not a template.
std::fs::write(dir.join("bad.ectemplate"), b"nope").unwrap();
assert!(t.execute("essential.importTemplate", json!({"path": dir.join("bad.ectemplate").to_string_lossy()})).is_err());
}
#[test]
fn media_replacement_swaps_footage_per_instance() {
use std::sync::Arc;
// Two "footage" items rendered by a fake source: item → solid colour.
struct Colors;
impl crate::render::FootageSource for Colors {
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
let c = if f.path.contains("red") { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] };
Some(Arc::new(effectcraft_raster::Image::filled(16, 16, c)))
}
}
let mut s = Session { footage: Arc::new(Colors), ..Default::default() };
let foot = |p: &str| effectcraft_project::Footage {
path: p.into(),
kind: effectcraft_project::FootageKind::Still,
width: 16,
height: 16,
has_video: true,
..Default::default()
};
let (red, blue) = s
.edit("x", None, |proj, _| {
let r = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(foot("red.png")));
let b = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(foot("blue.png")));
Ok((r, b))
})
.unwrap();
s.execute("comp.new", json!({"name": "Slot", "width": 16, "height": 16, "frameRate": 30, "duration": 1})).unwrap();
let slot = s.active_comp_id().unwrap();
let fl = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap();
let m = s.execute("essential.addMedia", json!({"layer": fl})).unwrap()["control"].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 16, "height": 16, "frameRate": 30, "duration": 1})).unwrap();
let main = s.active_comp_id().unwrap();
let inst = s.execute("layer.addItem", json!({"item": slot.0})).unwrap()["layer"].as_u64().unwrap();
assert!(center(&s, main)[0] > 0.99);
s.execute("essential.set", json!({"layer": inst, "control": m, "item": blue.0})).unwrap();
let px = center(&s, main);
assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}");
assert!(center(&s, slot)[0] > 0.99, "the source keeps its media");
assert_eq!(s.execute("essential.instance", json!({"layer": inst})).unwrap()["controls"][0]["type"], "media");
s.execute("essential.revert", json!({"layer": inst})).unwrap();
assert!(center(&s, main)[0] > 0.99);
}
/// Responsive Design — Time: stretching a precomp keeps its protected regions at their
/// original speed; the unprotected parts absorb the stretch.
#[test]
fn protected_regions_keep_speed_under_time_stretch() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "frameRate": 30, "duration": 4})).unwrap();
let inner = s.active_comp_id().unwrap();
// Intro 0–1 s and outro 3–4 s.
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("comp.responsiveTime", json!({"op": "intro"})).unwrap();
s.execute("time.set", json!({"time": 3.0})).unwrap();
s.execute("comp.responsiveTime", json!({"op": "outro"})).unwrap();
let comp = s.project.comp(inner).unwrap().clone();
assert_eq!(crate::render::eval::protected_regions(&comp), vec![(0.0, 1.0), (3.0, 4.0)]);
s.execute("comp.new", json!({"name": "Outer", "width": 32, "height": 32, "frameRate": 30, "duration": 10})).unwrap();
let outer = s.active_comp_id().unwrap();
let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [l]})).unwrap();
s.execute("layer.timeStretch", json!({"percent": 200})).unwrap();
let st = |s: &Session, t: f64| {
let c = s.project.comp(outer).unwrap();
let layer = c.layer(LayerId(l)).unwrap();
crate::render::EvalCtx::new(&s.project, outer, c, Tick::from_seconds_f64(t)).source_time(layer).seconds()
};
// 8 s total: intro 1 s at speed 1, middle 2 s stretched to 6 s, outro 1 s at speed 1.
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
assert!(near(st(&s, 0.5), 0.5));
assert!(near(st(&s, 1.0), 1.0));
assert!(near(st(&s, 4.0), 2.0));
assert!(near(st(&s, 7.0), 3.0));
assert!(near(st(&s, 7.5), 3.5), "outro plays at full speed: {}", st(&s, 7.5));
// Protected regions play at the original speed: one comp second = one source second.
assert!(near(st(&s, 0.8) - st(&s, 0.3), 0.5));
assert!(near(st(&s, 7.9) - st(&s, 7.4), 0.5));
assert!(near(st(&s, 5.0) - st(&s, 4.0), 1.0 / 3.0), "the middle is slowed down 3×");
// Compressing to 75 %: the middle plays faster.
s.execute("layer.timeStretch", json!({"percent": 75})).unwrap();
assert!(near(st(&s, 0.5), 0.5));
assert!(near(st(&s, 1.0 + 0.5), 1.0 + 0.5 / 0.5), "{}", st(&s, 1.5));
// Without protected regions the plain stretch applies.
s.execute("comp.open", json!({"comp": inner.0})).ok();
let mut p = (*s.project).clone();
p.comp_mut(inner).unwrap().markers.clear();
s.project = std::sync::Arc::new(p);
assert!(near(st(&s, 1.5), 2.0));
}
+1 -1
View File
@@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
for id in ["file.createProxy", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
for id in ["keys.audioToKeyframes", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}
+1
View File
@@ -52,6 +52,7 @@ impl Importer for ModelImporter {
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
})
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through
//! a synthetic footage source.
use std::sync::Arc;
use effectcraft_project::render_queue::ProxyUse;
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId, ItemKind};
use effectcraft_raster::Image;
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::Session;
use crate::render::{FootageSource, RenderOpts};
/// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:
/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a
/// semi-transparent green premultiplied against black / white, read as straight), `half.png`
/// (white at 25 % alpha), `wide.png` (10×10 white).
struct Synthetic;
fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("");
match name {
"red16.png" => [1.0, 0.0, 0.0, 1.0],
"blue8.png" => [0.0, 0.0, 1.0, 1.0],
"lines.mov" => {
if y.is_multiple_of(2) {
[1.0, 0.0, 0.0, 1.0]
} else {
[0.0, 0.0, 1.0, 1.0]
}
}
// Straight interpretation of a file whose colour is green·a + matte·(1 − a), a = 0.5.
"matte_black.png" => [0.0, 0.5, 0.0, 0.5],
"matte_white.png" => [0.5, 1.0, 0.5, 0.5],
"half.png" => [1.0, 1.0, 1.0, 0.25],
_ => {
let _ = x;
[1.0, 1.0, 1.0, 1.0]
}
}
}
fn size_of(path: &str) -> (u32, u32) {
match std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("") {
"red16.png" => (16, 16),
"blue8.png" => (8, 8),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
_ => (10, 10),
}
}
impl FootageSource for Synthetic {
fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
let (w, h) = size_of(&f.path);
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
// Decoders hand out premultiplied pixels (straight file colour × alpha).
let p = file_px(&f.path, x, y);
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
}
}
Some(Arc::new(img))
}
}
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
let (w, h) = size_of(path);
let video = path.ends_with(".mov");
Ok(Footage {
path: path.into(),
kind: if video { FootageKind::Video } else { FootageKind::Still },
width: w,
height: h,
has_video: true,
frame_rate: FrameRate::from_f64(10.0),
duration: Tick::from_seconds_f64(if video { 2.0 } else { 0.0 }),
..Default::default()
})
}
}
fn session() -> Session {
Session { footage: Arc::new(Synthetic), importer: Some(Arc::new(Probe)), ..Default::default() }
}
fn import(s: &mut Session, path: &str) -> ItemId {
ItemId(s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap())
}
fn px(s: &Session, comp: ItemId, t: f64, opts: RenderOpts, x: i64, y: i64) -> [f32; 4] {
s.render(comp, Tick::from_seconds_f64(t), opts).get(x, y)
}
fn red(p: [f32; 4]) -> bool {
p[0] > 0.99 && p[2] < 0.01 && p[3] > 0.99
}
fn blue(p: [f32; 4]) -> bool {
p[2] > 0.99 && p[0] < 0.01 && p[3] > 0.99
}
#[test]
fn footage_proxy_follows_use_proxy_and_proxy_use() {
let mut s = session();
let full = import(&mut s, "/m/red16.png");
s.execute("comp.new", json!({"name": "C", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": full.0})).unwrap();
let o = RenderOpts::default();
assert!(red(px(&s, c, 0.0, o, 8, 8)));
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/blue8.png"})).unwrap();
// The 8×8 proxy fills the 16×16 footage frame.
for (x, y) in [(1, 1), (8, 8), (14, 14)] {
assert!(blue(px(&s, c, 0.0, o, x, y)), "proxy at {x},{y}");
}
assert!(s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
// Render Settings ▸ Proxy Use.
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
// Use Proxy off: the viewer shows the source, Use All Proxies still uses it.
s.execute("file.useProxy", json!({"item": full.0})).unwrap();
assert!(!s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
assert!(red(px(&s, c, 0.0, o, 8, 8)));
assert!(blue(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
// Undo / Set Proxy ▸ None.
s.execute("edit.undo", json!({})).unwrap();
assert!(blue(px(&s, c, 0.0, o, 8, 8)));
s.execute("file.setProxyNone", json!({"item": full.0})).unwrap();
assert!(s.project.item(full).unwrap().proxy.is_none());
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
// Render queue items carry Proxy Use (Best Settings: none).
let r = s.execute("renderQueue.add", json!({"proxyUse": "all"})).unwrap();
assert_eq!(s.project.render_queue[0].settings.proxy_use, ProxyUse::UseAll, "{r}");
assert_eq!(effectcraft_project::render_queue::RenderSettings::default().proxy_use, ProxyUse::UseNone);
}
#[test]
fn comp_proxy_and_create_proxy() {
let mut s = session();
s.execute("comp.new", json!({"name": "Inner", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let inner = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
let o = RenderOpts::default();
assert!(red(px(&s, outer, 0.0, o, 8, 8)));
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/blue8.png"})).unwrap();
assert!(blue(px(&s, outer, 0.0, o, 14, 1)));
assert!(blue(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
assert!(red(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
s.execute("file.setProxyNone", json!({"item": inner.0})).unwrap();
// Create Proxy queues a half-resolution render whose post-render action sets the proxy.
s.state.project_selection = vec![inner];
let r = s.execute("file.createProxy", json!({"kind": "still"})).unwrap();
let it = s.project.render_queue.last().unwrap().clone();
assert_eq!(it.comp, inner);
assert_eq!(it.post_render, effectcraft_project::render_queue::PostRenderAction::SetProxy);
assert_eq!(it.settings.resolution, 0.5);
assert!(r["output"].as_str().unwrap().contains("Inner_proxy_"), "{r}");
let m = s.execute("file.createProxy", json!({"kind": "movie", "comp": inner.0})).unwrap();
assert!(m["output"].as_str().unwrap().ends_with("Inner_proxy.mov"), "{m}");
// The post-render action (what a finished render runs) attaches the file.
let id = it.id;
s.post_render_action(id, "/m/blue8.png").unwrap();
assert_eq!(s.project.item(inner).unwrap().proxy.as_ref().unwrap().footage.path, "/m/blue8.png");
assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
}
#[test]
fn interpret_proxy_settings_apply_to_the_proxy() {
let mut s = session();
let full = import(&mut s, "/m/red16.png");
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/half.png"})).unwrap();
s.execute("file.interpretProxy", json!({"item": full.0, "alpha": "ignore", "invertAlpha": true})).unwrap();
let px = s.project.item(full).unwrap().proxy.clone().unwrap();
assert_eq!(px.footage.alpha, AlphaMode::Ignore);
assert!(px.footage.invert_alpha);
let f = match &s.project.item(full).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => unreachable!(),
};
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
}
/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
/// interpolated; the dominant field comes first.
#[test]
fn separate_fields_make_frames_from_fields() {
let mut s = session();
let mov = import(&mut s, "/m/lines.mov");
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 20, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": mov.0})).unwrap();
let o = RenderOpts::default();
// Progressive: alternating rows.
assert!(red(px(&s, c, 0.0, o, 1, 0)) && blue(px(&s, c, 0.0, o, 1, 1)));
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "upper"})).unwrap();
// Field 1 (upper = even rows, red) then field 2 (odd rows, blue), each 1/20 s.
for y in 0..3 {
assert!(red(px(&s, c, 0.0, o, 1, y)), "upper field at row {y}");
assert!(blue(px(&s, c, 0.05, o, 1, y + 1)), "lower field at row {}", y + 1);
}
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "lower"})).unwrap();
assert!(blue(px(&s, c, 0.0, o, 1, 2)));
assert!(red(px(&s, c, 0.05, o, 1, 1)));
let r = s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "off"})).unwrap();
assert_eq!(r["items"][0]["fields"], "Off");
}
/// Non-square pixels: a 10×10 footage with pixel aspect 2 covers 20×10 comp pixels.
#[test]
fn pixel_aspect_stretches_footage_in_the_comp() {
let mut s = session();
let w = import(&mut s, "/m/wide.png");
s.execute("comp.new", json!({"name": "C", "width": 40, "height": 20, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": w.0})).unwrap();
let o = RenderOpts::default();
let covered = |s: &Session| s.render(c, Tick::ZERO, o).data.iter().filter(|p| p[3] > 0.5).count();
assert_eq!(covered(&s), 100);
s.execute("file.interpretFootage", json!({"item": w.0, "pixelAspect": 2.0})).unwrap();
assert_eq!(covered(&s), 200);
// Centered on the comp: x 10..30 (edge pixels are filtered).
assert!(px(&s, c, 0.0, o, 11, 10)[3] > 0.99 && px(&s, c, 0.0, o, 28, 10)[3] > 0.99);
assert!(px(&s, c, 0.0, o, 8, 10)[3] < 0.01 && px(&s, c, 0.0, o, 31, 10)[3] < 0.01);
// A comp with the same pixel aspect shows the footage 1:1.
s.execute("comp.settings", json!({"pixelAspect": 2.0})).unwrap();
assert_eq!(covered(&s), 100);
}
/// Alpha ▸ Guess: premultiplied files are recognised with their matte colour; Invert Alpha
/// and Interpret As Linear Light change the decoded pixels.
#[test]
fn guess_alpha_invert_and_linear_light() {
let mut s = session();
let b = import(&mut s, "/m/matte_black.png");
let w = import(&mut s, "/m/matte_white.png");
let h = import(&mut s, "/m/half.png");
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => unreachable!(),
};
s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap();
assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied);
assert_eq!(foot(&s, b).premul_color, [0.0; 3]);
assert_eq!(foot(&s, w).alpha, AlphaMode::Premultiplied);
assert_eq!(foot(&s, w).premul_color, [1.0; 3]);
// White at 25 %: not explained by a black matte (1 > 0.25) but it is by white… either way
// the guess is consistent with the pixels.
assert!(matches!(foot(&s, h).alpha, AlphaMode::Straight | AlphaMode::Premultiplied));
// An explicit matte colour.
s.execute("file.interpretFootage", json!({"item": b.0, "alpha": "premultiplied", "matteColor": "#808080"})).unwrap();
assert!((foot(&s, b).premul_color[0] - 128.0 / 255.0).abs() < 1e-3);
// Invert Alpha: 25 % → 75 %.
s.execute("file.interpretFootage", json!({"item": h.0, "alpha": "straight", "invertAlpha": true})).unwrap();
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": h.0})).unwrap();
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
assert!((p[3] - 0.75).abs() < 0.01 && p[0] > 0.7, "{p:?}");
// Interpret As Linear Light keeps alpha and re-encodes the colour.
s.execute("file.interpretFootage", json!({"item": h.0, "invertAlpha": false, "linearLight": true})).unwrap();
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
assert!((p[3] - 0.25).abs() < 0.01, "{p:?}");
// Remember / Apply Interpretation carries the new settings.
s.state.project_selection = vec![h];
s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap();
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
assert!(foot(&s, w).linear_light);
}
+2 -1
View File
@@ -257,6 +257,7 @@ impl Importer for MockImporter {
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
})
}
}
@@ -331,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
}
// What remains a stub is still disabled.
for id in ["view.displayColorManagement", "file.createProxy", "track.camera"] {
for id in ["view.displayColorManagement", "keys.audioToKeyframes"] {
assert!(!s.is_enabled(id), "{id}");
}
}
+2
View File
@@ -101,6 +101,7 @@ pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Ses
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
};
let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage));
let comp = p.comp(cid).unwrap().clone();
@@ -410,6 +411,7 @@ fn analyze_1080p_single_point_speed() {
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
};
let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage));
let comp = p.comp(cid).unwrap().clone();
+237
View File
@@ -0,0 +1,237 @@
//! Photoshop import as a composition (IO-2) and Layer ▸ Create text conversions (SHP-5).
use effectcraft_color::BlendMode;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{ItemKind, LayerSource, MaskMode};
use effectcraft_psd::write::*;
use effectcraft_psd::{DValue, Descriptor, Rect};
use serde_json::json;
use crate::Session;
use crate::render::RenderOpts;
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-psd-tests-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
/// A layered document exercising groups, masks, blend modes, text, styles and adjustments.
pub(crate) fn sample_psd() -> Vec<u8> {
let mut d = WDoc::new(120, 80);
let shadow = Descriptor::new("null").with("masterFXSwitch", DValue::Bool(true)).with(
"DrSh",
DValue::Descriptor(
Descriptor::new("DrSh")
.with("enab", DValue::Bool(true))
.with("Md ", DValue::Enum("BlnM".into(), "Mltp".into()))
.with("Clr ", DValue::Descriptor(Descriptor::rgb([0.0, 0.0, 0.5])))
.with("Opct", DValue::UnitFloat("#Prc".into(), 60.0))
.with("uglg", DValue::Bool(false))
.with("lagl", DValue::UnitFloat("#Ang".into(), 45.0))
.with("Dstn", DValue::UnitFloat("#Pxl".into(), 9.0))
.with("blur", DValue::UnitFloat("#Pxl".into(), 4.0)),
),
);
let stroke = Descriptor::new("null").with(
"FrFX",
DValue::Descriptor(
Descriptor::new("FrFX")
.with("enab", DValue::Bool(true))
.with("Styl", DValue::Enum("FStl".into(), "InsF".into()))
.with("Md ", DValue::Enum("BlnM".into(), "Nrml".into()))
.with("Opct", DValue::UnitFloat("#Prc".into(), 100.0))
.with("Sz ", DValue::UnitFloat("#Pxl".into(), 2.0))
.with("Clr ", DValue::Descriptor(Descriptor::rgb([1.0, 1.0, 0.0]))),
),
);
let mut masked = WLayer::solid("Masked Square", Rect::new(10, 10, 40, 40), [0.0, 0.8, 0.2, 1.0]).with_blend(b"mul ");
masked.mask = Some(WMask {
rect: Rect::new(10, 10, 40, 40),
data: (0..1600).map(|i| if i % 40 < 20 { 1.0 } else { 0.0 }).collect(),
default_color: 0,
disabled: false,
});
let mut hidden = WLayer::solid("Hidden", Rect::new(0, 0, 10, 10), [1.0, 0.0, 1.0, 1.0]);
hidden.hidden = true;
let mut clipped = WLayer::solid("Clipped", Rect::new(60, 30, 20, 20), [1.0, 1.0, 1.0, 1.0]).with_blend(b"scrn").with_opacity(128);
clipped.clipping = true;
let tri = vec![[[70.0, 10.0]; 3], [[110.0, 10.0]; 3], [[90.0, 40.0]; 3]];
d.layers = vec![
WLayer::solid("Background", Rect::new(0, 0, 120, 80), [0.2, 0.3, 0.4, 1.0]),
WLayer::group_end(),
masked,
hidden,
WLayer::group("Group A", true, *b"pass"),
WLayer::solid("Base", Rect::new(55, 25, 30, 30), [0.9, 0.1, 0.1, 1.0]).with_block(effects_block(&shadow)),
clipped,
WLayer::solid("Vector Masked", Rect::new(60, 0, 60, 50), [0.1, 0.1, 0.9, 1.0])
.with_block(vector_mask_block(&[tri], 120, 80, false))
.with_block(effects_block(&stroke)),
WLayer::empty("Hue Shift", vec![hue_sat_block(40, 10, -5)]),
WLayer::empty("Title", vec![text_block("Hello PSD", [8.0, 70.0], "Inter-Bold", 14.0, [1.0, 1.0, 1.0], 0)]),
];
write(&d)
}
#[test]
fn psd_imports_as_composition() {
let path = tmp("sample.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
assert_eq!(s.state.active_comp, Some(cid));
assert_eq!(s.project.item(cid).unwrap().name, "sample");
let c = s.project.comp(cid).unwrap().clone();
assert_eq!((c.width, c.height), (120, 80));
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]);
// Text layer: editable text, positioned at the type origin.
let title = &c.layers[0];
assert!(matches!(title.source, LayerSource::Text));
let Some(KV::Text(doc)) = title.props.prop("text/sourceText").map(|p| p.value.clone()) else { panic!("text") };
assert_eq!(doc.text, "Hello PSD");
assert_eq!(doc.size, 14.0);
assert_eq!(doc.font, "Inter");
assert_eq!(doc.style, "Bold");
assert_eq!(title.props.prop("transform/position").unwrap().value.as_vec2(), [8.0, 70.0]);
// Adjustment layer with Hue/Saturation.
let hue = &c.layers[1];
assert!(hue.switches.adjustment);
let fx = hue.effects().unwrap().groups().next().unwrap();
assert_eq!(fx.get("hue").unwrap().value.as_f64(), 40.0);
assert_eq!(fx.get("saturation").unwrap().value.as_f64(), 10.0);
// Vector mask → mask; Stroke style.
let vm = &c.layers[2];
let m = vm.masks().unwrap().groups().next().unwrap();
assert!(matches!(m.kind, effectcraft_project::GroupKind::Mask { mode: MaskMode::Add, .. }));
let Some(KV::Path(p)) = m.get("path").map(|p| p.value.clone()) else { panic!("mask path") };
assert_eq!(p.vertices.len(), 3);
assert!((p.vertices[1][0] - 110.0).abs() < 1e-3 && (p.vertices[2][1] - 40.0).abs() < 1e-3);
let st = vm.props.group("layerStyles/stroke").unwrap();
assert_eq!(st.get("size").unwrap().value.as_f64(), 2.0);
assert_eq!(st.get("position").unwrap().value, KV::Enum(1));
// Clipped → preserve transparency; opacity and blend mode.
let clipped = &c.layers[3];
assert!(clipped.preserve_transparency);
assert_eq!(clipped.blend_mode, BlendMode::Screen);
assert!((clipped.props.prop("transform/opacity").unwrap().value.as_f64() - 128.0 / 255.0 * 100.0).abs() < 1e-9);
// Drop shadow.
let ds = c.layers[4].props.group("layerStyles/dropShadow").unwrap();
assert_eq!(ds.get("distance").unwrap().value.as_f64(), 9.0);
assert_eq!(ds.get("angle").unwrap().value.as_f64(), 45.0);
assert_eq!(ds.get("useGlobalLight").unwrap().value, KV::Bool(false));
assert_eq!(ds.get("opacity").unwrap().value.as_f64(), 60.0);
// Group → collapsed precomp (pass through) with its layers.
let g = &c.layers[5];
assert!(g.switches.collapse);
let LayerSource::Comp { item } = g.source else { panic!("precomp") };
let inner = s.project.comp(item).unwrap();
let inner_names: Vec<&str> = inner.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(inner_names, ["Hidden", "Masked Square"]);
assert!(!inner.layers[0].switches.video);
assert_eq!(inner.layers[1].blend_mode, BlendMode::Multiply);
// Footage items name their Photoshop layer; document-sized footage.
let LayerSource::Footage { item } = inner.layers[1].source else { panic!("footage") };
let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() };
let sl = f.layer.as_ref().unwrap();
assert_eq!((sl.name.as_str(), sl.layer_size), ("Masked Square", false));
assert_eq!((f.width, f.height), (120, 80));
assert_eq!(s.project.item(item).unwrap().name, "Masked Square/sample.psd");
// One undo step removes everything.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.comp(cid).is_none());
}
#[test]
fn psd_retain_layer_sizes() {
let path = tmp("retain.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap();
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
let c = s.project.comp(cid).unwrap();
let base = c.layer_by_name("Base").unwrap();
let LayerSource::Footage { item } = base.source else { panic!() };
let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() };
assert_eq!((f.width, f.height), (30, 30));
assert!(f.layer.as_ref().unwrap().layer_size);
assert_eq!(base.props.prop("transform/position").unwrap().value.as_vec2(), [70.0, 40.0]);
assert_eq!(base.props.prop("transform/anchor").unwrap().value.as_vec2(), [15.0, 15.0]);
// Vector mask vertices are in the layer's own space.
let vm = c.layer_by_name("Vector Masked").unwrap();
let Some(KV::Path(p)) = vm.masks().unwrap().groups().next().unwrap().get("path").map(|p| p.value.clone()) else { panic!() };
assert!((p.vertices[0][0] - 10.0).abs() < 1e-3, "{:?}", p.vertices);
}
#[test]
fn import_rejects_unknown_kind() {
let mut s = Session::default();
assert!(s.execute_checked("file.import", json!({"paths": ["x.psd"], "importAs": "banana"})).is_err());
}
fn text_session() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 320, "height": 120, "frameRate": 30, "duration": 2})).unwrap();
let r = s.execute_checked("layer.newText", json!({"text": "Ab8", "size": 64, "position": [160, 80], "fill": "#ffcc00"})).unwrap();
(s, r["layer"].as_u64().unwrap())
}
fn alpha_diff(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image) -> (f32, f32) {
let mut sum = 0.0;
let mut mass = 0.0;
for (p, q) in a.data.iter().zip(&b.data) {
sum += (p[3] - q[3]).abs();
mass += p[3];
}
(sum / a.data.len() as f32, mass)
}
#[test]
fn create_shapes_from_text_matches_text_render() {
let (mut s, lid) = text_session();
let cid = s.active_comp_id().unwrap();
let opts = RenderOpts::default();
let text_img = s.render(cid, s.time(), opts);
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
let r = s.execute_checked("layer.create", json!({"op": "shapesFromText"})).unwrap();
let sid = r["layers"][0].as_u64().unwrap();
let c = s.active_comp().unwrap();
assert_eq!(c.layers[0].id.0, sid);
assert!(matches!(c.layers[0].source, LayerSource::Shape));
assert_eq!(c.layers[0].name, "Ab8 Outlines");
assert!(!c.layer(effectcraft_project::LayerId(lid)).unwrap().switches.video, "source hidden");
let groups: Vec<String> = c.layers[0].props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(groups, ["A", "b", "8"]);
let shape_img = s.render(cid, s.time(), opts);
let (d, mass) = alpha_diff(&text_img, &shape_img);
assert!(mass > 500.0, "text drew something");
assert!(d < 0.01, "mean alpha difference {d}");
// Fill colour carried over.
let i = shape_img.data.iter().position(|p| p[3] > 0.99).unwrap();
let p = shape_img.data[i];
assert!((p[0] - 1.0).abs() < 0.02 && (p[1] - 0.8).abs() < 0.02 && p[2] < 0.02, "{p:?}");
}
#[test]
fn create_masks_from_text() {
let (mut s, lid) = text_session();
let cid = s.active_comp_id().unwrap();
let text_img = s.render(cid, s.time(), RenderOpts::default());
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
let r = s.execute_checked("layer.create", json!({"op": "masksFromText"})).unwrap();
let mid = effectcraft_project::LayerId(r["layers"][0].as_u64().unwrap());
let c = s.active_comp().unwrap();
let l = c.layer(mid).unwrap();
assert!(matches!(l.source, LayerSource::Solid { .. }));
// "A", "b" and "8" have 2, 2 and 3 contours.
assert_eq!(l.masks().unwrap().groups().count(), 7);
let img = s.render(cid, s.time(), RenderOpts::default());
let (d, _) = alpha_diff(&text_img, &img);
assert!(d < 0.01, "mean alpha difference {d}");
// Other ops need the right layer type.
assert!(s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).is_err());
assert!(s.execute_checked("layer.create", json!({"op": "nullTracing", "layers": [lid]})).is_err());
}
+13 -4
View File
@@ -70,7 +70,8 @@ pub fn track_options(o: &TrackerOptions) -> trk::TrackOptions {
}
/// A track point's regions and attach offset read from its properties at a layer time.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PointValues {
pub uid: Uid,
pub spec: trk::PointSpec,
@@ -206,7 +207,7 @@ pub(crate) fn source_frame(
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc<Image>, [f64; 2])> {
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr };
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
&& f.has_video
@@ -292,6 +293,7 @@ pub(crate) fn run_work(w: Work, shared: &TrackShared) {
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
@@ -322,16 +324,19 @@ fn write_frames(p: &mut Project, job: &TrackJob, frames: &[FrameResult]) {
impl Session {
/// Whether a track analysis is running.
pub fn is_tracking(&self) -> bool {
self.track_job.as_ref().is_some_and(|j| !j.is_finished())
self.track_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::Track).is_some()
}
/// Live progress of the running (or just finished) analysis.
pub fn track_progress(&self) -> Option<TrackProgress> {
self.track_job.as_ref().map(|j| j.progress())
self.track_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::Track).map(|j| j.progress.track()))
}
/// Cancel the running analysis (results so far are kept).
pub fn stop_track(&mut self) -> bool {
if self.cancel_offloaded(crate::offload::JobKind::Track) {
return true;
}
match &self.track_job {
Some(j) if !j.is_finished() => {
j.cancel();
@@ -348,6 +353,10 @@ impl Session {
return Err("a track analysis is already running".into());
}
self.poll_track();
if !wait && self.offloads() {
let Work { comp, layer, settings, points, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Analyze Track".into(), self.project.clone()));
self.history.redo.clear();
+250
View File
@@ -0,0 +1,250 @@
//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG footage →
//! shape layer), Create Shapes from Text and Create Masks from Text (glyph outlines).
use effectcraft_keyframe::{Gradient, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, Layer, LayerSource, MaskMode, Project, PropGroup};
use effectcraft_svg::{Affine, Doc, Geom, GradientKind, Node, Paint};
use kurbo::{BezPath, Point};
fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec<PropGroup> {
effectcraft_path::from_kurbo(path)
.into_iter()
.enumerate()
.map(|(i, sp)| {
let mut g = build::shape_path(ids, sp);
g.name = format!("Path {}", i + 1);
g
})
.collect()
}
fn det_scale(m: Affine) -> f64 {
let c = m.as_coeffs();
(c[0] * c[3] - c[1] * c[2]).abs().sqrt()
}
/// Axis-aligned scale + translation only (basic shapes stay parametric).
fn axis_aligned(m: Affine) -> Option<(f64, f64, f64, f64)> {
let c = m.as_coeffs();
(c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && c[0] > 0.0 && c[3] > 0.0).then_some((c[0], c[3], c[4], c[5]))
}
fn gradient_value(g: &effectcraft_svg::Gradient) -> Gradient {
Gradient {
colors: g.stops.iter().map(|(o, c)| (*o, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0])).collect(),
opacities: g.stops.iter().map(|(o, c)| (*o, c[3] as f32)).collect(),
}
}
/// Gradient start/end points in shape-layer space (`m`: shape user space → layer space).
fn gradient_points(g: &effectcraft_svg::Gradient, m: Affine, bbox: kurbo::Rect) -> (bool, [f64; 2], [f64; 2]) {
let unit = if g.bbox_units { Affine::new([bbox.width(), 0.0, 0.0, bbox.height(), bbox.x0, bbox.y0]) } else { Affine::IDENTITY };
let full = m * unit * g.transform;
let p = |x: f64, y: f64| {
let q = full * Point::new(x, y);
[q.x, q.y]
};
match g.kind {
GradientKind::Linear { x1, y1, x2, y2 } => (false, p(x1, y1), p(x2, y2)),
GradientKind::Radial { cx, cy, r, .. } => (true, p(cx, cy), p(cx + r, cy)),
}
}
fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec<PropGroup> {
use kurbo::Shape as _;
let mut items = vec![];
// Geometry: parametric when the transform keeps rectangles/ellipses axis-aligned.
match (&s.geom, axis_aligned(m)) {
(Geom::Rect { x, y, w, h, rx, ry }, Some((sx, sy, tx, ty))) if (rx - ry).abs() < 1e-9 && (*rx == 0.0 || (sx - sy).abs() < 1e-9) => {
let (w2, h2) = (w * sx, h * sy);
let pos = [tx + (x + w / 2.0) * sx, ty + (y + h / 2.0) * sy];
let mut g = build::shape_rect(ids, [w2, h2], pos, rx * sx);
g.name = "Rectangle Path 1".into();
items.push(g);
}
(Geom::Ellipse { cx, cy, rx, ry }, Some((sx, sy, tx, ty))) => {
items.push(build::shape_ellipse(ids, [2.0 * rx * sx, 2.0 * ry * sy], [tx + cx * sx, ty + cy * sy]));
}
(g, _) => items.extend(shape_paths(ids, &(m * g.to_path()))),
}
let bbox = s.geom.to_path().bounding_box();
if let Some(st) = &s.stroke {
let width = st.width * det_scale(m);
let mut g = match &st.paint {
Paint::Color(c) => build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width),
Paint::Gradient(gr) => {
let (radial, a, b) = gradient_points(gr, m, bbox);
build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width)
}
};
set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0));
set(&mut g, "cap", Value::Enum(st.cap as u32));
set(&mut g, "join", Value::Enum(st.join as u32));
set(&mut g, "miter", Value::Scalar(st.miter));
if let Some((d, off)) = &st.dash
&& let Some(dg) = g.sub_mut("dashes")
{
let k = det_scale(m);
set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k));
set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k));
set(dg, "offset", Value::Scalar(off * k));
}
items.push(g);
}
if let Some(f) = &s.fill {
let mut g = match f {
Paint::Color(c) => build::shape_fill(ids, [c[0], c[1], c[2], 1.0]),
Paint::Gradient(gr) => {
let (radial, a, b) = gradient_points(gr, m, bbox);
build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr))
}
};
set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0));
set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd)));
items.push(g);
}
items
}
fn set(g: &mut PropGroup, k: &str, v: Value) {
if let Some(p) = g.get_mut(k) {
p.value = v;
}
}
fn group(ids: &mut Ids, name: &str, items: Vec<PropGroup>, opacity: f64) -> PropGroup {
let mut g = build::shape_group(ids, name, items);
if let Some(p) = g.prop_mut("transform/opacity") {
p.value = Value::Scalar(opacity * 100.0);
}
g
}
/// Shape layer contents for an SVG document, in document pixels (transforms are baked into the
/// geometry; group and element opacity go to the group transforms). Top of the paint order first.
pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine) -> Vec<PropGroup> {
let mut out = vec![];
for n in nodes.iter().rev() {
match n {
Node::Group(g) => {
let items = walk(ids, &g.children, m * g.transform);
if !items.is_empty() {
out.push(group(ids, &g.name, items, g.opacity));
}
}
Node::Shape(s) => {
let items = shape_items(ids, s, m * s.transform);
if !items.is_empty() {
out.push(group(ids, &s.name, items, s.opacity));
}
}
}
}
out
}
let root = &doc.root;
let items = walk(ids, &root.children, root.transform);
if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items }
}
/// A copy of `src`'s transform group with fresh uids.
fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) {
let Some(tr) = src.props.sub("transform") else { return };
let mut tr = tr.clone();
let mut n = proj.next_id;
tr.reassign_uids(&mut n);
proj.next_id = n + 1;
if let Some(slot) = dst.props.children.iter_mut().find(|c| c.match_id() == "transform") {
*slot = tr.into();
}
}
fn like_source(dst: &mut Layer, src: &Layer) {
dst.start_time = src.start_time;
dst.in_point = src.in_point;
dst.out_point = src.out_point;
dst.stretch = src.stretch;
dst.parent = src.parent;
dst.switches.three_d = src.switches.three_d;
}
/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`.
pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer {
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
let items = svg_contents(&mut Ids(&mut proj.next_id), doc);
if let Some(c) = l.props.sub_mut("contents") {
c.children.extend(items.into_iter().map(Into::into));
}
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
l
}
/// Layer ▸ Create ▸ Create Shapes from Text: one group per character with its outline, fill and
/// stroke (as laid out at the comp time `ctx` was made for).
pub fn shapes_from_text(proj: &mut Project, comp: &Comp, ctx_project: &Project, cid: ItemId, src: &Layer, time: effectcraft_time::Tick) -> Option<Layer> {
let ctx = effectcraft_render::EvalCtx::new(ctx_project, cid, comp, time);
let geom = effectcraft_render::text::text_geom(&ctx, src)?;
let chars: Vec<char> = geom.doc.text.chars().filter(|c| !c.is_whitespace()).collect();
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
let mut groups = vec![];
{
let mut ids = Ids(&mut proj.next_id);
for (i, g) in geom.glyphs.iter().enumerate() {
let path = g.path();
if path.elements().is_empty() {
continue;
}
let mut items = shape_paths(&mut ids, &path);
if g.stroke_width > 0.0 && g.stroke[3] > 0.0 {
let c = g.stroke;
items.push(build::shape_stroke(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0], g.stroke_width));
}
if g.apply_fill {
let c = g.fill;
let mut f = build::shape_fill(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
set(&mut f, "opacity", Value::Scalar(c[3] as f64 * 100.0));
items.push(f);
}
let name = chars.get(i).map(|c| c.to_string()).unwrap_or_else(|| format!("Glyph {}", i + 1));
groups.push(group(&mut ids, &name, items, 1.0));
}
}
// First character on top, as in After Effects.
if let Some(c) = l.props.sub_mut("contents") {
c.children.extend(groups.into_iter().map(Into::into));
}
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
Some(l)
}
/// Glyph outlines of a text layer in comp space (for Create Masks from Text).
pub fn text_outlines_in_comp(project: &Project, cid: ItemId, comp: &Comp, src: &Layer, time: effectcraft_time::Tick) -> Vec<ShapePath> {
let ctx = effectcraft_render::EvalCtx::new(project, cid, comp, time);
let Some(geom) = effectcraft_render::text::text_geom(&ctx, src) else { return vec![] };
let (m, _) = ctx.layer_to_comp(src);
let mut out = vec![];
for g in &geom.glyphs {
let p = g.path();
let p = effectcraft_path::transform(std::slice::from_ref(&p), &m).remove(0);
out.extend(effectcraft_path::from_kurbo(&p));
}
out
}
/// A comp-sized white solid with one mask per glyph contour (Difference, so counters stay open).
pub fn masks_layer(proj: &mut Project, comp: &Comp, name: &str, outlines: Vec<ShapePath>, solid: ItemId) -> Layer {
let mut l = build::layer(proj, comp, name, LayerSource::Solid { item: solid }, (comp.width, comp.height), None);
let mut ids = Ids(&mut proj.next_id);
if let Some(masks) = l.props.sub_mut("masks") {
for (i, sp) in outlines.into_iter().enumerate() {
masks.children.push(build::mask(&mut ids, &format!("Mask {}", i + 1), sp, MaskMode::Difference, [255, 255, 0]).into());
}
}
l
}
+19 -4
View File
@@ -11,6 +11,7 @@
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
//! automatically too).
use crate::offload::{JobKind, rate};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -232,6 +233,7 @@ fn run_work(w: WarpWork, shared: &WarpShared) {
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
crate::offload::report(s.done, s.total);
}
lock(&shared.state).step = 2;
let lt0 = layer.layer_time(w.times[0]).seconds();
@@ -264,21 +266,29 @@ pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) ->
impl Session {
pub fn is_warp_analyzing(&self) -> bool {
self.warp_job.as_ref().is_some_and(|j| !j.is_finished())
self.warp_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Warp).is_some()
}
/// Progress of the running (or just finished) analysis.
pub fn warp_progress(&self) -> Option<WarpProgress> {
self.warp_job.as_ref().map(|j| j.progress())
self.warp_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::Warp).map(|j| {
let p = &j.progress;
WarpProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: rate(p.done, p.elapsed), ..Default::default() }
})
})
}
/// The effect being analysed: (comp, layer, effect uid).
pub fn warp_target(&self) -> Option<(ItemId, LayerId, Uid)> {
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Warp).map(|j| (j.comp, j.layer, j.uid)))
}
/// Cancel the running analysis (nothing is written).
pub fn stop_warp(&mut self) -> bool {
if self.cancel_offloaded(JobKind::Warp) {
return true;
}
match &self.warp_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
@@ -325,6 +335,11 @@ impl Session {
times,
};
let key = signature(&self.project, comp, l);
if !wait && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(WarpShared::default());
let thread = if wait {
@@ -391,7 +406,7 @@ impl Session {
/// re-analyses in the background, like After Effects.
pub fn poll_warp(&mut self, auto: bool) -> bool {
let changed = self.poll_warp_job();
if auto && self.warp_job.is_none() {
if auto && self.warp_job.is_none() && self.offloaded(JobKind::Warp).is_none() {
while let Some((c, l, e)) = self.warp_pending.first().copied() {
self.warp_pending.remove(0);
if self.start_warp(c, l, e, false).is_ok() {