mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
Merge branch 'main' into worktree-agent-a15cecfec57d5290d
# Conflicts: # crates/effects/src/ocio.rs # crates/engine/src/commands/stubs.rs # docs/parity.md
This commit is contained in:
@@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::ConfigStore;
|
||||
use crate::config::{ConfigStore, FileOps, StdFiles};
|
||||
use crate::prefs::Prefs;
|
||||
|
||||
/// Folder created next to a project for its auto-saves.
|
||||
@@ -68,16 +68,12 @@ pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) ->
|
||||
|
||||
/// Existing auto-saves of `stem` in `dir`: (slot, path, modified).
|
||||
pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut v: Vec<_> = rd
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let n = slot_of(stem, &name)?;
|
||||
let m = e.metadata().ok()?.modified().ok()?;
|
||||
Some((n, e.path(), m))
|
||||
})
|
||||
.collect();
|
||||
existing_in(&StdFiles, dir, stem)
|
||||
}
|
||||
|
||||
/// [`existing`] through `fs`.
|
||||
pub fn existing_in(fs: &dyn FileOps, dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
|
||||
let mut v: Vec<_> = fs.list(dir).into_iter().filter_map(|(name, m)| Some((slot_of(stem, &name)?, dir.join(&name), m))).collect();
|
||||
v.sort_by_key(|x| x.0);
|
||||
v
|
||||
}
|
||||
@@ -95,18 +91,29 @@ pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Optio
|
||||
|
||||
/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path.
|
||||
pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option<u32>, json: &str) -> std::io::Result<(PathBuf, u32)> {
|
||||
write_in(&StdFiles, prefs, project, fallback, last_slot, json)
|
||||
}
|
||||
|
||||
/// [`write`] through `fs`.
|
||||
pub fn write_in(
|
||||
fs: &dyn FileOps,
|
||||
prefs: &Prefs,
|
||||
project: Option<&str>,
|
||||
fallback: Option<&Path>,
|
||||
last_slot: Option<u32>,
|
||||
json: &str,
|
||||
) -> std::io::Result<(PathBuf, u32)> {
|
||||
let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let stem = project_stem(project);
|
||||
let max = prefs.auto_save.max_versions.max(1);
|
||||
let have = existing(&dir, &stem);
|
||||
let have = existing_in(fs, &dir, &stem);
|
||||
let slot = next_slot(&have, last_slot, max);
|
||||
let path = dir.join(file_name(&stem, slot));
|
||||
crate::config::atomic_write(&path, json.as_bytes())?;
|
||||
fs.write(&path, json.as_bytes())?;
|
||||
// Max versions lowered: drop slots above it.
|
||||
for (n, p, _) in have {
|
||||
if n > max {
|
||||
let _ = std::fs::remove_file(p);
|
||||
let _ = fs.remove(&p);
|
||||
}
|
||||
}
|
||||
Ok((path, slot))
|
||||
@@ -114,8 +121,13 @@ pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last
|
||||
|
||||
/// The newest auto-save of a project, if any.
|
||||
pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
|
||||
latest_in(&StdFiles, prefs, project, fallback)
|
||||
}
|
||||
|
||||
/// [`latest`] through `fs`.
|
||||
pub fn latest_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
|
||||
let dir = folder(prefs, project, fallback)?;
|
||||
existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
|
||||
existing_in(fs, &dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
|
||||
}
|
||||
|
||||
/// What the crash-recovery sentinel records.
|
||||
@@ -151,8 +163,8 @@ pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option<Recovery> {
|
||||
let autosave = s
|
||||
.autosave
|
||||
.clone()
|
||||
.filter(|p| Path::new(p).is_file())
|
||||
.or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
|
||||
.filter(|p| store.files().is_file(Path::new(p)))
|
||||
.or_else(|| latest_in(store.files(), prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
|
||||
Some(Recovery { project: s.project, autosave, dirty: s.dirty })
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ 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};
|
||||
@@ -242,6 +243,7 @@ fn run_work(w: CamWork, shared: &CameraShared) {
|
||||
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)
|
||||
}
|
||||
@@ -259,21 +261,29 @@ fn run_work(w: CamWork, shared: &CameraShared) {
|
||||
|
||||
impl Session {
|
||||
pub fn is_camera_analyzing(&self) -> bool {
|
||||
self.camera_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
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())
|
||||
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))
|
||||
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);
|
||||
@@ -326,6 +336,11 @@ impl Session {
|
||||
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());
|
||||
{
|
||||
@@ -423,7 +438,7 @@ impl Session {
|
||||
/// (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() {
|
||||
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() {
|
||||
|
||||
@@ -87,6 +87,9 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
||||
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
|
||||
c.motion_blur_samples = (n as u32).clamp(2, 64);
|
||||
}
|
||||
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
|
||||
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
|
||||
}
|
||||
}
|
||||
|
||||
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -249,7 +252,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Composition...",
|
||||
["Composition"],
|
||||
Some("Cmd+N"),
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, renderer? classic3D|advanced3D, anchor?, open?}",
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), renderer? classic3D|advanced3D, anchor?, open?}",
|
||||
always,
|
||||
new_comp
|
||||
),
|
||||
@@ -258,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Composition Settings...",
|
||||
["Composition"],
|
||||
Some("Cmd+K"),
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?, renderer? classic3D|advanced3D}",
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), pixelAspect?, renderer? classic3D|advanced3D}",
|
||||
has_comp,
|
||||
settings
|
||||
),
|
||||
|
||||
@@ -69,7 +69,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
|
||||
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
|
||||
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
|
||||
let next = crate::autosave::increment_path(&cur, |p| s.file_ops().is_file(std::path::Path::new(p)));
|
||||
save_to(s, &next)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Layer ▸ Camera focus commands: Link Focus Distance to Point of Interest, Link Focus Distance to
|
||||
//! Layer (both add an expression to Focus Distance, as After Effects does) and Set Focus Distance
|
||||
//! to Layer (a one-off value).
|
||||
|
||||
use effectcraft_geom::vec3;
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Comp, Expression, ItemId, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, comp_id, layer_mut, resolve_layer};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// (comp, camera, target?) from `{camera?, layer?}` or the selection (a camera plus another layer).
|
||||
fn refs(s: &Session, p: &Value, cmd: &str, need_target: bool) -> Result<(ItemId, LayerId, Option<LayerId>)> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let sel: Vec<LayerId> = s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect();
|
||||
let is_cam = |c: &Comp, l: LayerId| c.layer(l).is_some_and(|l| l.is_camera());
|
||||
let cam = match p.get("camera") {
|
||||
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?,
|
||||
None => sel.iter().copied().find(|l| is_cam(comp, *l)).ok_or_else(|| bad(cmd, "select a camera layer"))?,
|
||||
};
|
||||
if !is_cam(comp, cam) {
|
||||
return Err(bad(cmd, "`camera` is not a camera layer"));
|
||||
}
|
||||
let target = match p.get("layer") {
|
||||
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?),
|
||||
None => sel.iter().copied().find(|l| *l != cam),
|
||||
};
|
||||
if need_target && target.is_none() {
|
||||
return Err(bad(cmd, "select the camera and the layer to focus on"));
|
||||
}
|
||||
Ok((cid, cam, target))
|
||||
}
|
||||
|
||||
fn set_focus_expr(s: &mut Session, cid: ItemId, cam: LayerId, label: &str, text: String) -> Result<Value> {
|
||||
s.edit(label, None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, cam)?;
|
||||
let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad("camera", "the camera has no Focus Distance"))?;
|
||||
pr.expr = Some(Expression { text: text.clone(), enabled: true });
|
||||
Ok(json!({"expression": text}))
|
||||
})
|
||||
}
|
||||
|
||||
fn link_poi(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, cam, _) = refs(s, p, "camera.linkFocusToPoi", false)?;
|
||||
set_focus_expr(s, cid, cam, "Link Focus Distance to Point of Interest", "length(transform.pointOfInterest, transform.position)".into())
|
||||
}
|
||||
|
||||
/// The focus expression for a target layer: its anchor point's distance along the camera's view
|
||||
/// axis.
|
||||
fn layer_expr(name: &str) -> String {
|
||||
let n = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
|
||||
format!(
|
||||
"var m = thisComp.layer({n});\nvar c = toWorld([0, 0, 0]);\nvar f = normalize(sub(toWorld([0, 0, 1]), c));\ndot(sub(m.toWorld(m.transform.anchorPoint), c), f)"
|
||||
)
|
||||
}
|
||||
|
||||
fn link_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, cam, target) = refs(s, p, "camera.linkFocusToLayer", true)?;
|
||||
let name = s.project.comp(cid).and_then(|c| c.layer(target?)).map(|l| l.name.clone()).ok_or(EngineError::NoComp)?;
|
||||
set_focus_expr(s, cid, cam, "Link Focus Distance to Layer", layer_expr(&name))
|
||||
}
|
||||
|
||||
/// Camera depth of `target`'s anchor point at the current time.
|
||||
pub(crate) fn focus_depth(s: &Session, cid: ItemId, cam: LayerId, target: LayerId) -> Option<f64> {
|
||||
let comp = s.project.comp(cid)?;
|
||||
let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid));
|
||||
ctx.expr = s.expr.as_deref();
|
||||
let c = ctx.comp.layer(cam)?;
|
||||
let t = ctx.comp.layer(target)?;
|
||||
let cs = effectcraft_render::three_d::camera::layer_camera(&ctx, c);
|
||||
let a = t.transform().map(|tr| ctx.v3(t, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
|
||||
Some(cs.depth(ctx.world_matrix(t).apply(vec3(a[0], a[1], a[2]))))
|
||||
}
|
||||
|
||||
fn set_to_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "camera.setFocusToLayer";
|
||||
let (cid, cam, target) = refs(s, p, c, true)?;
|
||||
let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?;
|
||||
if d <= 0.0 {
|
||||
return Err(bad(c, "the layer is behind the camera"));
|
||||
}
|
||||
let t = s.time();
|
||||
s.edit("Set Focus Distance to Layer", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, cam)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad(c, "the camera has no Focus Distance"))?;
|
||||
pr.set_value_at(lt, KV::Scalar(d));
|
||||
Ok(json!({"focusDistance": d}))
|
||||
})
|
||||
}
|
||||
|
||||
fn camera_selected(s: &Session) -> std::result::Result<(), String> {
|
||||
let c = s.active_comp().ok_or("no composition is open")?;
|
||||
if s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| l.is_camera())) { Ok(()) } else { Err("select a camera layer".into()) }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], None, "{comp?, camera?}", camera_selected, link_poi),
|
||||
cmd!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, link_layer),
|
||||
cmd!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, set_to_layer),
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// Application.
|
||||
fe!("app.about", "About EffectCraft...", [], None, "{}", always),
|
||||
fe!("layer.style.options", "Layer Style Options...", ["Layer", "Layer Styles"], None, "{layer?, style?: blendingOptions|dropShadow|…}", has_layers),
|
||||
fe!(
|
||||
"app.settings",
|
||||
"Settings...",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Keyframe color labels: Label a keyframe (Keyframe ▸ Label ▸ colour) and Edit ▸ Select Keyframe
|
||||
//! Label Group (On Selected Layers / On All Layers / Visible Keyframes on …).
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_comp, has_keys, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
fn label_p(p: &Value, c: &str) -> Result<u8> {
|
||||
match p.get("label") {
|
||||
Some(Value::String(s)) => {
|
||||
let l = if s.eq_ignore_ascii_case("none") { Some(Label::None) } else { Label::from_name(s) };
|
||||
let l = l.ok_or_else(|| bad(c, format!("unknown label `{s}`")))?;
|
||||
Ok(Label::ALL.iter().position(|x| *x == l).unwrap_or(0) as u8)
|
||||
}
|
||||
Some(Value::Number(n)) => Ok(n.as_u64().filter(|i| (*i as usize) < Label::ALL.len()).ok_or_else(|| bad(c, "label index 0–16"))? as u8),
|
||||
_ => Err(bad(c, "missing `label` (name or 0–16)")),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_label(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "keys.setLabel";
|
||||
let label = label_p(p, c)?;
|
||||
let n = super::prop::edit_keys(s, "Keyframe Label", None, |keys, i, _| {
|
||||
keys[i].label = label;
|
||||
true
|
||||
})?;
|
||||
Ok(json!({"label": Label::ALL[label as usize].name(), "keys": n}))
|
||||
}
|
||||
|
||||
/// Select every keyframe that shares a label with a selected keyframe, on the selected or all
|
||||
/// layers. `visible` (property uids revealed in the Timeline) limits the "Visible Keyframes"
|
||||
/// scopes; without it every property counts as visible.
|
||||
fn select_label_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "keys.selectLabelGroup";
|
||||
let scope = str_p(p, "scope").unwrap_or("selected");
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let labels: Vec<u8> =
|
||||
s.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer)?.props.find(k.prop)?.keys.iter().find(|x| x.time == k.time).map(|x| x.label)).collect();
|
||||
if labels.is_empty() {
|
||||
return Err(bad(c, "select a keyframe first"));
|
||||
}
|
||||
let only_selected = match scope {
|
||||
"selected" | "visibleSelected" => true,
|
||||
"all" | "visibleAll" => false,
|
||||
_ => return Err(bad(c, "scope: selected|all|visibleSelected|visibleAll")),
|
||||
};
|
||||
let visible: Option<Vec<u64>> =
|
||||
(scope.starts_with("visible")).then(|| p.get("visible").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect())).flatten();
|
||||
let key_layers: Vec<_> = s.state.selected_keys.iter().map(|k| k.layer).collect();
|
||||
let mut sel = Vec::new();
|
||||
for l in &comp.layers {
|
||||
if only_selected && !s.state.selected_layers.contains(&l.id) && !key_layers.contains(&l.id) {
|
||||
continue;
|
||||
}
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
if visible.as_ref().is_some_and(|v| !v.contains(&pr.uid)) {
|
||||
return;
|
||||
}
|
||||
for k in &pr.keys {
|
||||
if labels.contains(&k.label) {
|
||||
sel.push(KeyRef { layer: l.id, prop: pr.uid, time: k.time });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!({"keys": s.state.selected_keys.len()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("keys.setLabel", "Keyframe Label", [], None, "{label: name|none|0–16}", has_keys, set_label),
|
||||
cmd!(
|
||||
"keys.selectLabelGroup",
|
||||
"Select Keyframe Label Group",
|
||||
[],
|
||||
None,
|
||||
"{scope: selected|all|visibleSelected|visibleAll, visible?: [prop uid]}",
|
||||
has_comp,
|
||||
select_label_group
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -86,7 +86,7 @@ fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut outs = pts(p.get("outTangents"));
|
||||
ins.resize(n, [0.0; 2]);
|
||||
outs.resize(n, [0.0; 2]);
|
||||
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
|
||||
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
|
||||
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
|
||||
let uid = s.edit("New Mask", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
@@ -114,6 +114,7 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sp.vertices.insert(i, point);
|
||||
sp.in_tangents.insert(i, tin);
|
||||
sp.out_tangents.insert(i, tout);
|
||||
sp.feather_after_insert(i, None);
|
||||
Ok(i)
|
||||
})?;
|
||||
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
|
||||
@@ -241,6 +242,7 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sp.vertices.remove(i);
|
||||
sp.in_tangents.remove(i);
|
||||
sp.out_tangents.remove(i);
|
||||
sp.feather_after_remove(i);
|
||||
}
|
||||
}
|
||||
if sp.vertices.len() < 3 {
|
||||
@@ -324,9 +326,199 @@ pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option<us
|
||||
sp.vertices.insert(at, m);
|
||||
sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]);
|
||||
sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]);
|
||||
sp.feather_after_insert(at, Some(t));
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// Nearest (segment, t, distance) of a path to a point (layer space), sampled per segment.
|
||||
pub(crate) fn nearest_on_path(sp: &ShapePath, q: [f64; 2]) -> Option<(usize, f64, f64)> {
|
||||
let n = sp.vertices.len();
|
||||
let mut best: Option<(usize, f64, f64)> = None;
|
||||
for seg in 0..sp.segment_count() {
|
||||
let j = (seg + 1) % n;
|
||||
let p0 = sp.vertices[seg];
|
||||
let p3 = sp.vertices[j];
|
||||
let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]);
|
||||
let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
|
||||
let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]);
|
||||
let at = |t: f64| {
|
||||
let u = 1.0 - t;
|
||||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
[a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]]
|
||||
};
|
||||
let dist = |t: f64| {
|
||||
let p = at(t);
|
||||
(p[0] - q[0]).hypot(p[1] - q[1])
|
||||
};
|
||||
let (mut bt, mut bd) = (0.0, f64::INFINITY);
|
||||
for k in 0..=64 {
|
||||
let t = k as f64 / 64.0;
|
||||
let d = dist(t);
|
||||
if d < bd {
|
||||
(bt, bd) = (t, d);
|
||||
}
|
||||
}
|
||||
// Refine by bisection-style narrowing.
|
||||
let mut step = 1.0 / 64.0;
|
||||
for _ in 0..20 {
|
||||
step *= 0.5;
|
||||
for t in [bt - step, bt + step] {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let d = dist(t);
|
||||
if d < bd {
|
||||
(bt, bd) = (t, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_none_or(|b| bd < b.2) {
|
||||
best = Some((seg, bt, bd));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Whether a layer-space point is inside a closed path.
|
||||
fn inside_path(sp: &ShapePath, q: [f64; 2]) -> bool {
|
||||
sp.closed && {
|
||||
let cov = effectcraft_path::fill_coverage(
|
||||
&[effectcraft_path::to_kurbo(sp)],
|
||||
&effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-q[0] + 0.5, -q[1] + 0.5)),
|
||||
1,
|
||||
1,
|
||||
effectcraft_path::FillRule::NonZero,
|
||||
);
|
||||
cov.data[0] >= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Point of segment `seg` at parameter `t`.
|
||||
fn point_on(sp: &ShapePath, seg: usize, t: f64) -> Option<[f64; 2]> {
|
||||
let n = sp.vertices.len();
|
||||
if seg >= sp.segment_count() {
|
||||
return None;
|
||||
}
|
||||
let j = (seg + 1) % n;
|
||||
let (p0, p3) = (sp.vertices[seg], sp.vertices[j]);
|
||||
let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]);
|
||||
let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
|
||||
let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]);
|
||||
let u = 1.0 - t;
|
||||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
Some([a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]])
|
||||
}
|
||||
|
||||
fn feather_index(p: &Value, sp: &ShapePath, c: &str) -> Result<usize> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (feather point, 0-based)"))? as usize;
|
||||
if i >= sp.feather.len() {
|
||||
return Err(bad(c, format!("no feather point {i} (the mask has {})", sp.feather.len())));
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
/// Mask Feather tool: add a feather point on the path, by `segment`/`t` or at the path position
|
||||
/// nearest `point` (layer space). Without `radius`, a `point` off the path sets it from its
|
||||
/// distance (outside the mask = outer feather, inside = inner).
|
||||
fn feather_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "mask.featherPoint.add";
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0));
|
||||
let q = pt(p.get("point"));
|
||||
let radius = super::f_p(p, "radius");
|
||||
let tension = super::f_p(p, "tension").unwrap_or(0.0).clamp(0.0, 100.0) / 100.0;
|
||||
let (uid, i) = edit_path(s, p, c, |sp| {
|
||||
let (seg, t, r) = match (seg, q) {
|
||||
(Some(seg), _) => {
|
||||
if seg >= sp.segment_count() {
|
||||
return Err(bad(c, format!("no segment {seg}")));
|
||||
}
|
||||
(seg, t.unwrap_or(0.5), radius.unwrap_or(0.0))
|
||||
}
|
||||
(None, Some(q)) => {
|
||||
let (seg, t, d) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?;
|
||||
(seg, t, radius.unwrap_or(if inside_path(sp, q) { -d } else { d }))
|
||||
}
|
||||
(None, None) => return Err(bad(c, "give `segment` (and `t`) or `point`")),
|
||||
};
|
||||
sp.feather.push(effectcraft_keyframe::FeatherPoint { segment: seg, t, radius: r, tension });
|
||||
Ok(sp.feather.len() - 1)
|
||||
})?;
|
||||
Ok(json!({"mask": uid, "index": i}))
|
||||
}
|
||||
|
||||
/// Drag a feather point: move it along the path (`segment`/`t`, or nearest `point`) and/or set
|
||||
/// its `radius` (signed: negative = inner) and `tension` (%).
|
||||
fn feather_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "mask.featherPoint.set";
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0));
|
||||
let q = pt(p.get("point"));
|
||||
let radius = super::f_p(p, "radius");
|
||||
let tension = super::f_p(p, "tension").map(|v| v.clamp(0.0, 100.0) / 100.0);
|
||||
let toward = pt(p.get("toward"));
|
||||
let (_, out) = edit_path(s, p, c, |sp| {
|
||||
let i = feather_index(p, sp, c)?;
|
||||
if let Some(q) = q {
|
||||
let (sg, tt, _) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?;
|
||||
sp.feather[i].segment = sg;
|
||||
sp.feather[i].t = tt;
|
||||
}
|
||||
if let Some(sg) = seg {
|
||||
if sg >= sp.segment_count() {
|
||||
return Err(bad(c, format!("no segment {sg}")));
|
||||
}
|
||||
sp.feather[i].segment = sg;
|
||||
}
|
||||
if let Some(t) = t {
|
||||
sp.feather[i].t = t;
|
||||
}
|
||||
if let Some(r) = radius {
|
||||
sp.feather[i].radius = r;
|
||||
}
|
||||
// Dragging the radius handle: the distance from the point's path position to `toward`,
|
||||
// outer outside the mask, inner inside.
|
||||
if let Some(w) = toward {
|
||||
let f = sp.feather[i];
|
||||
let at = point_on(sp, f.segment, f.t).ok_or_else(|| bad(c, "the feather point is off the path"))?;
|
||||
let d = (w[0] - at[0]).hypot(w[1] - at[1]);
|
||||
sp.feather[i].radius = if inside_path(sp, w) { -d } else { d };
|
||||
}
|
||||
if let Some(k) = tension {
|
||||
sp.feather[i].tension = k;
|
||||
}
|
||||
Ok(serde_json::to_value(sp.feather[i]).unwrap_or(Value::Null))
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn feather_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "mask.featherPoint.remove";
|
||||
let all = super::b_p(p, "all").unwrap_or(false);
|
||||
let (_, n) = edit_path(s, p, c, |sp| {
|
||||
if all {
|
||||
sp.feather.clear();
|
||||
} else {
|
||||
let i = feather_index(p, sp, c)?;
|
||||
sp.feather.remove(i);
|
||||
}
|
||||
Ok(sp.feather.len())
|
||||
})?;
|
||||
Ok(json!({"remaining": n}))
|
||||
}
|
||||
|
||||
fn feather_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "mask.featherPoint.list";
|
||||
let (cid, lid) = layer_p(s, p, c)?;
|
||||
let key = p.get("mask").cloned().ok_or_else(|| bad(c, "missing `mask`"))?;
|
||||
let t = s.time();
|
||||
let mut props = s.project.comp(cid).and_then(|cc| cc.layer(lid)).ok_or(EngineError::NoComp)?.props.clone();
|
||||
let lt = s.project.comp(cid).and_then(|cc| cc.layer(lid)).map(|l| l.layer_time(t)).unwrap_or(t);
|
||||
let g = path_group(&mut props, &key).ok_or_else(|| bad(c, "no such mask"))?;
|
||||
let Some(KV::Path(sp)) = g.get("path").map(|pr| pr.value_at(lt)) else { return Err(bad(c, "mask has no path")) };
|
||||
Ok(
|
||||
json!({"points": sp.feather.iter().map(|f| json!({"segment": f.segment, "t": f.t, "radius": f.radius, "tension": f.tension * 100.0})).collect::<Vec<_>>()}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5).
|
||||
fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seg =
|
||||
@@ -370,6 +562,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
|
||||
cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex),
|
||||
cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex),
|
||||
cmd!(
|
||||
"mask.featherPoint.add",
|
||||
"Add Mask Feather Point",
|
||||
[],
|
||||
None,
|
||||
"{layer?, mask, segment?, t?: 0..1, point?: [x,y] (layer space), radius? (px, negative = inner), tension? (%)} → {index}",
|
||||
has_comp,
|
||||
feather_add
|
||||
),
|
||||
cmd!(
|
||||
"mask.featherPoint.set",
|
||||
"Move Mask Feather Point",
|
||||
[],
|
||||
None,
|
||||
"{layer?, mask, index, segment?, t?, point? (slide along the path), radius?, toward?: [x,y] (radius from the drag point, layer space), tension? (%), merge?}",
|
||||
has_comp,
|
||||
feather_set
|
||||
),
|
||||
cmd!("mask.featherPoint.remove", "Delete Mask Feather Point", [], None, "{layer?, mask, index? | all?}", has_comp, feather_remove),
|
||||
crate::query!("mask.featherPoint.list", "Mask Feather Points", "{layer?, mask}", feather_list),
|
||||
cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
|
||||
cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
|
||||
]
|
||||
|
||||
@@ -14,8 +14,10 @@ pub mod essential;
|
||||
pub mod expr_tools;
|
||||
mod file;
|
||||
pub(crate) mod file_more;
|
||||
mod focus;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod key_labels;
|
||||
mod key_transform;
|
||||
mod layer;
|
||||
mod layer_menu;
|
||||
@@ -39,6 +41,7 @@ mod render_queue;
|
||||
pub(crate) mod rig3d;
|
||||
pub mod roto_cmds;
|
||||
mod settings;
|
||||
mod shape_stroke;
|
||||
mod stubs;
|
||||
mod styles;
|
||||
mod text_anim;
|
||||
@@ -135,6 +138,9 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(animation::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(shape_stroke::specs());
|
||||
v.extend(focus::specs());
|
||||
v.extend(key_labels::specs());
|
||||
v.extend(key_transform::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(lottie::specs());
|
||||
|
||||
@@ -239,7 +239,7 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let root = s.default_autosave_root();
|
||||
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
Ok(json!({
|
||||
"lastAutoSave": s.autosave.last_path,
|
||||
"latestOnDisk": latest,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and
|
||||
//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the
|
||||
//! commands here set several of them in one undo step and add the groups to strokes saved before
|
||||
//! they existed.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
|
||||
use effectcraft_project::{ItemId, LayerId, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn is_stroke(g: &PropGroup) -> bool {
|
||||
matches!(g.match_id.as_str(), "stroke" | "gstroke")
|
||||
}
|
||||
|
||||
/// `{layer?, prop?: stroke uid}` → the stroke; without `prop`, the first stroke of the layer (a
|
||||
/// selected stroke property wins).
|
||||
fn stroke_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
if let Some(uid) = p.get("prop").and_then(Value::as_u64) {
|
||||
let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?;
|
||||
if !is_stroke(g) {
|
||||
return Err(bad(cmd, format!("`{}` is not a Stroke or Gradient Stroke", g.name)));
|
||||
}
|
||||
return Ok((cid, lid, uid));
|
||||
}
|
||||
let contents = l.props.sub("contents").ok_or_else(|| bad(cmd, "not a shape layer"))?;
|
||||
// A selected stroke (or a property inside one), else the first stroke in the contents.
|
||||
let mut found = None;
|
||||
for (sl, su) in &s.state.selected_props {
|
||||
if *sl == lid
|
||||
&& let Some(g) = find_stroke_containing(contents, *su)
|
||||
{
|
||||
found = Some(g);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if found.is_none() {
|
||||
let mut first = None;
|
||||
walk_groups(contents, &mut |g| {
|
||||
if first.is_none() && is_stroke(g) {
|
||||
first = Some(g.uid);
|
||||
}
|
||||
});
|
||||
found = first;
|
||||
}
|
||||
found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, "the layer has no stroke"))
|
||||
}
|
||||
|
||||
fn walk_groups(g: &PropGroup, f: &mut impl FnMut(&PropGroup)) {
|
||||
for sub in g.groups() {
|
||||
f(sub);
|
||||
walk_groups(sub, f);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_stroke_containing(contents: &PropGroup, uid: Uid) -> Option<Uid> {
|
||||
let mut out = None;
|
||||
walk_groups(contents, &mut |g| {
|
||||
if out.is_none() && is_stroke(g) && (g.uid == uid || g.find(uid).is_some() || g.find_group(uid).is_some()) {
|
||||
out = Some(g.uid);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn dashes_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "shape.dashes.add";
|
||||
let (cid, lid, uid) = stroke_ref(s, p, c)?;
|
||||
s.edit("Add Dash", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
|
||||
let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?;
|
||||
// Before the first "+", Dash is 0 (no dashes): the first "+" turns it on.
|
||||
let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0);
|
||||
let mut ids = Ids(&mut next);
|
||||
let added = if dash <= 0.0 {
|
||||
if let Some(p) = d.get_mut("dash") {
|
||||
p.value = KV::Scalar(10.0);
|
||||
}
|
||||
"dash"
|
||||
} else {
|
||||
let Some((dm, gm)) = EXTRA_DASHES.iter().find(|(dm, _)| d.get(dm).is_none()) else {
|
||||
return Err(bad(c, "a stroke has at most three dash/gap pairs"));
|
||||
};
|
||||
let k = if *dm == "dash2" { 2 } else { 3 };
|
||||
// Insert before Offset, which stays last.
|
||||
let at = d.children.iter().position(|n| n.match_id() == "offset").unwrap_or(d.children.len());
|
||||
let dv = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(10.0);
|
||||
let gv = d.get("gap").map(|p| p.value.as_f64()).filter(|g| *g > 0.0).unwrap_or(dv);
|
||||
d.children.insert(at, ids.prop(gm, &format!("Gap {k}"), KV::Scalar(gv)).with_ui(effectcraft_project::ParamUi::Pixels).into());
|
||||
d.children.insert(at, ids.prop(dm, &format!("Dash {k}"), KV::Scalar(dv)).with_ui(effectcraft_project::ParamUi::Pixels).into());
|
||||
dm
|
||||
};
|
||||
proj.next_id = next;
|
||||
Ok(json!({"added": added}))
|
||||
})
|
||||
}
|
||||
|
||||
fn dashes_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "shape.dashes.remove";
|
||||
let (cid, lid, uid) = stroke_ref(s, p, c)?;
|
||||
s.edit("Remove Dash", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
|
||||
let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?;
|
||||
for (dm, gm) in EXTRA_DASHES.iter().rev() {
|
||||
if d.get(dm).is_some() {
|
||||
d.children.retain(|n| n.match_id() != *dm && n.match_id() != *gm);
|
||||
return Ok(json!({"removed": dm}));
|
||||
}
|
||||
}
|
||||
// The last "−" turns dashes off.
|
||||
if let Some(p) = d.get_mut("dash") {
|
||||
p.value = KV::Scalar(0.0);
|
||||
p.keys.clear();
|
||||
}
|
||||
Ok(json!({"removed": "dash"}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Set `fields` (`param name → (match id, kind)`) of the stroke's `group`, creating the group.
|
||||
fn set_group(s: &mut Session, p: &Value, c: &str, group: &str, fields: &[(&str, &str, bool)]) -> Result<Value> {
|
||||
let (cid, lid, uid) = stroke_ref(s, p, c)?;
|
||||
let t = s.time();
|
||||
let label = if group == "taper" { "Stroke Taper" } else { "Stroke Wave" };
|
||||
s.edit(label, merge_p(p), |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
{
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
|
||||
if g.sub(group).is_none() {
|
||||
let mut ids = Ids(&mut next);
|
||||
let ng = if group == "taper" { build::stroke_taper(&mut ids) } else { build::stroke_wave(&mut ids) };
|
||||
g.children.push(ng.into());
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let g = l.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
|
||||
let sub = g.sub_mut(group).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let mut out = serde_json::Map::new();
|
||||
for (param, m, is_enum) in fields {
|
||||
let Some(v) = p.get(*param) else { continue };
|
||||
let pr = sub.get_mut(m).ok_or_else(|| bad(c, format!("no {m}")))?;
|
||||
let nv = if *is_enum {
|
||||
let opts: &[&str] = if *m == "units" && group == "taper" { &["pixels", "percent"] } else { &["pixels", "cycles"] };
|
||||
let i = match v {
|
||||
Value::String(s) => {
|
||||
opts.iter().position(|o| o.eq_ignore_ascii_case(s)).ok_or_else(|| bad(c, format!("`{param}` is one of {opts:?}")))? as u32
|
||||
}
|
||||
Value::Number(n) => n.as_u64().unwrap_or(0).min(opts.len() as u64 - 1) as u32,
|
||||
_ => return Err(bad(c, format!("bad `{param}`"))),
|
||||
};
|
||||
KV::Enum(i)
|
||||
} else {
|
||||
let x = v.as_f64().ok_or_else(|| bad(c, format!("`{param}` must be a number")))?;
|
||||
let x = if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui { x.clamp(min, max) } else { x.max(0.0) };
|
||||
KV::Scalar(x)
|
||||
};
|
||||
pr.set_value_at(lt, nv.clone());
|
||||
out.insert(param.to_string(), nv.to_json());
|
||||
}
|
||||
Ok(json!({"stroke": uid, group: Value::Object(out)}))
|
||||
})
|
||||
}
|
||||
|
||||
fn taper(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set_group(
|
||||
s,
|
||||
p,
|
||||
"shape.stroke.taper",
|
||||
"taper",
|
||||
&[
|
||||
("units", "units", true),
|
||||
("startLength", "startLength", false),
|
||||
("endLength", "endLength", false),
|
||||
("startWidth", "startWidth", false),
|
||||
("endWidth", "endWidth", false),
|
||||
("startEase", "startEase", false),
|
||||
("endEase", "endEase", false),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn wave(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set_group(
|
||||
s,
|
||||
p,
|
||||
"shape.stroke.wave",
|
||||
"wave",
|
||||
&[("amount", "amount", false), ("units", "units", true), ("wavelength", "wavelength", false), ("cycles", "cycles", false), ("phase", "phase", false)],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add),
|
||||
cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove),
|
||||
cmd!(
|
||||
"shape.stroke.taper",
|
||||
"Stroke Taper",
|
||||
[],
|
||||
None,
|
||||
"{layer?, prop?: stroke uid, units?: pixels|percent, startLength?, endLength?, startWidth? (%), endWidth? (%), startEase? (%), endEase? (%)}",
|
||||
has_layers,
|
||||
taper
|
||||
),
|
||||
cmd!(
|
||||
"shape.stroke.wave",
|
||||
"Stroke Wave",
|
||||
[],
|
||||
None,
|
||||
"{layer?, prop?: stroke uid, amount? (%), units?: pixels|cycles, wavelength?, cycles?, phase? (°)}",
|
||||
has_layers,
|
||||
wave
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -17,7 +17,6 @@ macro_rules! stub {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
|
||||
// Layer ▸ New (3D / content-aware fill).
|
||||
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
|
||||
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
|
||||
@@ -26,13 +25,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
|
||||
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
|
||||
// Cameras / lights / materials / 3D views (3D milestone).
|
||||
stub!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], "{}"),
|
||||
// Keyframes / text / tracking.
|
||||
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"], "{}"),
|
||||
// Workspaces and panels that don't exist yet.
|
||||
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
|
||||
]
|
||||
|
||||
@@ -372,6 +372,60 @@ fn list_presets(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(presets().iter().map(|p| json!({"id": p.get("id"), "name": p.get("name"), "description": p.get("description")})).collect()))
|
||||
}
|
||||
|
||||
/// Animation ▸ Animate Text ▸ Variable Font Axes: the axes of the layer's font, or (with `axis`)
|
||||
/// a new animator — or a property on `animator` — that offsets that axis (user units, weighted
|
||||
/// by the selectors, added to the axis default).
|
||||
fn font_axes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.animatorFontAxes";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
|
||||
let Some(KV::Text(doc)) = layer.props.prop("text/sourceText").map(|pr| pr.value_at(layer.layer_time(s.time()))) else {
|
||||
return Err(bad(C, "not a text layer"));
|
||||
};
|
||||
let face = effectcraft_text::layout_doc(&doc).glyphs.first().map(|g| g.face).unwrap_or_else(|| effectcraft_text::resolve(&doc.font, &doc.style).face);
|
||||
let axes = effectcraft_text::variable::font_axes(face);
|
||||
let list = || json!(axes.iter().map(|a| json!({"tag": a.tag, "name": a.name, "min": a.min, "default": a.default, "max": a.max})).collect::<Vec<_>>());
|
||||
let Some(want) = str_p(p, "axis") else { return Ok(json!({"font": doc.font, "axes": list()})) };
|
||||
if axes.is_empty() {
|
||||
return Err(bad(C, format!("{} {} has no variation axes (not a variable font)", doc.font, doc.style)));
|
||||
}
|
||||
let axis = axes
|
||||
.iter()
|
||||
.find(|a| a.tag == want || a.name.eq_ignore_ascii_case(want))
|
||||
.ok_or_else(|| bad(C, format!("no axis `{want}`; the font has {}", list())))?
|
||||
.clone();
|
||||
let target = match p.get("animator") {
|
||||
Some(_) => Some(animator_uid(s, &layer, p, C)?),
|
||||
None => None,
|
||||
};
|
||||
let uid = s.edit("Add Variable Font Axis", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let prop = ids
|
||||
.prop(&format!("{}{}", effectcraft_render::text::AXIS_PREFIX, axis.tag.trim_end()), &axis.name, KV::Scalar(0.0))
|
||||
.with_ui(effectcraft_project::ParamUi::Number);
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let uid = match target {
|
||||
Some(a) => {
|
||||
let g = l.props.find_group_mut(a).and_then(|g| g.sub_mut("properties")).ok_or_else(|| bad(C, "no such animator"))?;
|
||||
g.children.push(prop.into());
|
||||
a
|
||||
}
|
||||
None => {
|
||||
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
|
||||
let name = format!("Animator {}", anims.children.len() + 1);
|
||||
let g = build::text_animator(&mut ids, &name, vec![prop]);
|
||||
let uid = g.uid;
|
||||
anims.children.push(g.into());
|
||||
uid
|
||||
}
|
||||
};
|
||||
proj.next_id = next;
|
||||
Ok(uid)
|
||||
})?;
|
||||
Ok(json!({"animator": uid, "axis": axis.tag}))
|
||||
}
|
||||
|
||||
fn list_kinds(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-").map(|(k, l)| json!({"property": k, "label": l})).collect()))
|
||||
}
|
||||
@@ -415,6 +469,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text_layer,
|
||||
apply_preset
|
||||
),
|
||||
cmd!(
|
||||
"text.animatorFontAxes",
|
||||
"Variable Font Axes",
|
||||
["Animation", "Animate Text"],
|
||||
None,
|
||||
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index}",
|
||||
has_text_layer,
|
||||
font_axes
|
||||
),
|
||||
query!("text.presets", "Text Animation Presets", "{}", list_presets),
|
||||
query!("text.animatorProperties", "Text Animator Properties", "{}", list_kinds),
|
||||
]
|
||||
|
||||
@@ -254,7 +254,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*t = [r.x, r.y];
|
||||
}
|
||||
}
|
||||
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
|
||||
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
|
||||
let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0]));
|
||||
let width = f_p(p, "strokeWidth").unwrap_or(2.0);
|
||||
let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0]));
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery
|
||||
//! sentinel). The engine only sees the [`ConfigStore`] trait; frontends choose where it lives:
|
||||
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app can back it
|
||||
//! with `localStorage`, tests and headless runs use [`MemoryConfig`] (or none at all).
|
||||
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app backs it
|
||||
//! with the browser's Origin Private File System (IndexedDB fallback), tests and headless runs use
|
||||
//! [`MemoryConfig`] (or none at all).
|
||||
//!
|
||||
//! Auto-saves are project files, not settings, but they live wherever the store says: the store's
|
||||
//! [`FileOps`] (the file system by default; the browser's storage on the web).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -12,11 +16,52 @@ pub trait ConfigStore: Send + Sync {
|
||||
fn read(&self, name: &str) -> Option<String>;
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
|
||||
fn remove(&self, name: &str) -> std::io::Result<()>;
|
||||
/// A directory on disk next to the configuration (default Auto-Save folder for untitled
|
||||
/// projects); `None` for stores that aren't on the file system.
|
||||
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
|
||||
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
/// Where auto-saves are listed, written and found (default: the file system).
|
||||
fn files(&self) -> &dyn FileOps {
|
||||
&StdFiles
|
||||
}
|
||||
}
|
||||
|
||||
/// The file operations auto-save and crash recovery need.
|
||||
pub trait FileOps: Send + Sync {
|
||||
/// Files directly in `dir`: (file name, modified time).
|
||||
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)>;
|
||||
/// Write `data` to `path` atomically, creating its folder.
|
||||
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()>;
|
||||
fn is_file(&self, path: &Path) -> bool;
|
||||
}
|
||||
|
||||
/// [`FileOps`] on the file system.
|
||||
pub struct StdFiles;
|
||||
|
||||
impl FileOps for StdFiles {
|
||||
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
rd.flatten()
|
||||
.filter_map(|e| {
|
||||
let m = e.metadata().ok().filter(|m| m.is_file())?.modified().ok()?;
|
||||
Some((e.file_name().to_string_lossy().to_string(), m))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(d)?;
|
||||
}
|
||||
atomic_write(path, data)
|
||||
}
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()> {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store (tests, headless sessions).
|
||||
|
||||
@@ -146,7 +146,15 @@ fn card(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, a: &str, b: &s
|
||||
&mut l,
|
||||
"ec.generate.grid",
|
||||
[w, h],
|
||||
&[("width", Value::Scalar(w)), ("height", Value::Scalar(h)), ("border", Value::Scalar(10.0)), ("opacity", Value::Scalar(35.0))],
|
||||
&[
|
||||
("sizeFrom", Value::Enum(2)),
|
||||
("width", Value::Scalar(w)),
|
||||
("height", Value::Scalar(h)),
|
||||
("border", Value::Scalar(10.0)),
|
||||
("opacity", Value::Scalar(35.0)),
|
||||
// Normal: the grid over the gradient.
|
||||
("blendingMode", Value::Enum(1)),
|
||||
],
|
||||
);
|
||||
l.switches.three_d = true;
|
||||
set(&mut l, "transform/position", Value::Vec3(pos));
|
||||
@@ -174,6 +182,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
|
||||
("border", Value::Scalar(3.0)),
|
||||
("color", Value::Color(hex("#6F7BB8"))),
|
||||
("opacity", Value::Scalar(45.0)),
|
||||
("blendingMode", Value::Enum(1)),
|
||||
],
|
||||
);
|
||||
floor.switches.three_d = true;
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod demo;
|
||||
pub mod links;
|
||||
pub mod mask_track;
|
||||
pub mod menus;
|
||||
pub mod offload;
|
||||
pub mod prefs;
|
||||
pub mod psd_import;
|
||||
pub mod render_queue;
|
||||
@@ -319,6 +320,11 @@ pub struct Session {
|
||||
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
|
||||
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
|
||||
pub script: Option<ScriptRunner>,
|
||||
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
|
||||
/// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32).
|
||||
pub offload: Option<Arc<dyn offload::Offload>>,
|
||||
/// Analyses running in the [`Session::offload`] worker.
|
||||
pub offloaded: Vec<offload::OffloadedJob>,
|
||||
}
|
||||
|
||||
/// A script to run (see [`Session::script`]).
|
||||
@@ -373,6 +379,8 @@ impl Default for Session {
|
||||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
script: None,
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -630,6 +638,7 @@ impl Session {
|
||||
self.roto_job = None;
|
||||
self.roto_pending.clear();
|
||||
p.fix_next_id();
|
||||
upgrade_effects(&mut p);
|
||||
self.project = Arc::new(p);
|
||||
self.history = History::default();
|
||||
self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() };
|
||||
@@ -643,6 +652,35 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring effect instances saved by earlier versions up to date with the effect registry
|
||||
/// (renamed / regrouped / added parameters, reordered popups; see
|
||||
/// [`effectcraft_effects::migrate`]).
|
||||
pub fn upgrade_effects(p: &mut Project) {
|
||||
let mut sizes = std::collections::HashMap::new();
|
||||
for (cid, c) in p.comps() {
|
||||
for l in &c.layers {
|
||||
let (w, h) = effectcraft_render::source_size(p, l);
|
||||
sizes.insert((*cid, l.id), if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] });
|
||||
}
|
||||
}
|
||||
let mut next = p.next_id;
|
||||
for (cid, it) in p.items.iter_mut() {
|
||||
let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };
|
||||
for l in &mut std::sync::Arc::make_mut(c).layers {
|
||||
let size = sizes.get(&(*cid, l.id)).copied().unwrap_or([100.0, 100.0]);
|
||||
let Some(fx) = l.props.sub_mut("effects") else { continue };
|
||||
for n in &mut fx.children {
|
||||
let effectcraft_project::Node::Group(g) = n else { continue };
|
||||
let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { continue };
|
||||
if let Some(spec) = effectcraft_effects::find(effect) {
|
||||
effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p.next_id = next;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rq_tests;
|
||||
#[cfg(test)]
|
||||
@@ -686,6 +724,8 @@ mod tests_stubs;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
#[cfg(test)]
|
||||
mod tests_tail;
|
||||
#[cfg(test)]
|
||||
mod tests_text;
|
||||
#[cfg(test)]
|
||||
mod tests_text_edit;
|
||||
|
||||
@@ -105,6 +105,7 @@ pub fn transform_path(p: &ShapePath, h: &trk::Homography) -> ShapePath {
|
||||
in_tangents: p.vertices.iter().zip(&p.in_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
|
||||
out_tangents: p.vertices.iter().zip(&p.out_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
|
||||
closed: p.closed,
|
||||
feather: p.feather.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +196,7 @@ fn run_work(w: MaskWork, shared: &MaskTrackShared) {
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
|
||||
s.time = w.times[k].seconds();
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
@@ -213,16 +215,19 @@ fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
|
||||
impl Session {
|
||||
/// Whether a mask track is running.
|
||||
pub fn is_mask_tracking(&self) -> bool {
|
||||
self.mask_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.mask_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::MaskTrack).is_some()
|
||||
}
|
||||
|
||||
/// Live progress of the running (or just finished) mask track.
|
||||
pub fn mask_track_progress(&self) -> Option<TrackProgress> {
|
||||
self.mask_job.as_ref().map(|j| j.progress())
|
||||
self.mask_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::MaskTrack).map(|j| j.progress.track()))
|
||||
}
|
||||
|
||||
/// Cancel the running mask track (frames tracked so far are kept).
|
||||
pub fn stop_mask_track(&mut self) -> bool {
|
||||
if self.cancel_offloaded(crate::offload::JobKind::MaskTrack) {
|
||||
return true;
|
||||
}
|
||||
match &self.mask_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
@@ -237,6 +242,10 @@ impl Session {
|
||||
return Err("a track analysis is already running".into());
|
||||
}
|
||||
self.poll_mask_track();
|
||||
if !wait && self.offloads() {
|
||||
let MaskWork { comp, layer, path, method, times, .. } = work;
|
||||
return self.offload_analysis(crate::offload::WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times });
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
self.history.undo.push(("Track Mask".into(), self.project.clone()));
|
||||
self.history.redo.clear();
|
||||
|
||||
@@ -688,6 +688,8 @@ Layer
|
||||
Matte with Layer Above | layer.trackMatte {"op":"above"}
|
||||
Matte with Layer Below | layer.trackMatte {"op":"below"}
|
||||
Layer Styles
|
||||
Layer Style Options... | layer.style.options
|
||||
---
|
||||
Convert to Editable Styles | layer.style.convertToEditable
|
||||
Show All | layer.style.showAll
|
||||
Remove All | layer.style.removeAll
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
//! Background jobs where the platform has no threads (the browser).
|
||||
//!
|
||||
//! On the desktop, renders and analyses run on threads that share the session's project and
|
||||
//! footage. A wasm build without atomics has one thread per instance, so the web app runs a
|
||||
//! second engine instance in a Web Worker instead and talks to it with messages: an
|
||||
//! [`Offload`] (set as [`Session::offload`]) ships a [`WorkerRequest`] — the serialized project,
|
||||
//! the footage files it reads and the job — and feeds the worker's [`WorkerReply`]s back into an
|
||||
//! [`Inbox`] that the session drains every frame ([`Session::poll_render`],
|
||||
//! [`Session::poll_offload`]). The worker side is [`run_request`]: a plain session fed the
|
||||
//! project runs the job blocking and reports through a callback.
|
||||
//!
|
||||
//! | Job | Worker runs | Reply applied on the UI thread |
|
||||
//! |---|---|---|
|
||||
//! | Render Queue | the export of every queued item | item status, progress; files arrive through the host |
|
||||
//! | Warp Stabilizer analysis | [`Session::start_warp`] | the effect's property group (one undo step) |
|
||||
//! | Track Motion / Stabilize Motion | the tracker analysis | the tracker group |
|
||||
//! | Mask tracking | the mask track | the mask group |
|
||||
//! | Roto Brush Freeze | the freeze | the effect group |
|
||||
//! | 3D Camera Tracker analysis | [`Session::start_camera`] | the effect's property group |
|
||||
//!
|
||||
//! Roto Brush propagation fills the session's segmentation cache rather than the project, so it
|
||||
//! keeps running on the UI thread.
|
||||
//!
|
||||
//! Cancelling terminates the worker: what an analysis tracked so far is dropped and the Render
|
||||
//! Queue item being rendered becomes "User Stopped".
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
use effectcraft_project::render_queue::{RenderQueueItem, RenderStatus};
|
||||
use effectcraft_project::tracking::TrackerSettings;
|
||||
use effectcraft_project::{ItemId, ItemKind, LayerId, Project, PropGroup, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::mask_track::MaskMethod;
|
||||
use crate::render_queue::{ItemUpdate, JobShared, JobState};
|
||||
use crate::tracking::PointValues;
|
||||
use crate::{Event, Session};
|
||||
|
||||
/// One job for a worker.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkerRequest {
|
||||
pub id: u64,
|
||||
/// The project ([`Project::to_json`]).
|
||||
pub project: String,
|
||||
/// Footage files the job reads; the host ships their bytes before the request.
|
||||
pub files: Vec<String>,
|
||||
pub job: WorkerJob,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum WorkerJob {
|
||||
/// Render Queue: per item, one (item with that output module, resolved output path) per
|
||||
/// output module.
|
||||
Render {
|
||||
items: Vec<Vec<(RenderQueueItem, String)>>,
|
||||
},
|
||||
Warp {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
Camera {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
RotoFreeze {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
Track {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
tracker: Uid,
|
||||
direction: crate::tracking::Direction,
|
||||
settings: TrackerSettings,
|
||||
points: Vec<PointValues>,
|
||||
times: Vec<Tick>,
|
||||
},
|
||||
MaskTrack {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
mask: Uid,
|
||||
direction: crate::tracking::Direction,
|
||||
path: ShapePath,
|
||||
method: MaskMethod,
|
||||
times: Vec<Tick>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkerJob {
|
||||
pub fn kind(&self) -> JobKind {
|
||||
match self {
|
||||
WorkerJob::Render { .. } => JobKind::Render,
|
||||
WorkerJob::Warp { .. } => JobKind::Warp,
|
||||
WorkerJob::Camera { .. } => JobKind::Camera,
|
||||
WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze,
|
||||
WorkerJob::Track { .. } => JobKind::Track,
|
||||
WorkerJob::MaskTrack { .. } => JobKind::MaskTrack,
|
||||
}
|
||||
}
|
||||
|
||||
/// (comp, layer, property group uid) an analysis writes to.
|
||||
pub fn target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
match *self {
|
||||
WorkerJob::Render { .. } => None,
|
||||
WorkerJob::Warp { comp, layer, effect } | WorkerJob::Camera { comp, layer, effect } | WorkerJob::RotoFreeze { comp, layer, effect } => {
|
||||
Some((comp, layer, effect))
|
||||
}
|
||||
WorkerJob::Track { comp, layer, tracker, .. } => Some((comp, layer, tracker)),
|
||||
WorkerJob::MaskTrack { comp, layer, mask, .. } => Some((comp, layer, mask)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum JobKind {
|
||||
Render,
|
||||
Warp,
|
||||
Camera,
|
||||
RotoFreeze,
|
||||
Track,
|
||||
MaskTrack,
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
/// The undo step an analysis result becomes.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
JobKind::Render => "Render",
|
||||
JobKind::Warp => "Warp Stabilizer Analysis",
|
||||
JobKind::Camera => "3D Camera Tracker Analysis",
|
||||
JobKind::RotoFreeze => "Freeze",
|
||||
JobKind::Track => "Analyze Track",
|
||||
JobKind::MaskTrack => "Track Mask",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a worker reports.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum WorkerReply {
|
||||
/// Render Queue progress.
|
||||
Render {
|
||||
state: JobState,
|
||||
},
|
||||
/// A Render Queue item started or finished.
|
||||
Item {
|
||||
update: ItemUpdate,
|
||||
},
|
||||
/// Analysis progress (frames).
|
||||
Progress {
|
||||
done: u64,
|
||||
total: u64,
|
||||
},
|
||||
/// An analysis finished: its property group after the job, and the job's message.
|
||||
Group {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
group: Box<PropGroup>,
|
||||
message: String,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
/// The job ended (always the last reply).
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Replies waiting for the UI thread.
|
||||
#[derive(Default)]
|
||||
pub struct Inbox(Mutex<Vec<WorkerReply>>);
|
||||
|
||||
impl Inbox {
|
||||
pub fn push(&self, r: WorkerReply) {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).push(r);
|
||||
}
|
||||
pub fn take(&self) -> Vec<WorkerReply> {
|
||||
std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs jobs off the UI thread (the web app: a pool of Web Workers).
|
||||
pub trait Offload: Send + Sync {
|
||||
/// Start `req`; replies go to `inbox` (ending with [`WorkerReply::Done`]).
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String>;
|
||||
/// Stop job `id` now (the worker is terminated; no more replies arrive).
|
||||
fn cancel(&self, id: u64);
|
||||
}
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
pub fn next_id() -> u64 {
|
||||
NEXT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Footage files a project reads (paths, sequence frames).
|
||||
pub fn footage_files(p: &Project) -> Vec<String> {
|
||||
let mut v: Vec<String> = p
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|i| match &i.kind {
|
||||
ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- progress hook (worker side)
|
||||
|
||||
thread_local! {
|
||||
static HOOK: RefCell<Option<Box<dyn FnMut(u64, u64)>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Analyses call this as frames complete; a worker forwards it (no-op elsewhere).
|
||||
pub fn report(done: u64, total: u64) {
|
||||
HOOK.with(|h| {
|
||||
if let Ok(mut h) = h.try_borrow_mut()
|
||||
&& let Some(f) = h.as_mut()
|
||||
{
|
||||
f(done, total);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn with_hook<R>(hook: Box<dyn FnMut(u64, u64)>, f: impl FnOnce() -> R) -> R {
|
||||
HOOK.with(|h| *h.borrow_mut() = Some(hook));
|
||||
let r = f();
|
||||
HOOK.with(|h| *h.borrow_mut() = None);
|
||||
r
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- worker side
|
||||
|
||||
/// Where a worker sends its replies.
|
||||
pub type Post = Rc<dyn Fn(WorkerReply)>;
|
||||
|
||||
/// Run `req` on `s` (a session with footage, expressions and an exporter, but no project yet),
|
||||
/// blocking, reporting through `post`. Always ends with [`WorkerReply::Done`].
|
||||
pub fn run_request(s: &mut Session, req: WorkerRequest, post: &Post) {
|
||||
match Project::from_json(&req.project) {
|
||||
Ok(p) => {
|
||||
s.replace_project(p, None);
|
||||
if let Err(e) = run_job(s, req.job, post.clone()) {
|
||||
post(WorkerReply::Failed { error: e });
|
||||
}
|
||||
}
|
||||
Err(e) => post(WorkerReply::Failed { error: format!("project: {e}") }),
|
||||
}
|
||||
post(WorkerReply::Done);
|
||||
}
|
||||
|
||||
fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
|
||||
let kind = job.kind();
|
||||
let target = job.target();
|
||||
match job {
|
||||
WorkerJob::Render { items } => {
|
||||
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
|
||||
let shared = JobShared::default();
|
||||
// Item updates and progress go out as they happen (progress at most every 100 ms).
|
||||
let last = Cell::new(None::<web_time::Instant>);
|
||||
let notify = |sh: &JobShared, force: bool| {
|
||||
let ups = std::mem::take(&mut *crate::render_queue::lock(&sh.updates));
|
||||
for u in ups {
|
||||
post(WorkerReply::Item { update: u });
|
||||
}
|
||||
if force || last.get().is_none_or(|t| t.elapsed().as_millis() >= 100) {
|
||||
last.set(Some(web_time::Instant::now()));
|
||||
post(WorkerReply::Render { state: sh.snapshot() });
|
||||
}
|
||||
};
|
||||
crate::render_queue::run_items(s, exporter, items, &shared, &mut |sh| notify(sh, false));
|
||||
notify(&shared, true);
|
||||
Ok(())
|
||||
}
|
||||
WorkerJob::Warp { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_warp(comp, layer, effect, true).map(|_| ())),
|
||||
WorkerJob::Camera { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_camera(comp, layer, effect, true).map(|_| ())),
|
||||
WorkerJob::RotoFreeze { comp, layer, effect } => analysis(s, kind, target, post, |s| {
|
||||
s.start_roto(comp, layer, effect, crate::roto::RotoTask::Freeze, crate::roto::Direction::Both, true).map(|_| ())
|
||||
}),
|
||||
WorkerJob::Track { comp, layer, tracker, direction, settings, points, times } => analysis(s, kind, target, post, |s| {
|
||||
let work = crate::tracking::Work {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
cache: s.layer_cache.clone(),
|
||||
comp,
|
||||
layer,
|
||||
settings,
|
||||
points,
|
||||
times,
|
||||
};
|
||||
s.start_track(work, tracker, direction, true)
|
||||
}),
|
||||
WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times } => analysis(s, kind, target, post, |s| {
|
||||
let work = crate::mask_track::MaskWork {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
cache: s.layer_cache.clone(),
|
||||
comp,
|
||||
layer,
|
||||
path,
|
||||
method,
|
||||
times,
|
||||
};
|
||||
s.start_mask_track(work, mask, direction, true)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn analysis(
|
||||
s: &mut Session,
|
||||
kind: JobKind,
|
||||
target: Option<(ItemId, LayerId, Uid)>,
|
||||
post: Post,
|
||||
run: impl FnOnce(&mut Session) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let (comp, layer, uid) = target.ok_or("no target")?;
|
||||
let rev = s.revision;
|
||||
// Progress at most every 100 ms.
|
||||
let p = post.clone();
|
||||
let mut last = None::<web_time::Instant>;
|
||||
let hook = Box::new(move |done: u64, total: u64| {
|
||||
if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 100) {
|
||||
last = Some(web_time::Instant::now());
|
||||
p(WorkerReply::Progress { done, total });
|
||||
}
|
||||
});
|
||||
with_hook(hook, || run(s))?;
|
||||
let message = s
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
Event::Toast { message, .. } => Some(message.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let failed = s.events.iter().any(|e| matches!(e, Event::Toast { error: true, .. }));
|
||||
if failed {
|
||||
return Err(message);
|
||||
}
|
||||
if s.revision == rev && kind != JobKind::Track && kind != JobKind::MaskTrack {
|
||||
// Nothing written (e.g. the analysis was empty).
|
||||
return Err(if message.is_empty() { "nothing was analysed".into() } else { message });
|
||||
}
|
||||
let group = s.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().ok_or("the analysed property is gone")?;
|
||||
post(WorkerReply::Group { comp, layer, group: Box::new(group), message });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- UI side
|
||||
|
||||
/// Analysis progress of an offloaded job.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OffloadProgress {
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
pub elapsed: f64,
|
||||
pub finished: bool,
|
||||
pub cancelled: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Frames per second of `done` frames in `elapsed` seconds.
|
||||
pub(crate) fn rate(done: u64, elapsed: f64) -> f64 {
|
||||
if elapsed > 0.0 { done as f64 / elapsed } else { 0.0 }
|
||||
}
|
||||
|
||||
impl OffloadProgress {
|
||||
pub(crate) fn track(&self) -> crate::tracking::TrackProgress {
|
||||
crate::tracking::TrackProgress { done: self.done, total: self.total, elapsed: self.elapsed, fps: rate(self.done, self.elapsed), ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// An analysis running in a worker.
|
||||
pub struct OffloadedJob {
|
||||
pub id: u64,
|
||||
pub kind: JobKind,
|
||||
pub comp: ItemId,
|
||||
pub layer: LayerId,
|
||||
pub uid: Uid,
|
||||
pub inbox: Arc<Inbox>,
|
||||
pub progress: OffloadProgress,
|
||||
started: web_time::Instant,
|
||||
}
|
||||
|
||||
/// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]).
|
||||
pub struct RemoteRender {
|
||||
pub id: u64,
|
||||
pub inbox: Arc<Inbox>,
|
||||
/// The queue items in the job.
|
||||
pub ids: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Whether jobs started without `wait` go to the [`Offload`].
|
||||
pub fn offloads(&self) -> bool {
|
||||
self.offload.is_some()
|
||||
}
|
||||
|
||||
/// The offloaded analysis of `kind`, if one runs.
|
||||
pub fn offloaded(&self, kind: JobKind) -> Option<&OffloadedJob> {
|
||||
self.offloaded.iter().find(|j| j.kind == kind && !j.progress.finished)
|
||||
}
|
||||
|
||||
/// Send an analysis to the worker.
|
||||
pub(crate) fn offload_analysis(&mut self, job: WorkerJob) -> Result<(), String> {
|
||||
let off = self.offload.clone().ok_or("no background worker")?;
|
||||
let kind = job.kind();
|
||||
let (comp, layer, uid) = job.target().ok_or("not an analysis")?;
|
||||
if self.offloaded(kind).is_some() {
|
||||
return Err(format!("a {} job is already running", kind.label()));
|
||||
}
|
||||
let id = next_id();
|
||||
let inbox = Arc::new(Inbox::default());
|
||||
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
|
||||
off.start(req, inbox.clone())?;
|
||||
self.offloaded.push(OffloadedJob { id, kind, comp, layer, uid, inbox, progress: OffloadProgress::default(), started: web_time::Instant::now() });
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel the offloaded analysis of `kind` (nothing is written).
|
||||
pub fn cancel_offloaded(&mut self, kind: JobKind) -> bool {
|
||||
let off = self.offload.clone();
|
||||
let Some(j) = self.offloaded.iter_mut().find(|j| j.kind == kind && !j.progress.finished) else { return false };
|
||||
if let Some(off) = off {
|
||||
off.cancel(j.id);
|
||||
}
|
||||
j.progress.cancelled = true;
|
||||
j.progress.finished = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply worker replies to offloaded analyses (results become one undo step each) and drop
|
||||
/// finished jobs. Frontends call this every frame while [`Session::offloaded`] is not empty.
|
||||
pub fn poll_offload(&mut self) -> bool {
|
||||
if self.offloaded.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
let mut results = vec![];
|
||||
for j in &mut self.offloaded {
|
||||
if j.progress.cancelled {
|
||||
continue;
|
||||
}
|
||||
for r in j.inbox.take() {
|
||||
changed = true;
|
||||
match r {
|
||||
WorkerReply::Progress { done, total } => {
|
||||
j.progress.done = done;
|
||||
j.progress.total = total;
|
||||
}
|
||||
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
|
||||
WorkerReply::Failed { error } => j.progress.error = Some(error),
|
||||
WorkerReply::Done => j.progress.finished = true,
|
||||
WorkerReply::Render { .. } | WorkerReply::Item { .. } => {}
|
||||
}
|
||||
}
|
||||
j.progress.elapsed = j.started.elapsed().as_secs_f64();
|
||||
}
|
||||
for (kind, comp, layer, group, message) in results {
|
||||
let uid = group.uid;
|
||||
let r = self.edit(kind.label(), None, |p, _| {
|
||||
let g = p
|
||||
.comp_mut(comp)
|
||||
.and_then(|c| c.layer_mut(layer))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?;
|
||||
*g = *group;
|
||||
Ok(())
|
||||
});
|
||||
match r {
|
||||
Ok(()) => self.events.push(Event::Toast { message, error: false }),
|
||||
Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }),
|
||||
}
|
||||
}
|
||||
let mut done = vec![];
|
||||
self.offloaded.retain(|j| {
|
||||
if j.progress.finished {
|
||||
done.push((j.kind, j.progress.clone()));
|
||||
}
|
||||
!j.progress.finished
|
||||
});
|
||||
for (kind, p) in done {
|
||||
changed = true;
|
||||
if let Some(e) = p.error {
|
||||
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
|
||||
} else if p.cancelled {
|
||||
self.events.push(Event::Toast { message: format!("{} stopped", kind.label()), error: false });
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Feed a remote render's replies into its [`JobShared`]; a cancelled one is wrapped up here
|
||||
/// (the item being rendered and the ones after it become "User Stopped").
|
||||
pub(crate) fn drain_remote_render(&mut self) {
|
||||
let Some(job) = &self.render_job else { return };
|
||||
let Some(remote) = &job.remote else { return };
|
||||
let shared = job.shared.clone();
|
||||
for r in remote.inbox.take() {
|
||||
match r {
|
||||
WorkerReply::Render { state } => {
|
||||
let fin = crate::render_queue::lock(&shared.state).finished;
|
||||
*crate::render_queue::lock(&shared.state) = JobState { finished: fin, ..state };
|
||||
}
|
||||
WorkerReply::Item { update } => crate::render_queue::lock(&shared.updates).push(update),
|
||||
WorkerReply::Failed { error } => {
|
||||
log::warn!("render worker: {error}");
|
||||
let cur = crate::render_queue::lock(&shared.state).current;
|
||||
if let Some(id) = cur {
|
||||
crate::render_queue::lock(&shared.updates).push(ItemUpdate::Finished {
|
||||
id,
|
||||
status: RenderStatus::Failed(error),
|
||||
seconds: 0.0,
|
||||
output: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
WorkerReply::Done => crate::render_queue::lock(&shared.state).finished = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> String {
|
||||
let a = serde_json::to_string(v).unwrap();
|
||||
let back: T = serde_json::from_str(&a).unwrap();
|
||||
let b = serde_json::to_string(&back).unwrap();
|
||||
assert_eq!(a, b);
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_round_trips() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
s.execute("renderQueue.add", serde_json::json!({})).unwrap();
|
||||
let item = s.project.render_queue[0].clone();
|
||||
let reqs = [
|
||||
WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] },
|
||||
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::Track {
|
||||
comp,
|
||||
layer: LayerId(1),
|
||||
tracker: 4,
|
||||
direction: crate::tracking::Direction::Backward,
|
||||
settings: TrackerSettings::default(),
|
||||
points: vec![PointValues {
|
||||
uid: 5,
|
||||
spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] },
|
||||
attach_offset: [0.25, -1.0],
|
||||
}],
|
||||
times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)],
|
||||
},
|
||||
WorkerJob::MaskTrack {
|
||||
comp,
|
||||
layer: LayerId(2),
|
||||
mask: 7,
|
||||
direction: crate::tracking::Direction::Forward,
|
||||
path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0),
|
||||
method: MaskMethod::Perspective,
|
||||
times: vec![Tick::from_seconds_f64(1.0)],
|
||||
},
|
||||
];
|
||||
for job in reqs {
|
||||
let kind = job.kind();
|
||||
let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job };
|
||||
let t = round(&req);
|
||||
assert!(t.contains("\"kind\":"), "{kind:?}");
|
||||
let back: WorkerRequest = serde_json::from_str(&t).unwrap();
|
||||
assert_eq!(back.job.kind(), kind);
|
||||
assert_eq!(Project::from_json(&back.project).unwrap(), *s.project);
|
||||
}
|
||||
let g = s.active_comp().unwrap().layers[0].props.clone();
|
||||
for r in [
|
||||
WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } },
|
||||
WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } },
|
||||
WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } },
|
||||
WorkerReply::Progress { done: 1, total: 2 },
|
||||
WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() },
|
||||
WorkerReply::Failed { error: "boom".into() },
|
||||
WorkerReply::Done,
|
||||
] {
|
||||
let t = round(&r);
|
||||
assert!(t.starts_with("{\"type\":"), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An [`Offload`] that runs the job right away in a second session, through JSON (what a
|
||||
/// Web Worker does, minus the threads).
|
||||
struct Inline;
|
||||
impl Offload for Inline {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() };
|
||||
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
|
||||
run_request(&mut w, req, &post);
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, _: u64) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_runs_through_the_offload() {
|
||||
let mut s = Session {
|
||||
exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })),
|
||||
offload: Some(Arc::new(Inline)),
|
||||
..Default::default()
|
||||
};
|
||||
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
|
||||
s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap();
|
||||
let ids = s.start_render(false).unwrap();
|
||||
// (the inline offload has replied already; replies are applied when polled)
|
||||
s.poll_render();
|
||||
assert!(s.render_job.is_none());
|
||||
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
|
||||
assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status);
|
||||
assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif")));
|
||||
// `wait` renders inline.
|
||||
s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap();
|
||||
s.start_render(true).unwrap();
|
||||
assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none());
|
||||
}
|
||||
|
||||
/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests).
|
||||
struct InlineWith(Arc<dyn effectcraft_render::FootageSource>);
|
||||
impl Offload for InlineWith {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
let mut w = Session { footage: self.0.clone(), ..Default::default() };
|
||||
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
|
||||
run_request(&mut w, req, &post);
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, _: u64) {}
|
||||
}
|
||||
|
||||
/// Never replies (a job still running); records cancellations.
|
||||
#[derive(Default)]
|
||||
struct Hanging(Mutex<Vec<u64>>);
|
||||
impl Offload for Hanging {
|
||||
fn start(&self, _: WorkerRequest, _: Arc<Inbox>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, id: u64) {
|
||||
self.0.lock().unwrap().push(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn pose(f: u32) -> ([f64; 2], f64, f64) {
|
||||
([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_analysis_runs_through_the_offload_as_one_undo_step() {
|
||||
let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
|
||||
s.offload = Some(Arc::new(InlineWith(s.footage.clone())));
|
||||
s.execute("track.motion", serde_json::json!({})).unwrap();
|
||||
s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap();
|
||||
assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread");
|
||||
assert!(s.track_progress().is_some());
|
||||
assert!(s.poll_offload());
|
||||
assert!(!s.is_tracking() && s.offloaded.is_empty());
|
||||
let l = s.active_comp().unwrap().layer(clip).unwrap();
|
||||
let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone();
|
||||
let fc = tp.get("featureCenter").unwrap();
|
||||
assert_eq!(fc.keys.len(), 25);
|
||||
let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2();
|
||||
assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}");
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track");
|
||||
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked"))));
|
||||
assert!(s.undo());
|
||||
let l = s.active_comp().unwrap().layer(clip).unwrap();
|
||||
assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
|
||||
// `wait` runs inline.
|
||||
s.redo();
|
||||
s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok();
|
||||
assert!(s.offloaded.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_offloaded_jobs() {
|
||||
let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
|
||||
let off = Arc::new(Hanging::default());
|
||||
s.offload = Some(off.clone());
|
||||
s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() }));
|
||||
// Analysis: stopping drops the job, nothing is written.
|
||||
s.execute("track.motion", serde_json::json!({})).unwrap();
|
||||
let rev = s.revision;
|
||||
s.execute("track.analyze", serde_json::json!({})).unwrap();
|
||||
assert!(s.is_tracking());
|
||||
assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time");
|
||||
assert!(s.stop_track());
|
||||
s.poll_offload();
|
||||
assert!(!s.is_tracking());
|
||||
assert_eq!(off.0.lock().unwrap().len(), 1);
|
||||
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped"))));
|
||||
assert!(s.revision > rev);
|
||||
// Render: the queued items become User Stopped.
|
||||
s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap();
|
||||
let ids = s.start_render(false).unwrap();
|
||||
assert!(s.is_rendering());
|
||||
assert!(s.stop_render());
|
||||
s.poll_render();
|
||||
assert!(!s.is_rendering());
|
||||
assert_eq!(off.0.lock().unwrap().len(), 2);
|
||||
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
|
||||
assert_eq!(it.status, RenderStatus::UserStopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_hook_is_scoped() {
|
||||
report(1, 2); // no hook: nothing happens
|
||||
let seen = Rc::new(RefCell::new(vec![]));
|
||||
let s2 = seen.clone();
|
||||
with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), || {
|
||||
report(1, 3);
|
||||
report(2, 3);
|
||||
});
|
||||
report(3, 3);
|
||||
assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Optio
|
||||
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 };
|
||||
let mut path = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: sp.closed, feather: Vec::new() };
|
||||
for kn in &sp.knots {
|
||||
let v = [kn[1][0] - origin[0], kn[1][1] - origin[1]];
|
||||
path.vertices.push(v);
|
||||
@@ -352,7 +352,8 @@ fn add_effect(proj: &mut Project, l: &mut Layer, effect: &str, comp: &Comp, para
|
||||
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) {
|
||||
// Paths reach parameters in twirl-down groups (`master/exposure`).
|
||||
if let Some(p) = g.prop_mut(k) {
|
||||
p.value = v.clone();
|
||||
}
|
||||
}
|
||||
@@ -364,9 +365,13 @@ fn add_effect(proj: &mut Project, l: &mut Layer, effect: &str, comp: &Comp, para
|
||||
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::BrightnessContrast { brightness, contrast, legacy } => add_effect(
|
||||
proj,
|
||||
l,
|
||||
"Brightness & Contrast",
|
||||
comp,
|
||||
&[("brightness", Value::Scalar(*brightness)), ("contrast", Value::Scalar(*contrast)), ("useLegacy", Value::Bool(*legacy))],
|
||||
),
|
||||
Adjustment::HueSaturation { hue, saturation, lightness, colorize } => {
|
||||
let mut p = vec![("hue", Value::Scalar(*hue)), ("saturation", Value::Scalar(*saturation)), ("lightness", Value::Scalar(*lightness))];
|
||||
if *colorize {
|
||||
@@ -397,7 +402,7 @@ fn add_adjustment_effect(proj: &mut Project, l: &mut Layer, adj: &Adjustment, co
|
||||
l,
|
||||
"Exposure",
|
||||
comp,
|
||||
&[("exposure", Value::Scalar(*exposure)), ("offset", Value::Scalar(*offset)), ("gamma", Value::Scalar(*gamma))],
|
||||
&[("master/exposure", Value::Scalar(*exposure)), ("master/offset", Value::Scalar(*offset)), ("master/gamma", Value::Scalar(*gamma))],
|
||||
),
|
||||
Adjustment::Other(k) => warnings.push(format!("{name}: adjustment `{k}` is not converted (layer kept as an empty adjustment layer)")),
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
|
||||
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
|
||||
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_render::{ExprHost, FootageSource};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Event, Session};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub trait Exporter: Send + Sync {
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
/// Live progress of a render job (serde for agents / the control channel).
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct JobState {
|
||||
/// Item being rendered.
|
||||
pub current: Option<u64>,
|
||||
@@ -73,7 +73,8 @@ pub struct JobState {
|
||||
}
|
||||
|
||||
/// Per-item status changes from the job thread, applied to the project by `poll_render`.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "camelCase")]
|
||||
pub enum ItemUpdate {
|
||||
Started { id: u64, unix: u64 },
|
||||
Finished { id: u64, status: RenderStatus, seconds: f64, output: Option<String> },
|
||||
@@ -86,7 +87,7 @@ pub struct JobShared {
|
||||
pub updates: Mutex<Vec<ItemUpdate>>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
@@ -100,6 +101,8 @@ impl JobShared {
|
||||
pub struct RenderJob {
|
||||
pub shared: Arc<JobShared>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
/// Rendering in a worker ([`crate::offload`]).
|
||||
pub remote: Option<crate::offload::RemoteRender>,
|
||||
}
|
||||
|
||||
impl RenderJob {
|
||||
@@ -132,7 +135,19 @@ fn unix_now() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn run_work(w: Work, shared: &JobShared) {
|
||||
/// Render `items` with the session's footage, expressions and GPU (a worker's render).
|
||||
pub(crate) fn run_items(
|
||||
s: &Session,
|
||||
exporter: Arc<dyn Exporter>,
|
||||
items: Vec<Vec<(RenderQueueItem, String)>>,
|
||||
shared: &JobShared,
|
||||
notify: &mut dyn FnMut(&JobShared),
|
||||
) {
|
||||
let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items };
|
||||
run_work(work, shared, notify);
|
||||
}
|
||||
|
||||
fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
|
||||
let t0 = web_time::Instant::now();
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
@@ -154,18 +169,22 @@ fn run_work(w: Work, shared: &JobShared) {
|
||||
s.items_done = k;
|
||||
s.remaining = None;
|
||||
}
|
||||
notify(shared);
|
||||
// Every output module encodes the same frames (Composition ▸ Add Output Module); the
|
||||
// first module's file is the item's output.
|
||||
let mut r: Result<ExportResult, String> = Err("no output module".into());
|
||||
for (mi, (mitem, path)) in modules.iter().enumerate() {
|
||||
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
|
||||
let rr = w.exporter.export(&job, &mut |done, total| {
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = done;
|
||||
s.total = total;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.item_elapsed = t1.elapsed().as_secs_f64();
|
||||
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = done;
|
||||
s.total = total;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.item_elapsed = t1.elapsed().as_secs_f64();
|
||||
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
|
||||
}
|
||||
notify(shared);
|
||||
!shared.cancel.load(Ordering::Relaxed)
|
||||
});
|
||||
match rr {
|
||||
@@ -187,6 +206,7 @@ fn run_work(w: Work, shared: &JobShared) {
|
||||
}
|
||||
};
|
||||
lock(&shared.updates).push(up);
|
||||
notify(shared);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.items_done = w.items.len();
|
||||
@@ -245,10 +265,12 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
|
||||
/// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered.
|
||||
/// wasm32 has no threads: the render always runs to completion before this returns.
|
||||
/// renders, or the [`Session::offload`] worker; call [`Session::poll_render`] regularly).
|
||||
/// Returns the ids being rendered. Without threads or an offload (wasm32) the render always
|
||||
/// runs to completion before this returns.
|
||||
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let offload = self.offload.clone().filter(|_| !wait);
|
||||
let wait = wait || (cfg!(target_arch = "wasm32") && offload.is_none());
|
||||
if self.is_rendering() {
|
||||
return Err("a render is already in progress".into());
|
||||
}
|
||||
@@ -291,15 +313,31 @@ impl Session {
|
||||
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
|
||||
}
|
||||
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
|
||||
if let Some(off) = offload {
|
||||
let id = crate::offload::next_id();
|
||||
let inbox = Arc::new(crate::offload::Inbox::default());
|
||||
let req = crate::offload::WorkerRequest {
|
||||
id,
|
||||
project: self.project.to_json(),
|
||||
files: crate::offload::footage_files(&self.project),
|
||||
job: crate::offload::WorkerJob::Render { items },
|
||||
};
|
||||
off.start(req, inbox.clone())?;
|
||||
let shared = Arc::new(JobShared::default());
|
||||
lock(&shared.state).items_total = ids.len();
|
||||
self.render_job = Some(RenderJob { shared, thread: None, remote: Some(crate::offload::RemoteRender { id, inbox, ids: ids.clone() }) });
|
||||
self.poll_render();
|
||||
return Ok(ids);
|
||||
}
|
||||
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
|
||||
let shared = Arc::new(JobShared::default());
|
||||
if wait {
|
||||
run_work(work, &shared);
|
||||
self.render_job = Some(RenderJob { shared, thread: None });
|
||||
run_work(work, &shared, &mut |_| {});
|
||||
self.render_job = Some(RenderJob { shared, thread: None, remote: None });
|
||||
} else {
|
||||
let sh = shared.clone();
|
||||
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?;
|
||||
self.render_job = Some(RenderJob { shared, thread: Some(thread) });
|
||||
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh, &mut |_| {})).map_err(|e| e.to_string())?;
|
||||
self.render_job = Some(RenderJob { shared, thread: Some(thread), remote: None });
|
||||
}
|
||||
self.poll_render();
|
||||
Ok(ids)
|
||||
@@ -310,6 +348,25 @@ impl Session {
|
||||
match &self.render_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.cancel();
|
||||
if let Some(r) = &j.remote {
|
||||
// A worker can't be interrupted mid-frame: terminate it and stop what's left.
|
||||
if let Some(off) = &self.offload {
|
||||
off.cancel(r.id);
|
||||
}
|
||||
self.drain_remote_render();
|
||||
let j = self.render_job.as_ref().expect("job");
|
||||
let r = j.remote.as_ref().expect("remote");
|
||||
let finished: Vec<u64> =
|
||||
lock(&j.shared.updates).iter().filter_map(|u| if let ItemUpdate::Finished { id, .. } = u { Some(*id) } else { None }).collect();
|
||||
for id in &r.ids {
|
||||
let done = finished.contains(id)
|
||||
|| self.project.render_queue.iter().any(|i| i.id == *id && matches!(i.status, RenderStatus::Done | RenderStatus::Failed(_)));
|
||||
if !done {
|
||||
lock(&j.shared.updates).push(ItemUpdate::Finished { id: *id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
|
||||
}
|
||||
}
|
||||
lock(&j.shared.state).finished = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
@@ -319,6 +376,7 @@ impl Session {
|
||||
/// Apply job updates to the queue items; drop the job once it finished. Returns whether
|
||||
/// anything changed. Frontends call this every frame while rendering.
|
||||
pub fn poll_render(&mut self) -> bool {
|
||||
self.drain_remote_render();
|
||||
let Some(job) = &self.render_job else { return false };
|
||||
let updates = std::mem::take(&mut *lock(&job.shared.updates));
|
||||
let finished = job.is_finished();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
|
||||
//! mattes made from other frames.
|
||||
|
||||
use crate::offload::JobKind;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -62,7 +63,7 @@ impl Default for RotoOptions {
|
||||
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
|
||||
|
||||
/// What a job does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RotoTask {
|
||||
Propagate,
|
||||
@@ -378,6 +379,7 @@ fn run_work(w: Work, shared: &RotoShared) {
|
||||
s.done = k as u64 + 1;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
if w.task == RotoTask::Freeze {
|
||||
*lock(&shared.frozen) = Some(frozen);
|
||||
@@ -402,19 +404,37 @@ pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction)
|
||||
|
||||
impl Session {
|
||||
pub fn is_roto_running(&self) -> bool {
|
||||
self.roto_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::RotoFreeze).is_some()
|
||||
}
|
||||
|
||||
pub fn roto_progress(&self) -> Option<RotoProgress> {
|
||||
self.roto_job.as_ref().map(|j| j.progress())
|
||||
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
|
||||
self.offloaded(JobKind::RotoFreeze).map(|j| {
|
||||
let p = &j.progress;
|
||||
RotoProgress {
|
||||
task: Some(RotoTask::Freeze),
|
||||
done: p.done,
|
||||
total: p.total,
|
||||
elapsed: p.elapsed,
|
||||
fps: crate::offload::rate(p.done, p.elapsed),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// (comp, layer, effect uid, task) of the running job.
|
||||
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
|
||||
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task))
|
||||
self.roto_job
|
||||
.as_ref()
|
||||
.map(|j| (j.comp, j.layer, j.effect, j.task))
|
||||
.or_else(|| self.offloaded(JobKind::RotoFreeze).map(|j| (j.comp, j.layer, j.uid, RotoTask::Freeze)))
|
||||
}
|
||||
|
||||
pub fn stop_roto(&mut self) -> bool {
|
||||
if self.cancel_offloaded(JobKind::RotoFreeze) {
|
||||
return true;
|
||||
}
|
||||
match &self.roto_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
@@ -471,6 +491,11 @@ impl Session {
|
||||
task,
|
||||
};
|
||||
let strokes = str_of(g, fx::STROKES);
|
||||
if !wait && task == RotoTask::Freeze && self.offloads() {
|
||||
drop(work);
|
||||
self.offload_analysis(crate::offload::WorkerJob::RotoFreeze { comp, layer, effect })?;
|
||||
return Ok(n);
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let shared = Arc::new(RotoShared::default());
|
||||
let thread = if wait {
|
||||
|
||||
@@ -16,8 +16,8 @@ fn demo() -> Session {
|
||||
}
|
||||
|
||||
/// Records exports; fails items whose path contains "fail"; honours cancellation.
|
||||
struct MockExporter {
|
||||
log: Mutex<Vec<String>>,
|
||||
pub(crate) struct MockExporter {
|
||||
pub(crate) log: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Exporter for MockExporter {
|
||||
|
||||
@@ -44,12 +44,13 @@ impl Session {
|
||||
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().then(default_folder)
|
||||
(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);
|
||||
let Some(folder) = folder.filter(|_| d.disk_cache_enabled) else {
|
||||
// (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;
|
||||
@@ -126,11 +127,19 @@ impl Session {
|
||||
self.config.as_ref().and_then(|c| c.dir())
|
||||
}
|
||||
|
||||
/// Where auto-saves live: the config store's [`crate::config::FileOps`], else the file system.
|
||||
pub fn file_ops(&self) -> &dyn crate::config::FileOps {
|
||||
match &self.config {
|
||||
Some(c) => c.files(),
|
||||
None => &crate::config::StdFiles,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an auto-save now (whether or not the project is dirty). Returns its path.
|
||||
pub fn autosave_now(&mut self) -> Result<String> {
|
||||
let json = self.project.to_json();
|
||||
let root = self.default_autosave_root();
|
||||
let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
|
||||
let (path, slot) = autosave::write_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
|
||||
.map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
|
||||
let p = path.to_string_lossy().to_string();
|
||||
self.autosave.last_slot = Some(slot);
|
||||
|
||||
@@ -152,3 +152,108 @@ fn layer_params_get_a_source_companion() {
|
||||
assert!(matches!(src.ui, effectcraft_project::ParamUi::Hidden));
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects saved before parameters were renamed, regrouped, added or had their popups
|
||||
/// reordered open with their instances brought up to date (`effectcraft_effects::migrate`).
|
||||
#[test]
|
||||
fn opening_an_old_project_upgrades_effect_instances() {
|
||||
use effectcraft_effects::migrate::PARAM_ID_ALIASES;
|
||||
use effectcraft_project::{Node, ParamUi, PropGroup};
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
apply(&mut s, a, "ec.blur.gaussian");
|
||||
// One instance of every effect with renamed parameter ids.
|
||||
let mut aliased: Vec<&str> = PARAM_ID_ALIASES.iter().map(|(e, _, _)| *e).collect();
|
||||
aliased.dedup();
|
||||
for e in &aliased {
|
||||
apply(&mut s, a, e);
|
||||
}
|
||||
// Rewrite the saved instances the way an older version wrote them.
|
||||
fn take(g: &mut PropGroup, path: &str) -> Option<effectcraft_project::Property> {
|
||||
match path.split_once('/') {
|
||||
None => {
|
||||
let i = g.children.iter().position(|c| matches!(c, Node::Prop(p) if p.match_id == path))?;
|
||||
match g.children.remove(i) {
|
||||
Node::Prop(p) => Some(p),
|
||||
Node::Group(_) => None,
|
||||
}
|
||||
}
|
||||
Some((first, rest)) => take(g.sub_mut(first)?, rest),
|
||||
}
|
||||
}
|
||||
let mut old = (*s.project).clone();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
{
|
||||
let comp = std::sync::Arc::make_mut(match &mut old.items.get_mut(&cid).unwrap().kind {
|
||||
effectcraft_project::ItemKind::Comp(c) => c,
|
||||
_ => unreachable!(),
|
||||
});
|
||||
let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap();
|
||||
for n in &mut l.props.sub_mut("effects").unwrap().children {
|
||||
let Node::Group(g) = n else { continue };
|
||||
if g.match_id == "ec.blur.gaussian" {
|
||||
// Repeat Edge Pixels did not exist yet, Blurriness had another name, the popup
|
||||
// listed its options in reverse.
|
||||
take(g, "repeatEdge").unwrap();
|
||||
g.get_mut("blurriness").unwrap().name = "Old Blurriness".into();
|
||||
let d = g.get_mut("dimensions").unwrap();
|
||||
let ParamUi::Popup { options } = &d.ui else { panic!("dimensions is a popup") };
|
||||
d.ui = ParamUi::Popup { options: options.iter().rev().cloned().collect() };
|
||||
d.value = KV::Enum(0);
|
||||
}
|
||||
for (e, old_id, new_id) in PARAM_ID_ALIASES {
|
||||
if g.match_id == *e {
|
||||
let mut p = take(g, new_id).unwrap_or_else(|| panic!("{e}: no {new_id}"));
|
||||
// Put it back at its old path (which may be inside a group).
|
||||
let (path, leaf) = old_id.rsplit_once('/').map(|(p, l)| (Some(p), l)).unwrap_or((None, old_id));
|
||||
p.match_id = leaf.to_string();
|
||||
let mut at: &mut PropGroup = g;
|
||||
for seg in path.into_iter().flat_map(|p| p.split('/')) {
|
||||
if at.sub(seg).is_none() {
|
||||
at.children.push(PropGroup::new(1_000_000 + at.children.len() as u64, seg, seg).into());
|
||||
}
|
||||
at = at.sub_mut(seg).unwrap();
|
||||
}
|
||||
at.children.push(p.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("ec-upgrade-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("old.ecproj").to_string_lossy().to_string();
|
||||
std::fs::write(&path, old.to_json()).unwrap();
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
let l = s2.project.comp(cid).unwrap().layer(LayerId(a)).unwrap().clone();
|
||||
let fx = l.effects().unwrap();
|
||||
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
|
||||
let sp = |id: &str| spec.params.iter().find(|p| p.id == id).unwrap();
|
||||
let blur = fx.groups().find(|g| g.match_id == "ec.blur.gaussian").unwrap();
|
||||
assert_eq!(blur.get("blurriness").unwrap().name, sp("blurriness").name);
|
||||
assert!(blur.get("repeatEdge").is_some(), "missing parameter added");
|
||||
let dims = blur.get("dimensions").unwrap();
|
||||
let ParamUi::Popup { options } = &sp("dimensions").ui else { panic!() };
|
||||
assert_eq!(dims.value, KV::Enum(options.len() as u32 - 1), "popup value remapped by label");
|
||||
assert_eq!(dims.ui, sp("dimensions").ui);
|
||||
for (e, old_id, new_id) in PARAM_ID_ALIASES {
|
||||
let g = fx.groups().find(|g| g.match_id == *e).unwrap();
|
||||
let at = |id: &str| {
|
||||
let (path, leaf) = id.rsplit_once('/').map(|(p, l)| (Some(p), l)).unwrap_or((None, id));
|
||||
let mut node = Some(g);
|
||||
for seg in path.into_iter().flat_map(|p| p.split('/')) {
|
||||
node = node.and_then(|n| n.sub(seg));
|
||||
}
|
||||
node.and_then(|n| n.get(leaf)).is_some()
|
||||
};
|
||||
assert!(!at(old_id), "{e}: {old_id} still present");
|
||||
let (path, leaf) = new_id.rsplit_once('/').map(|(p, l)| (Some(p), l)).unwrap_or((None, new_id));
|
||||
let mut node = Some(g);
|
||||
for seg in path.into_iter().flat_map(|p| p.split('/')) {
|
||||
node = node.and_then(|n| n.sub(seg));
|
||||
}
|
||||
assert!(node.and_then(|n| n.get(leaf)).is_some(), "{e}: {old_id} not moved to {new_id}");
|
||||
}
|
||||
// The upgraded project renders.
|
||||
let _ = s2.render(cid, s2.time(), Default::default());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
//! M13.5 long-tail parity: stroke taper/wave/dashes, and the other small gaps (commands, undo,
|
||||
//! serde).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use effectcraft_project::{LayerId, Project};
|
||||
|
||||
pub(crate) fn comp(w: u32, h: u32) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Tail", "width": w, "height": h, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn roundtrip(s: &Session) -> Project {
|
||||
Project::from_json(&s.project.to_json()).unwrap()
|
||||
}
|
||||
|
||||
/// A horizontal open path 160 px long across a 200×100 comp, 10 px red stroke and no fill.
|
||||
fn line_layer(s: &mut Session) -> (u64, u64) {
|
||||
let r = s.execute("shape.newPath", json!({"vertices": [[20, 50], [180, 50]], "fill": false, "stroke": [1, 0, 0], "strokeWidth": 10})).unwrap();
|
||||
let lid = r["layer"].as_u64().unwrap();
|
||||
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
|
||||
let stroke = l.props.find_group(r["group"].as_u64().unwrap()).unwrap().sub("contents").unwrap().sub("stroke").unwrap().uid;
|
||||
(lid, stroke)
|
||||
}
|
||||
|
||||
fn column(s: &Session, x: u32) -> f32 {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
|
||||
(0..img.height).map(|y| img.data[(y * img.width + x) as usize][3]).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_taper_wave_commands_render_undo_and_serde() {
|
||||
let mut s = comp(200, 100);
|
||||
let (lid, stroke) = line_layer(&mut s);
|
||||
assert!((column(&s, 30) - 10.0).abs() < 0.6);
|
||||
let r = s.execute("shape.stroke.taper", json!({"layer": lid, "startLength": 40, "startWidth": 0})).unwrap();
|
||||
assert_eq!(r["stroke"], json!(stroke));
|
||||
assert!((column(&s, 30) - 2.5).abs() < 0.8, "tapered start: {}", column(&s, 30));
|
||||
// Percent units.
|
||||
s.execute("shape.stroke.taper", json!({"layer": lid, "units": "percent", "startLength": 25})).unwrap();
|
||||
assert!((column(&s, 30) - 2.5).abs() < 0.8, "25 % of 160 px = 40 px: {}", column(&s, 30));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((column(&s, 30) - 10.0).abs() < 0.6, "undo restores the plain stroke");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!((column(&s, 30) - 2.5).abs() < 0.8);
|
||||
// Wave in cycles.
|
||||
s.execute("shape.stroke.wave", json!({"layer": lid, "amount": 100, "units": "cycles", "cycles": 4})).unwrap();
|
||||
assert!(column(&s, 120) < 0.6, "trough at 100 px (2.5 cycles): {}", column(&s, 120));
|
||||
// Values are ordinary animatable properties that survive save/load.
|
||||
let back = roundtrip(&s);
|
||||
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
|
||||
let g = l.props.find_group(stroke).unwrap();
|
||||
assert_eq!(g.prop("wave/cycles").unwrap().value.as_f64(), 4.0);
|
||||
assert_eq!(g.prop("taper/startLength").unwrap().value.as_f64(), 40.0);
|
||||
assert!(s.execute("shape.stroke.wave", json!({"layer": lid, "units": "furlongs"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_plus_minus_buttons() {
|
||||
let mut s = comp(200, 100);
|
||||
let (lid, stroke) = line_layer(&mut s);
|
||||
let add = |s: &mut Session| s.execute("shape.dashes.add", json!({"layer": lid, "prop": stroke})).unwrap()["added"].as_str().unwrap().to_string();
|
||||
assert_eq!(add(&mut s), "dash");
|
||||
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash"), "value": 20})).unwrap();
|
||||
assert_eq!(add(&mut s), "dash2");
|
||||
assert_eq!(add(&mut s), "dash3");
|
||||
assert!(s.execute("shape.dashes.add", json!({"layer": lid})).is_err(), "three pairs at most");
|
||||
let names = |s: &Session| {
|
||||
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
|
||||
l.props.find_group(stroke).unwrap().sub("dashes").unwrap().children.iter().map(|n| n.match_id().to_string()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "dash3", "gap3", "offset"]);
|
||||
// Dash 20, Gap 20 (= dash), Dash 2 20, Gap 2 20…: set Dash 2 to 5.
|
||||
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap"), "value": 10})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash2"), "value": 5})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap2"), "value": 15})).unwrap();
|
||||
s.execute("shape.dashes.remove", json!({"layer": lid})).unwrap();
|
||||
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "offset"]);
|
||||
// Pattern 20 on, 10 off, 5 on, 15 off from x = 20 (butt caps).
|
||||
assert!(column(&s, 30) > 9.0 && column(&s, 45) < 0.1 && column(&s, 52) > 9.0 && column(&s, 60) < 0.1 && column(&s, 80) > 9.0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(names(&s).len(), 7);
|
||||
let back = roundtrip(&s);
|
||||
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
|
||||
assert_eq!(l.props.find_group(stroke).unwrap().prop("dashes/dash2").unwrap().value.as_f64(), 5.0);
|
||||
}
|
||||
|
||||
fn dash_uid(s: &Session, lid: u64, stroke: u64, m: &str) -> Value {
|
||||
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
|
||||
json!(l.props.find_group(stroke).unwrap().sub("dashes").unwrap().get(m).unwrap().uid)
|
||||
}
|
||||
|
||||
/// A white 200×200 solid in a 200×200 comp with a 100×100 rectangular mask (50…150).
|
||||
fn masked_solid(s: &mut Session) -> (u64, u64) {
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("mask.new", json!({"layer": l, "vertices": [[50, 50], [150, 50], [150, 150], [50, 150]], "closed": true})).unwrap();
|
||||
(l, r["mask"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn render_at(s: &Session, t: f64) -> effectcraft_raster::Image {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), Default::default())
|
||||
}
|
||||
|
||||
fn alpha_at(s: &Session, x: u32, y: u32, t: f64) -> f32 {
|
||||
let img = render_at(s, t);
|
||||
img.data[(y * img.width + x) as usize][3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_mask_feather_points() {
|
||||
let mut s = comp(200, 200);
|
||||
let (l, m) = masked_solid(&mut s);
|
||||
s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap();
|
||||
assert!(alpha_at(&s, 100, 45, 0.0) < 0.01);
|
||||
// Mask Feather tool: click on the path and drag out above the top edge → an outer point of 20 px.
|
||||
let r = s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 30]})).unwrap();
|
||||
assert_eq!(r["index"], json!(0));
|
||||
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
|
||||
let p0 = &pts["points"][0];
|
||||
assert_eq!(p0["segment"], json!(0));
|
||||
assert!((p0["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && (p0["radius"].as_f64().unwrap() - 20.0).abs() < 1e-3, "{pts}");
|
||||
let soft = alpha_at(&s, 100, 39, 0.0);
|
||||
assert!((soft - 0.475).abs() < 0.06, "{soft}");
|
||||
// An inner point dragged inside the bottom edge.
|
||||
s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 130], "tension": 100})).unwrap();
|
||||
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
|
||||
assert!((pts["points"][1]["radius"].as_f64().unwrap() + 20.0).abs() < 1e-3, "{pts}");
|
||||
assert!((alpha_at(&s, 100, 140, 0.0) - 0.475).abs() < 0.08, "{}", alpha_at(&s, 100, 140, 0.0));
|
||||
assert!(alpha_at(&s, 100, 152, 0.0) < 0.01);
|
||||
// Drag the radius; undo; redo.
|
||||
s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 40})).unwrap();
|
||||
assert!(alpha_at(&s, 100, 39, 0.0) > 0.7);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((alpha_at(&s, 100, 39, 0.0) - soft).abs() < 0.08);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
// Inserting a vertex keeps the points where they were on the path.
|
||||
let before = alpha_at(&s, 100, 39, 0.0);
|
||||
s.execute("mask.insertVertex", json!({"layer": l, "mask": m, "segment": 0, "t": 0.25})).unwrap();
|
||||
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
|
||||
assert_eq!(pts["points"][0]["segment"], json!(1));
|
||||
assert!((pts["points"][0]["t"].as_f64().unwrap() - 1.0 / 3.0).abs() < 1e-6);
|
||||
assert!((alpha_at(&s, 100, 39, 0.0) - before).abs() < 0.02);
|
||||
// Serde: the points travel in the Mask Path value.
|
||||
let back = roundtrip(&s);
|
||||
let lay = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(l)).unwrap();
|
||||
let effectcraft_keyframe::Value::Path(sp) = &lay.props.find_group(m).unwrap().get("path").unwrap().value else { panic!() };
|
||||
assert_eq!(sp.feather.len(), 2);
|
||||
s.execute("mask.featherPoint.remove", json!({"layer": l, "mask": m, "all": true})).unwrap();
|
||||
assert!(alpha_at(&s, 100, 39, 0.0) < 0.01);
|
||||
assert!(s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_motion_blur_ramps_across_the_motion() {
|
||||
let mut s = comp(200, 200);
|
||||
let (l, m) = masked_solid(&mut s);
|
||||
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
|
||||
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "masks/#1/path", "time": 0.0})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
// 30 px in 30 frames: 1 px per frame; with a 360° shutter at frame 15 the right edge sweeps
|
||||
// 165…166, so pixel 165 is half covered.
|
||||
let verts: Vec<Value> = (0..4).map(|i| json!({"layer": l, "mask": m, "index": i})).collect();
|
||||
s.execute("mask.moveVertices", json!({"vertices": verts, "delta": [30, 0]})).unwrap();
|
||||
let a = alpha_at(&s, 165, 100, 0.5);
|
||||
assert!(a > 0.3 && a < 0.7, "{a}");
|
||||
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap();
|
||||
let hard = alpha_at(&s, 165, 100, 0.5);
|
||||
assert!(!(0.3..0.7).contains(&hard), "{hard}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_sample_limit_drives_layer_samples() {
|
||||
assert_eq!(effectcraft_render::adaptive_samples(0.5, 16, 128), 16);
|
||||
assert_eq!(effectcraft_render::adaptive_samples(100.0, 16, 128), 100);
|
||||
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 128), 128);
|
||||
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 9999), 256);
|
||||
let mut s = comp(400, 100);
|
||||
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap();
|
||||
// 100 px per frame (keys 3 frames apart, 300 px).
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [50, 50]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.1, "value": [350, 50]})).unwrap();
|
||||
let max_step = |s: &Session| {
|
||||
let img = render_at(s, 1.0 / 30.0);
|
||||
let row: Vec<f32> = (0..img.width).map(|x| img.data[(50 * img.width + x) as usize][3]).collect();
|
||||
row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max)
|
||||
};
|
||||
s.execute("comp.settings", json!({"adaptiveSampleLimit": 16})).unwrap();
|
||||
let coarse = max_step(&s);
|
||||
s.execute("comp.settings", json!({"adaptiveSampleLimit": 256})).unwrap();
|
||||
let fine = max_step(&s);
|
||||
assert!(coarse > 0.05, "16 samples over 100 px leave visible steps: {coarse}");
|
||||
assert!(fine < 0.03, "≈100 samples give a smooth ramp: {fine}");
|
||||
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 256);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 16);
|
||||
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 16);
|
||||
// Older projects without the field read the After Effects default.
|
||||
fn strip(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
o.remove("motion_blur_adaptive_limit");
|
||||
o.values_mut().for_each(strip);
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(strip),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut v: Value = serde_json::from_str(&s.project.to_json()).unwrap();
|
||||
strip(&mut v);
|
||||
let json = v.to_string();
|
||||
assert!(!json.contains("motion_blur_adaptive_limit"));
|
||||
assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_label_groups() {
|
||||
let mut s = comp(200, 100);
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
for l in [a, b] {
|
||||
for t in [0.0, 0.5, 1.0] {
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t})).unwrap();
|
||||
}
|
||||
}
|
||||
let uid = |s: &Session, l: u64| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
let (ua, ub) = (uid(&s, a), uid(&s, b));
|
||||
// Label a's first key and b's last key Blue.
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}, {"layer": b, "prop": ub, "time": 1.0}]})).unwrap();
|
||||
s.execute("keys.setLabel", json!({"label": "Blue"})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.5}]})).unwrap();
|
||||
s.execute("keys.setLabel", json!({"label": "Green"})).unwrap();
|
||||
// On selected layers: from a's first key, only a's Blue key.
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
|
||||
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap()["keys"], json!(1));
|
||||
// On all layers: both Blue keys.
|
||||
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(2));
|
||||
assert!(s.state.selected_keys.iter().any(|k| k.layer == LayerId(b) && (k.time.seconds() - 1.0).abs() < 1e-6));
|
||||
// Visible keyframes: only the revealed properties count.
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
|
||||
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [ua]})).unwrap()["keys"], json!(1));
|
||||
// Unlabelled keys group together too (label None).
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 1.0}]})).unwrap();
|
||||
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(3));
|
||||
// Undo, serde.
|
||||
let lab = |s: &Session| s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label;
|
||||
assert_eq!(lab(&s), 9);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(lab(&s), 0);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label, 9);
|
||||
assert!(s.execute("keys.setLabel", json!({"label": "Mauve"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tate_chu_yoko_via_set_text_with_undo_and_serde() {
|
||||
let mut s = comp(400, 400);
|
||||
let t = s.execute("layer.newText", json!({"text": "A12B", "size": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "vertical": true})).unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [1, 3], "tateChuYoko": true})).unwrap();
|
||||
let doc = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
|
||||
assert!(doc.style_at(1).tate_chu_yoko && doc.style_at(2).tate_chu_yoko && !doc.style_at(0).tate_chu_yoko);
|
||||
let lay = effectcraft_text::layout_doc(&doc);
|
||||
assert!((lay.glyphs[1].origin.y - lay.glyphs[2].origin.y).abs() < 1e-6, "the digits share a row");
|
||||
let back = roundtrip(&s);
|
||||
let effectcraft_keyframe::Value::Text(d) =
|
||||
&back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(d.style_at(1).tate_chu_yoko);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(1).tate_chu_yoko);
|
||||
s.execute("layer.setText", json!({"layer": t, "verticalRomanUpright": true})).unwrap();
|
||||
assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().vertical_roman_upright);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_font_axes_animator() {
|
||||
let mut s = comp(400, 200);
|
||||
let t = s.execute("layer.newText", json!({"text": "HH", "size": 60})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [t]})).unwrap();
|
||||
assert!(s.is_enabled("text.animatorFontAxes"));
|
||||
// Inter (bundled) is static.
|
||||
assert_eq!(s.execute("text.animatorFontAxes", json!({})).unwrap()["axes"], json!([]));
|
||||
assert!(s.execute("text.animatorFontAxes", json!({"axis": "wght"})).is_err());
|
||||
let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else {
|
||||
eprintln!("no variable font installed: animation check skipped");
|
||||
return;
|
||||
};
|
||||
let info = effectcraft_text::fonts::face(face).info.clone();
|
||||
s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap();
|
||||
let axis = axes.iter().find(|a| a.max > a.min).unwrap().clone();
|
||||
let listed = s.execute("text.animatorFontAxes", json!({})).unwrap();
|
||||
if listed["axes"].as_array().is_none_or(|a| a.is_empty()) {
|
||||
eprintln!("the variable face isn't reachable by family/style: skipped");
|
||||
return;
|
||||
}
|
||||
let tag = listed["axes"][0]["tag"].as_str().unwrap().to_string();
|
||||
let r = s.execute("text.animatorFontAxes", json!({"axis": tag})).unwrap();
|
||||
let anim = r["animator"].as_u64().unwrap();
|
||||
let area = |s: &Session| {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
|
||||
let l = comp.layer(LayerId(t)).unwrap();
|
||||
effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum::<f64>()
|
||||
};
|
||||
let before = area(&s);
|
||||
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap().clone();
|
||||
let pg = l.props.find_group(anim).unwrap().sub("properties").unwrap().props().next().unwrap().uid;
|
||||
let range = (axis.max - axis.min) as f64;
|
||||
s.execute("prop.set", json!({"layer": t, "prop": pg, "value": range})).unwrap();
|
||||
let after = area(&s);
|
||||
assert!((after - before).abs() > 1.0, "{} axis moved the outline: {before} → {after}", axis.tag);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((area(&s) - before).abs() < 1e-6);
|
||||
let _ = info;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_focus_commands_undo_and_serde() {
|
||||
let mut s = comp(400, 300);
|
||||
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 500]})).unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
||||
assert!(s.is_enabled("camera.setFocusToLayer"));
|
||||
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
|
||||
assert!((d - before - 500.0).abs() < 1e-6, "the default camera focuses on z = 0: {before} → {d}");
|
||||
s.execute("camera.linkFocusToLayer", json!({})).unwrap();
|
||||
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
|
||||
assert!(e["expression"].as_str().unwrap().contains("thisComp.layer("), "{e}");
|
||||
let back = roundtrip(&s);
|
||||
assert!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().expr.is_some());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
|
||||
assert!(e["expression"].is_null());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let v = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||
assert!((v - before).abs() < 1e-9);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
// A camera alone can't link to a layer, but can link to its point of interest.
|
||||
s.execute("layer.select", json!({"layers": [cam]})).unwrap();
|
||||
assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err());
|
||||
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
|
||||
}
|
||||
@@ -292,6 +292,7 @@ fn split_segment_keeps_curve_shape() {
|
||||
in_tangents: vec![[0.0; 2], [0.0, -50.0]],
|
||||
out_tangents: vec![[30.0, 60.0], [0.0; 2]],
|
||||
closed: false,
|
||||
feather: Vec::new(),
|
||||
};
|
||||
let seg = |sp: &ShapePath, i: usize, t: f64| {
|
||||
let (a, d) = (sp.vertices[i], sp.vertices[i + 1]);
|
||||
|
||||
@@ -70,7 +70,8 @@ pub fn track_options(o: &TrackerOptions) -> trk::TrackOptions {
|
||||
}
|
||||
|
||||
/// A track point's regions and attach offset read from its properties at a layer time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PointValues {
|
||||
pub uid: Uid,
|
||||
pub spec: trk::PointSpec,
|
||||
@@ -292,6 +293,7 @@ pub(crate) fn run_work(w: Work, shared: &TrackShared) {
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
|
||||
s.time = w.times[k].seconds();
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
@@ -322,16 +324,19 @@ fn write_frames(p: &mut Project, job: &TrackJob, frames: &[FrameResult]) {
|
||||
impl Session {
|
||||
/// Whether a track analysis is running.
|
||||
pub fn is_tracking(&self) -> bool {
|
||||
self.track_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.track_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::Track).is_some()
|
||||
}
|
||||
|
||||
/// Live progress of the running (or just finished) analysis.
|
||||
pub fn track_progress(&self) -> Option<TrackProgress> {
|
||||
self.track_job.as_ref().map(|j| j.progress())
|
||||
self.track_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::Track).map(|j| j.progress.track()))
|
||||
}
|
||||
|
||||
/// Cancel the running analysis (results so far are kept).
|
||||
pub fn stop_track(&mut self) -> bool {
|
||||
if self.cancel_offloaded(crate::offload::JobKind::Track) {
|
||||
return true;
|
||||
}
|
||||
match &self.track_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.cancel();
|
||||
@@ -348,6 +353,10 @@ impl Session {
|
||||
return Err("a track analysis is already running".into());
|
||||
}
|
||||
self.poll_track();
|
||||
if !wait && self.offloads() {
|
||||
let Work { comp, layer, settings, points, times, .. } = work;
|
||||
return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times });
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
self.history.undo.push(("Analyze Track".into(), self.project.clone()));
|
||||
self.history.redo.clear();
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
|
||||
//! automatically too).
|
||||
|
||||
use crate::offload::{JobKind, rate};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -232,6 +233,7 @@ fn run_work(w: WarpWork, shared: &WarpShared) {
|
||||
s.done = k as u64 + 1;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
lock(&shared.state).step = 2;
|
||||
let lt0 = layer.layer_time(w.times[0]).seconds();
|
||||
@@ -264,21 +266,29 @@ pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) ->
|
||||
|
||||
impl Session {
|
||||
pub fn is_warp_analyzing(&self) -> bool {
|
||||
self.warp_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.warp_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Warp).is_some()
|
||||
}
|
||||
|
||||
/// Progress of the running (or just finished) analysis.
|
||||
pub fn warp_progress(&self) -> Option<WarpProgress> {
|
||||
self.warp_job.as_ref().map(|j| j.progress())
|
||||
self.warp_job.as_ref().map(|j| j.progress()).or_else(|| {
|
||||
self.offloaded(JobKind::Warp).map(|j| {
|
||||
let p = &j.progress;
|
||||
WarpProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: rate(p.done, p.elapsed), ..Default::default() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The effect being analysed: (comp, layer, effect uid).
|
||||
pub fn warp_target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
|
||||
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Warp).map(|j| (j.comp, j.layer, j.uid)))
|
||||
}
|
||||
|
||||
/// Cancel the running analysis (nothing is written).
|
||||
pub fn stop_warp(&mut self) -> bool {
|
||||
if self.cancel_offloaded(JobKind::Warp) {
|
||||
return true;
|
||||
}
|
||||
match &self.warp_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
@@ -325,6 +335,11 @@ impl Session {
|
||||
times,
|
||||
};
|
||||
let key = signature(&self.project, comp, l);
|
||||
if !wait && self.offloads() {
|
||||
drop(work);
|
||||
self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?;
|
||||
return Ok(n);
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let shared = Arc::new(WarpShared::default());
|
||||
let thread = if wait {
|
||||
@@ -391,7 +406,7 @@ impl Session {
|
||||
/// re-analyses in the background, like After Effects.
|
||||
pub fn poll_warp(&mut self, auto: bool) -> bool {
|
||||
let changed = self.poll_warp_job();
|
||||
if auto && self.warp_job.is_none() {
|
||||
if auto && self.warp_job.is_none() && self.offloaded(JobKind::Warp).is_none() {
|
||||
while let Some((c, l, e)) = self.warp_pending.first().copied() {
|
||||
self.warp_pending.remove(0);
|
||||
if self.start_warp(c, l, e, false).is_ok() {
|
||||
|
||||
Reference in New Issue
Block a user