mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
M15.2: merge main (disk cache, 3D Camera Tracker); camera analysis in workers; no disk cache on wasm32
The 3D Camera Tracker analysis joins the offloaded jobs. The disk cache stays off in the browser (std::env::temp_dir panics on wasm32 once a config store exists). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(¶ms);
|
||||
let solve_key = ct::solve_key(&settings);
|
||||
let tracks = ct::tracks(¶ms).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);
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms);
|
||||
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(¶ms);
|
||||
out["shotType"] = json!(settings.shot.label());
|
||||
out["solveMethod"] = json!(settings.method.label());
|
||||
out["deleted"] = json!(settings.deleted.len());
|
||||
let solve = ct::solve(¶ms);
|
||||
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(¶ms, "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(¶ms);
|
||||
let auto = effectcraft_effects::warp_stab::param(¶ms, "advanced/autoDeletePoints").is_some_and(KV::as_bool);
|
||||
let mut add = ids.clone();
|
||||
if auto && let Some(sv) = ct::solve(¶ms) {
|
||||
// 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()}))
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -84,10 +84,48 @@ 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 importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
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 {
|
||||
match importer.probe(path) {
|
||||
@@ -99,6 +137,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,6 +157,10 @@ 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,
|
||||
@@ -118,7 +172,37 @@ 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))
|
||||
}
|
||||
|
||||
fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -224,7 +308,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...",
|
||||
|
||||
@@ -107,6 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage {
|
||||
missing: true,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
mod anim;
|
||||
pub mod anim_tools;
|
||||
mod animation;
|
||||
mod camera_cmds;
|
||||
mod comp;
|
||||
mod comp_more;
|
||||
mod create;
|
||||
mod edit;
|
||||
mod effect;
|
||||
mod file;
|
||||
@@ -124,6 +126,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());
|
||||
@@ -135,6 +138,7 @@ 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());
|
||||
|
||||
@@ -131,7 +131,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 +256,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 +434,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 +509,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, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
|
||||
@@ -29,7 +29,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// 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).
|
||||
@@ -51,7 +50,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"),
|
||||
stub!("track.camera", "Track Camera", ["Animation"], "{}"),
|
||||
// Workspaces and panels that don't exist yet.
|
||||
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! (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;
|
||||
@@ -17,11 +18,13 @@ 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;
|
||||
|
||||
@@ -173,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,
|
||||
@@ -269,6 +275,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).
|
||||
@@ -278,6 +288,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…).
|
||||
@@ -340,11 +352,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,
|
||||
@@ -411,6 +426,12 @@ impl Session {
|
||||
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) {
|
||||
@@ -436,6 +457,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 });
|
||||
}
|
||||
|
||||
@@ -594,6 +618,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();
|
||||
@@ -620,6 +647,10 @@ 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_fidelity;
|
||||
@@ -650,6 +681,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).
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! | 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.
|
||||
@@ -65,6 +66,11 @@ pub enum WorkerJob {
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
Camera {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
RotoFreeze {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
@@ -95,6 +101,7 @@ impl WorkerJob {
|
||||
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,
|
||||
@@ -105,7 +112,9 @@ impl WorkerJob {
|
||||
pub fn target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
match *self {
|
||||
WorkerJob::Render { .. } => None,
|
||||
WorkerJob::Warp { comp, layer, effect } | WorkerJob::RotoFreeze { comp, layer, effect } => Some((comp, layer, effect)),
|
||||
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)),
|
||||
}
|
||||
@@ -117,6 +126,7 @@ impl WorkerJob {
|
||||
pub enum JobKind {
|
||||
Render,
|
||||
Warp,
|
||||
Camera,
|
||||
RotoFreeze,
|
||||
Track,
|
||||
MaskTrack,
|
||||
@@ -128,6 +138,7 @@ impl JobKind {
|
||||
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",
|
||||
@@ -277,6 +288,7 @@ fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
|
||||
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(|_| ())
|
||||
}),
|
||||
@@ -554,6 +566,7 @@ mod tests {
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
//! 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() }),
|
||||
};
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 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,
|
||||
};
|
||||
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);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ impl Importer for ModelImporter {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ impl Importer for MockImporter {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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", "file.createProxy"] {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Ses
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
};
|
||||
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,
|
||||
layer: None,
|
||||
};
|
||||
let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage));
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user