Merge main into M13.2

This commit is contained in:
Brandon Thomas
2026-10-03 04:19:01 -04:00
86 changed files with 10306 additions and 325 deletions
+256
View File
@@ -0,0 +1,256 @@
//! Layer ▸ Time ▸ Align Video to Data: move a footage layer in time so its frames line up with
//! the samples of a data file (telemetry, GPS, sensor logs imported as data footage).
//!
//! The data's sample with timestamp `T` sits at comp time `dataStart + (T − T₀)` (`T₀` = the
//! first sample's timestamp; `dataStart` defaults to 0). A video frame at source time `s` was
//! recorded at `V₀ + s`, where `V₀` is the video's start: `videoStart` if given, else the
//! footage file's creation date (desktop). Aligning sets the layer's Start Time to
//! `dataStart + V₀ − T₀`, so the frame recorded at `T` plays when the data says `T`.
//!
//! **Supported data** (File ▸ Import as data footage; also in `docs/architecture.md`):
//! - JSON: an array of objects, or an object holding such an array (the first array of objects
//! found at the top level, e.g. `{"samples": [...]}`);
//! - CSV / TSV with a header row (quoted fields allowed).
//!
//! The time key is `key`, else the first field named `time`, `timestamp`, `t`, `date`,
//! `datetime`, `utc`, `gps_time`, `seconds` or `time_s` (any case). Timestamps may be ISO 8601
//! date-times (`2026-05-01T10:15:30.5Z`, offsets `±hh:mm`), times of day (`10:15:30.5`),
//! timecode (`10:15:30:12` at the footage's rate), Unix seconds, or Unix milliseconds (numbers
//! above 10¹¹). When the data has only times of day, `V₀` is reduced to its UTC time of day.
use effectcraft_project::{FootageKind, ItemId, ItemKind};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, bad, f_p, has_layers, layer_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// A parsed timestamp: seconds, and whether it carries a date (absolute) or is a time of day /
/// relative number.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Stamp {
pub secs: f64,
pub dated: bool,
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds.
fn clock(s: &str, fps: f64) -> Option<f64> {
let parts: Vec<&str> = s.split([':', ';']).collect();
match parts.as_slice() {
[h, m, sec] => Some(h.trim().parse::<f64>().ok()? * 3600.0 + m.parse::<f64>().ok()? * 60.0 + sec.parse::<f64>().ok()?),
[h, m, sec, f] => {
Some(h.trim().parse::<f64>().ok()? * 3600.0 + m.parse::<f64>().ok()? * 60.0 + sec.parse::<f64>().ok()? + f.parse::<f64>().ok()? / fps.max(1.0))
}
[m, sec] => Some(m.trim().parse::<f64>().ok()? * 60.0 + sec.parse::<f64>().ok()?),
_ => None,
}
}
/// Parse a timestamp value (see the module docs). `fps` reads timecode frames.
pub fn parse_stamp(v: &Value, fps: f64) -> Option<Stamp> {
match v {
Value::Number(n) => {
let x = n.as_f64()?;
Some(if x > 1e11 { Stamp { secs: x / 1000.0, dated: true } } else { Stamp { secs: x, dated: x > 1e8 } })
}
Value::String(s) => {
let s = s.trim();
if let Ok(x) = s.parse::<f64>() {
return parse_stamp(&json!(x), fps);
}
// ISO 8601: date [T| ] time [zone].
if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' {
let (y, m, d) = (s[0..4].parse::<i64>().ok()?, s[5..7].parse::<i64>().ok()?, s[8..10].parse::<i64>().ok()?);
let mut secs = days_from_civil(y, m, d) as f64 * 86_400.0;
let rest = s[10..].trim_start_matches(['T', 't', ' ']);
if !rest.is_empty() {
// Zone: Z or ±hh:mm / ±hhmm.
let (time, zone) = match rest.find(['Z', 'z', '+']).or_else(|| rest.rfind('-')) {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
secs += clock(time, fps)?;
if zone.len() > 1 {
let sign = if zone.starts_with('-') { -1.0 } else { 1.0 };
let z = zone[1..].replace(':', "");
let (zh, zm) = (z.get(0..2)?.parse::<f64>().ok()?, z.get(2..4).and_then(|m| m.parse::<f64>().ok()).unwrap_or(0.0));
secs -= sign * (zh * 3600.0 + zm * 60.0);
}
}
return Some(Stamp { secs, dated: true });
}
clock(s, fps).map(|secs| Stamp { secs, dated: false })
}
_ => None,
}
}
const TIME_KEYS: &[&str] = &["time", "timestamp", "t", "date", "datetime", "utc", "gps_time", "gpstime", "seconds", "time_s", "times"];
/// Split a CSV/TSV line (quotes allowed).
fn split_row(line: &str, sep: char) -> Vec<String> {
let mut out = vec![];
let mut cur = String::new();
let mut q = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if q && chars.peek() == Some(&'"') => {
cur.push('"');
chars.next();
}
'"' => q = !q,
c if c == sep && !q => out.push(std::mem::take(&mut cur)),
c => cur.push(c),
}
}
out.push(cur);
out.into_iter().map(|s| s.trim().to_string()).collect()
}
/// The time column of data footage: (key used, timestamps in row order).
pub fn data_times(text: &str, format: &str, key: Option<&str>) -> std::result::Result<(String, Vec<Value>), String> {
let pick = |names: &[String]| -> Option<String> {
if let Some(k) = key {
return names.iter().find(|n| n.eq_ignore_ascii_case(k)).cloned();
}
TIME_KEYS.iter().find_map(|t| names.iter().find(|n| n.eq_ignore_ascii_case(t)).cloned())
};
if format == "json" {
let v: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
let rows = match &v {
Value::Array(a) => a.clone(),
Value::Object(o) => {
o.values().find_map(|x| x.as_array().filter(|a| a.first().is_some_and(Value::is_object)).cloned()).ok_or("no array of samples in the JSON")?
}
_ => return Err("the JSON holds no samples".into()),
};
let names: Vec<String> = rows.first().and_then(Value::as_object).map(|o| o.keys().cloned().collect()).unwrap_or_default();
let k = pick(&names)
.ok_or_else(|| format!("no time field (looked for {}); pass `key`", key.map(|k| k.to_string()).unwrap_or_else(|| TIME_KEYS.join(", "))))?;
let times = rows.iter().filter_map(|r| r.get(&k).cloned()).collect();
return Ok((k, times));
}
let sep = if format == "tsv" { '\t' } else { ',' };
let mut lines = text.trim_start_matches('\u{feff}').lines().filter(|l| !l.trim().is_empty());
let header = split_row(lines.next().ok_or("the file is empty")?, sep);
let k = pick(&header)
.ok_or_else(|| format!("no time column (looked for {}); pass `key`", key.map(|k| k.to_string()).unwrap_or_else(|| TIME_KEYS.join(", "))))?;
let col = header.iter().position(|h| *h == k).unwrap_or(0);
let times = lines.filter_map(|l| split_row(l, sep).get(col).map(|c| json!(c))).collect();
Ok((k, times))
}
fn data_item(s: &Session, p: &Value) -> Result<ItemId> {
let c = "layer.alignVideoToData";
let id = match p.get("data") {
Some(Value::Number(n)) => n.as_u64().map(ItemId),
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id),
_ => s.project.items.values().find(|i| matches!(&i.kind, ItemKind::Footage(f) if f.kind == FootageKind::Data)).map(|i| i.id),
}
.ok_or_else(|| bad(c, "no data footage: import a JSON/CSV/TSV file and pass `data`"))?;
Ok(id)
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.alignVideoToData";
let (cid, lid) = layer_p(s, p, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let footage = match layer.source.item().and_then(|i| s.project.item(i)).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) if f.kind != FootageKind::Data => f.clone(),
_ => return Err(bad(c, "select a footage layer")),
};
let fps = footage.frame_rate.as_f64();
let did = data_item(s, p)?;
let Some(ItemKind::Footage(df)) = s.project.item(did).map(|i| &i.kind) else { return Err(bad(c, "not data footage")) };
let text = df.data.clone().ok_or_else(|| bad(c, "the data item holds no data"))?;
let format = df.codec.to_ascii_lowercase();
let (key, raw) = data_times(&text, &format, str_p(p, "key").filter(|k| !k.is_empty())).map_err(|e| bad(c, e))?;
let stamps: Vec<Stamp> = raw.iter().filter_map(|v| parse_stamp(v, fps)).collect();
let first = *stamps.first().ok_or_else(|| bad(c, format!("no readable timestamps in `{key}`")))?;
let video = match p.get("videoStart").filter(|v| v.as_str() != Some("")) {
Some(v) => parse_stamp(v, fps).ok_or_else(|| bad(c, "videoStart: ISO 8601 date-time, hh:mm:ss, timecode or seconds"))?,
None => {
let (created, _, _) = crate::media_browser::file_dates(&footage.path);
Stamp { secs: created.ok_or_else(|| bad(c, "the footage has no recording time; pass `videoStart`"))? as f64, dated: true }
}
};
// Compare like with like: times of day when the data has no dates.
let v0 = if video.dated && !first.dated { video.secs.rem_euclid(86_400.0) } else { video.secs };
let data_start = f_p(p, "dataStart").unwrap_or(0.0);
let fr = comp.frame_rate;
let start = fr.snap_nearest(Tick::from_seconds_f64(data_start + v0 - first.secs));
let shift = start - layer.start_time;
s.edit("Align Video to Data", None, |proj, _| {
let l = super::layer_mut(proj, cid, lid)?;
l.start_time += shift;
l.in_point += shift;
l.out_point += shift;
Ok(())
})?;
Ok(json!({
"layer": lid.0,
"startTime": start.seconds(),
"shift": shift.seconds(),
"key": key,
"samples": stamps.len(),
"firstSample": first.secs,
"videoStart": v0,
"dataStart": data_start,
}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"layer.alignVideoToData",
"Align Video to Data",
["Layer", "Time"],
None,
"{layer?, data?: data footage id|name, key?: time field, videoStart?: ISO date-time | hh:mm:ss | timecode | seconds (default: file creation time), dataStart? (comp s of the first sample, 0)}",
has_layers,
run
)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_timestamps() {
let p = |v: Value| parse_stamp(&v, 25.0).unwrap();
assert_eq!(p(json!("1970-01-02T00:00:10Z")).secs, 86_410.0);
assert_eq!(p(json!("2000-03-01T00:00:00Z")).secs, 951_868_800.0);
assert_eq!(p(json!("1970-01-01T02:00:00+02:00")).secs, 0.0);
assert_eq!(p(json!("1970-01-01 01:00:00.5-01:00")).secs, 7200.5);
assert_eq!(p(json!("10:00:01.25")), Stamp { secs: 36_001.25, dated: false });
assert_eq!(p(json!("10:00:01:05")).secs, 36_001.2);
assert_eq!(p(json!(1_700_000_000_500u64)), Stamp { secs: 1_700_000_000.5, dated: true });
assert_eq!(p(json!(12.5)), Stamp { secs: 12.5, dated: false });
assert_eq!(p(json!("3.5")).secs, 3.5);
assert!(parse_stamp(&json!("soon"), 25.0).is_none());
}
#[test]
fn finds_time_columns() {
let (k, t) = data_times("lat,Time,speed\n1,\"10:00:00\",3\n2,10:00:01,4\n", "csv", None).unwrap();
assert_eq!(k, "Time");
assert_eq!(t, vec![json!("10:00:00"), json!("10:00:01")]);
let (k, t) = data_times("a\tts\n1\t5\n", "tsv", Some("ts")).unwrap();
assert_eq!((k.as_str(), t), ("ts", vec![json!("5")]));
let (k, t) = data_times(r#"{"meta": 1, "samples": [{"timestamp": 3, "v": 1}, {"timestamp": 4}]}"#, "json", None).unwrap();
assert_eq!((k.as_str(), t), ("timestamp", vec![json!(3), json!(4)]));
assert!(data_times("x,y\n1,2\n", "csv", None).is_err());
}
}
+6
View File
@@ -103,6 +103,12 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad("anim.applyPreset", "select a layer"));
}
// Animation ▸ Recent Animation Presets.
if let Some(path) = str_p(p, "path") {
s.prefs.push_recent_preset(path);
s.save_prefs();
s.prefs_revision += 1;
}
let effects: Vec<PropGroup> =
doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();
let props: Vec<(String, Property)> = doc["properties"]
+593
View File
@@ -0,0 +1,593 @@
//! Menu entries of M14.5: Edit ▸ History, File ▸ Import Recent Footage, Replace Footage ▸ With
//! Layered Comp, Save a Copy As XML, Animation ▸ Recent Animation Presets, Composition ▸ VR
//! (Create VR Environment, Extract Cubemap), the "Assign Shortcut to …" submenus, Help ▸ Enable
//! Logging / Reveal Logging File / System Compatibility Report, and `File.execute()` for scripts.
use effectcraft_geom::{Mat4, Vec3, vec3};
use effectcraft_project::{ItemId, ItemKind, LayerSource};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_project_selection, str_p};
use crate::{EngineError, Event, Result, Session, cmd, query};
/// Run `f` (which may run several undoable commands) as one undo step named `label`; on error
/// the project is restored.
pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
// The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot
// taken before them), then become one step on the real one.
let saved = std::mem::take(&mut s.history.undo);
let before = s.project.clone();
let r = f(s);
let changed = !s.history.undo.is_empty();
s.history.undo = saved;
s.history.merge_key = None;
match r {
Ok(v) => {
if changed {
s.history.undo.push((label.to_string(), before));
let levels = s.prefs.general.undo_levels.max(1) as usize;
if s.history.undo.len() > levels {
let extra = s.history.undo.len() - levels;
s.history.undo.drain(..extra);
}
}
Ok(v)
}
Err(e) => {
if changed {
s.project = before;
s.sanitize_state();
s.bump();
}
Err(e)
}
}
}
fn id_of(v: &Value, key: &str) -> Result<u64> {
v.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::Other(format!("expected `{key}` in {v}")))
}
// ---------------------------------------------------------------- Edit ▸ History
/// Edit ▸ History: `{steps: n}` undoes the last n steps (the entry n−1 of the list), `{redo: n}`
/// redoes n; without either, lists the steps (newest first).
fn history(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(n) = p.get("steps").and_then(Value::as_u64) {
if n as usize > s.history.undo.len() {
return Err(bad("edit.history", format!("only {} undo steps", s.history.undo.len())));
}
for _ in 0..n {
s.undo();
}
} else if let Some(n) = p.get("redo").and_then(Value::as_u64) {
if n as usize > s.history.redo.len() {
return Err(bad("edit.history", format!("only {} redo steps", s.history.redo.len())));
}
for _ in 0..n {
s.redo();
}
}
let undo: Vec<&str> = s.history.undo.iter().rev().map(|(l, _)| l.as_str()).collect();
let redo: Vec<&str> = s.history.redo.iter().rev().map(|(l, _)| l.as_str()).collect();
Ok(json!({"undo": undo, "redo": redo}))
}
fn has_history(s: &Session) -> std::result::Result<(), String> {
if s.history.undo.is_empty() && s.history.redo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
}
// ---------------------------------------------------------------- recent footage / presets
fn import_recent(s: &mut Session, p: &Value) -> Result<Value> {
let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) {
(Some(x), _) => x.to_string(),
(None, Some(i)) => s.prefs.recent_footage.get(i as usize).cloned().ok_or_else(|| bad("file.importRecent", format!("no recent footage #{i}")))?,
_ => s.prefs.recent_footage.first().cloned().ok_or_else(|| bad("file.importRecent", "no recent footage"))?,
};
super::file::import(s, &json!({"paths": [path]}))
}
fn has_recent_footage(s: &Session) -> std::result::Result<(), String> {
if s.prefs.recent_footage.is_empty() { Err("no recent footage".into()) } else { Ok(()) }
}
fn clear_recent_footage(s: &mut Session, _: &Value) -> Result<Value> {
s.prefs.recent_footage.clear();
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
fn apply_recent_preset(s: &mut Session, p: &Value) -> Result<Value> {
let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) {
(Some(x), _) => x.to_string(),
(None, Some(i)) => s.prefs.recent_presets.get(i as usize).cloned().ok_or_else(|| bad("anim.applyRecentPreset", format!("no recent preset #{i}")))?,
_ => s.prefs.recent_presets.first().cloned().ok_or_else(|| bad("anim.applyRecentPreset", "no recent animation presets"))?,
};
let mut q = json!({"path": path});
if let Some(l) = p.get("layers") {
q["layers"] = l.clone();
}
s.execute("anim.applyPreset", q)
}
fn has_recent_presets(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
if s.prefs.recent_presets.is_empty() { Err("no recent animation presets".into()) } else { Ok(()) }
}
fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
s.prefs.recent_presets.clear();
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
// ---------------------------------------------------------------- File menu
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
let xml = crate::xml_project::to_xml(&s.project.to_json()).map_err(EngineError::Other)?;
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved an XML copy to {path}"));
Ok(json!({"path": path, "bytes": xml.len()}))
}
/// File ▸ Replace Footage ▸ With Layered Comp: a layered (Photoshop) footage item becomes a
/// composition of its layers; every layer using the footage now uses the comp, and the footage
/// item goes. One undo step.
fn replace_with_layered_comp(s: &mut Session, p: &Value) -> Result<Value> {
let item = p.get("item").and_then(Value::as_u64).map(ItemId).or_else(|| s.state.project_selection.first().copied());
let item = item.ok_or_else(|| bad("file.replaceWithLayeredComp", "select a layered footage item"))?;
let path = match s.project.item(item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) if f.codec == "PSD" || f.path.to_ascii_lowercase().ends_with(".psd") => f.path.clone(),
_ => return Err(bad("file.replaceWithLayeredComp", "the item is not layered footage (a Photoshop file)")),
};
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
grouped(s, "Replace Footage", |s| {
let (comp, _, _) = super::file::import_psd_comp(s, &path, bytes, false)?;
let comp = ItemId(comp);
s.edit("Replace Footage", None, |proj, st| {
let ids: Vec<ItemId> = proj.items.keys().copied().collect();
for cid in ids {
if cid == comp {
continue;
}
if let Some(c) = proj.comp_mut(cid) {
for l in &mut c.layers {
if matches!(&l.source, LayerSource::Footage { item: i } if *i == item) {
l.source = LayerSource::Comp { item: comp };
}
}
}
}
proj.items.remove(&item);
st.project_selection = vec![comp];
Ok(())
})?;
Ok(json!({"comp": comp.0}))
})
}
fn has_footage(s: &Session) -> std::result::Result<(), String> {
has_project_selection(s)?;
let ok = s.state.project_selection.iter().any(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_))));
if ok { Ok(()) } else { Err("select a footage item".into()) }
}
/// `File.execute()` from scripts (and agents): open a file with its default application.
/// Needs Allow Scripts to Write Files and Access Network; with Warn User When Executing Files on,
/// the frontend asks first (`confirmed: true` skips the question).
fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.executeFile", "missing `path`"))?;
if !s.prefs.scripting.allow_scripts_write_files {
return Err(bad(
"file.executeFile",
"scripts can't execute files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on",
));
}
if !std::path::Path::new(path).exists() {
return Err(bad("file.executeFile", format!("no file {path}")));
}
if s.prefs.scripting.warn_executing_files && !b_p(p, "confirmed").unwrap_or(false) {
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
return Ok(json!({"needsConfirmation": path}));
}
let url = format!("file://{}", path.replace(' ', "%20"));
s.events.push(Event::OpenUrl(url));
Ok(json!({"opened": path}))
}
// ---------------------------------------------------------------- Assign Shortcut to …
/// The F10 / F11 / F12 (3D views) or Shift+F10 / F11 / F12 (workspaces) slots.
fn slot_p(p: &Value, cmd: &str, shift: bool) -> Result<String> {
let raw = str_p(p, "slot").ok_or_else(|| bad(cmd, "missing `slot` (F10, F11 or F12)"))?;
let k = raw.trim().trim_start_matches("Shift+").to_ascii_uppercase();
if !matches!(k.as_str(), "F10" | "F11" | "F12") {
return Err(bad(cmd, "slot: F10, F11 or F12"));
}
Ok(if shift { format!("Shift+{k}") } else { k })
}
/// Bind `keys` to the bindable `key`, taking it from whatever had it.
fn reassign(s: &mut Session, key: &str, slot: &str, cmd: &str) -> Result<Value> {
let t = s.shortcuts();
let b = t.find(key).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut")))?;
let holders: Vec<(crate::shortcuts::Bindable, Vec<String>)> = t
.bindables
.iter()
.filter(|x| x.key != key && t.keys.get(&x.key).is_some_and(|k| k.iter().any(|k| k == slot)))
.map(|x| (x.clone(), t.keys.get(&x.key).cloned().unwrap_or_default()))
.collect();
let mine = t.keys.get(key).cloned().unwrap_or_default();
let replaced: Vec<String> = holders.iter().map(|(h, _)| h.label.clone()).collect();
for (h, keys) in &holders {
let rest: Vec<String> = keys.iter().filter(|k| *k != slot).cloned().collect();
s.keymaps.set(h, &rest).map_err(|e| bad(cmd, e))?;
}
let mut keys = vec![slot.to_string()];
keys.extend(mine.into_iter().filter(|k| k != slot));
let created = s.keymaps.set(&b, &keys).map_err(|e| bad(cmd, e))?;
s.shortcuts_changed();
if let Some(n) = &created {
s.toast(format!("The default shortcuts can't be changed: created the preset \"{n}\""));
}
Ok(json!({"command": key, "keys": keys, "replaced": replaced, "preset": s.keymaps.active}))
}
/// The command id of a 3D view.
pub(crate) fn view_command(v: effectcraft_render::three_d::View3D) -> &'static str {
use effectcraft_render::three_d::View3D::*;
match v {
ActiveCamera => "view.3d.activeCamera",
Default => "view.3d.default",
Front => "view.3d.front",
Left => "view.3d.left",
Top => "view.3d.top",
Back => "view.3d.back",
Right => "view.3d.right",
Bottom => "view.3d.bottom",
Custom1 => "view.3d.custom1",
Custom2 => "view.3d.custom2",
Custom3 => "view.3d.custom3",
}
}
/// View ▸ Assign Shortcut to "<current 3D view>": F10, F11 or F12 switches to the view.
fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result<Value> {
let slot = slot_p(p, "view.assign3dShortcut", false)?;
let cid = comp_id(s, p)?;
let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
reassign(s, view_command(cur), &slot, "view.assign3dShortcut")
}
/// Window ▸ Assign Shortcut to "<workspace>" Workspace: Shift+F10, Shift+F11 or Shift+F12.
/// The frontend fills in the current workspace.
fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result<Value> {
let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?;
let Some(ws) = str_p(p, "workspace") else { return super::frontend(s, "window.assignWorkspaceShortcut", p) };
let key = crate::shortcuts::binding_key("window.workspace", &json!({"name": ws}));
reassign(s, &key, &slot, "window.assignWorkspaceShortcut")
}
// ---------------------------------------------------------------- Help
fn log_file(s: &Session) -> std::path::PathBuf {
crate::logging::log_path(s.config.as_ref().and_then(|c| c.dir()).as_deref())
}
fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
let on = b_p(p, "on").unwrap_or(!crate::logging::is_enabled());
let file = log_file(s);
crate::logging::set_enabled(on, file.clone()).map_err(|e| EngineError::Other(format!("cannot write {}: {e}", file.display())))?;
s.prefs_revision += 1;
s.toast(if on { format!("Logging to {}", file.display()) } else { "Logging off".into() });
Ok(json!({"enabled": on, "path": file.to_string_lossy()}))
}
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
let path = file.to_string_lossy().to_string();
let url = super::layer_menu::folder_url(&path);
s.events.push(Event::OpenUrl(url.clone()));
Ok(json!({"path": path, "url": url}))
}
/// Media formats File ▸ Import reads (with the desktop host's decoders).
pub const IMPORT_FORMATS: &[&str] = &[
"MP4/MOV (H.264, HEVC, ProRes, AV1)",
"WebM/MKV (VP8, VP9, AV1)",
"PNG",
"JPEG",
"GIF",
"WebP",
"TIFF",
"BMP",
"OpenEXR",
"Photoshop (PSD)",
"SVG",
"WAV",
"AIFF",
"MP3",
"FLAC",
"AAC",
"Opus",
"glTF/GLB",
"OBJ",
"Lottie JSON",
];
/// Help ▸ System Compatibility Report: OS, CPU, memory, GPU adapter, import/export formats.
pub fn system_report(s: &Session) -> Value {
let mem = crate::sysinfo::memory();
let gb = |b: u64| format!("{:.1} GB", b as f64 / (1u64 << 30) as f64);
let export: Vec<&str> = s.exporter.as_ref().map(|e| e.formats().into_iter().map(|f| f.label()).collect()).unwrap_or_default();
let gpu = s.accel.as_ref().map(|a| a.name());
let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory));
let mut issues = vec![];
if gpu.is_none() {
issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string());
}
if mem.is_some_and(|m| m.total < 8 << 30) {
issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string());
}
if s.exporter.is_none() {
issues.push("Export is not available in this build.".to_string());
}
json!({
"app": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")),
"os": crate::sysinfo::os_version(),
"arch": std::env::consts::ARCH,
"cpu": crate::sysinfo::cpu_name(),
"cores": crate::sysinfo::cpu_cores(),
"memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})),
"gpu": gpu,
"caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)},
"import": IMPORT_FORMATS,
"export": export,
"logging": crate::logging::is_enabled(),
"recentWarnings": crate::logging::recent().into_iter().rev().take(20).collect::<Vec<_>>(),
"issues": issues,
})
}
/// The report as text (the dialog and `--` CLI output).
pub fn report_text(r: &Value) -> String {
let st = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("unknown").to_string();
let list = |k: &str| r.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")).unwrap_or_default();
let mut t = format!(
"{}\nOperating system: {} ({})\nCPU: {} ({} logical cores)\n",
st("app"),
st("os"),
st("arch"),
st("cpu"),
r.get("cores").and_then(Value::as_u64).unwrap_or(1)
);
match r.get("memory").filter(|m| !m.is_null()) {
Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))),
None => t.push_str("Memory: unknown\n"),
}
t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)")));
let c = &r["caches"];
t.push_str(&format!(
"Cache budgets: layer {}, footage {}, preview {}\n",
c["layer"].as_str().unwrap_or("?"),
c["footage"].as_str().unwrap_or("?"),
c["preview"].as_str().unwrap_or("?")
));
t.push_str(&format!("Import: {}\nExport: {}\n", list("import"), list("export")));
let issues = list("issues");
t.push_str(&if issues.is_empty() { "No compatibility issues found.".to_string() } else { format!("Issues: {issues}") });
t
}
fn report(s: &mut Session, p: &Value) -> Result<Value> {
let r = system_report(s);
if !b_p(p, "quiet").unwrap_or(false) {
s.events.push(Event::Frontend { command: "app.showReport".into(), params: json!({"title": "System Compatibility Report", "text": report_text(&r)}) });
}
Ok(r)
}
// ---------------------------------------------------------------- Composition ▸ VR
/// Orientation (degrees, AE order) of a camera whose +X, +Y (down) and +Z axes point along the
/// given world directions: searched over quarter turns, which is all the cube faces need.
fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] {
let mut best = ([0.0; 3], f64::MAX);
for a in 0..4 {
for b in 0..4 {
for c in 0..4 {
let o = vec3(a as f64 * 90.0, b as f64 * 90.0, c as f64 * 90.0);
let m = Mat4::orientation(o);
let err = (m.apply_vec(vec3(1.0, 0.0, 0.0)) - x).length()
+ (m.apply_vec(vec3(0.0, 1.0, 0.0)) - y).length()
+ (m.apply_vec(vec3(0.0, 0.0, 1.0)) - z).length();
if err < best.1 {
best = ([o.x, o.y, o.z], err);
}
}
}
}
best.0
}
/// Cube faces in the VR Converter's order (+X, −X, +Y, −Y, +Z, −Z): name, forward, right and up
/// in the VR convention (X right, Y up, Z front).
const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [
("Right", [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
("Left", [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
("Top", [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]),
("Bottom", [0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
("Front", [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
("Back", [0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
];
/// (column, row) of each face in the 3:2 cube map.
const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)];
/// VR world direction → After Effects world (Y down).
fn ae(v: [f64; 3]) -> Vec3 {
vec3(v[0], -v[1], v[2])
}
/// Composition ▸ VR ▸ Create VR Environment: renders the active comp's 3D scene in every
/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, with a
/// copy of the scene's layers), a 3:2 cube map of them, and an equirectangular output comp
/// (VR Converter, cube map → equirectangular). One undo step.
fn create_vr_environment(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let size = p.get("size").and_then(Value::as_u64).unwrap_or(1024).clamp(64, 8192);
let center = match p.get("position").and_then(Value::as_array) {
Some(a) => [0, 1, 2].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)),
None => [src.width as f64 / 2.0, src.height as f64 / 2.0, 0.0],
};
let fps = src.frame_rate.as_f64();
let dur = src.duration.seconds();
let r = grouped(s, "Create VR Environment", |s| {
let mut faces = vec![];
for (fname, fw, r, u) in FACES {
let fc = id_of(
&s.execute(
"comp.new",
json!({"name": format!("{name} VR {fname}"), "width": size, "height": size, "frameRate": fps, "duration": dur, "open": false}),
)?,
"comp",
)?;
let o = orientation_for_axes(ae(r), ae([-u[0], -u[1], -u[2]]), ae(fw));
let cam =
id_of(&s.execute("layer.newCamera", json!({"comp": fc, "name": format!("{fname} Camera"), "angleOfView": 90.0, "type": "oneNode"}))?, "layer")?;
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/position", "value": center}))?;
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/orientation", "value": o}))?;
// The scene's layers (not its cameras), in the scene's coordinates, under the face
// camera.
let layers: Vec<effectcraft_project::Layer> = src.layers.iter().filter(|l| !l.is_camera()).cloned().collect();
s.edit("Create VR Environment", None, |proj, _| {
let mut next = proj.next_id;
let mut map = std::collections::BTreeMap::new();
let mut copies = vec![];
for l in &layers {
let mut c = l.clone();
super::edit::reid(&mut c, &mut next);
map.insert(l.id, c.id);
copies.push(c);
}
for c in &mut copies {
c.parent = c.parent.and_then(|p| map.get(&p).copied());
if let Some(m) = &mut c.track_matte {
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
}
}
proj.next_id = next;
let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?;
comp.layers.extend(copies);
Ok(())
})?;
faces.push(fc);
}
let cube = id_of(
&s.execute(
"comp.new",
json!({"name": format!("{name} VR Cube Map"), "width": size * 3, "height": size * 2, "frameRate": fps, "duration": dur, "open": false}),
)?,
"comp",
)?;
for (i, fc) in faces.iter().enumerate() {
let l = id_of(&s.execute("layer.addItem", json!({"comp": cube, "item": fc, "time": 0.0}))?, "layer")?;
let (c, rr) = CELLS_3X2[i];
let pos = [(c as f64 + 0.5) * size as f64, (rr as f64 + 0.5) * size as f64];
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "transform/position", "value": pos}))?;
}
let out = id_of(
&s.execute(
"comp.new",
json!({"name": format!("{name} VR Output"), "width": size * 2, "height": size, "frameRate": fps, "duration": dur, "open": false}),
)?,
"comp",
)?;
let l = id_of(&s.execute("layer.addItem", json!({"comp": out, "item": cube, "time": 0.0}))?, "layer")?;
s.execute("prop.set", json!({"comp": out, "layer": l, "path": "transform/scale", "value": [200.0 / 3.0, 50.0, 100.0]}))?;
s.execute("effect.apply", json!({"comp": out, "layers": [l], "effect": "ec.vr.converter"}))?;
s.execute("prop.set", json!({"comp": out, "layer": l, "path": "effects/#1/sourceProjection", "value": 2}))?;
s.execute("prop.set", json!({"comp": out, "layer": l, "path": "effects/#1/targetProjection", "value": 0}))?;
Ok(json!({"faces": faces, "cubeMap": cube, "output": out}))
})?;
s.open_comp(ItemId(r["output"].as_u64().unwrap_or(0)));
Ok(r)
}
/// Composition ▸ VR ▸ Extract Cubemap: a 3:2 cube-map comp of an equirectangular comp (VR
/// Converter, equirectangular → cube map), for editing the faces. One undo step.
fn extract_cubemap(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let face = p.get("faceSize").and_then(Value::as_u64).unwrap_or((src.width / 4).max(16) as u64).clamp(16, 8192);
let (w, h) = (face * 3, face * 2);
let r = grouped(s, "Extract Cubemap", |s| {
let cube = id_of(
&s.execute(
"comp.new",
json!({"name": format!("{name} Cubemap"), "width": w, "height": h, "frameRate": src.frame_rate.as_f64(), "duration": src.duration.seconds(), "open": false}),
)?,
"comp",
)?;
let l = id_of(&s.execute("layer.addItem", json!({"comp": cube, "item": cid.0, "time": 0.0}))?, "layer")?;
let scale = [w as f64 / src.width.max(1) as f64 * 100.0, h as f64 / src.height.max(1) as f64 * 100.0, 100.0];
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "transform/scale", "value": scale}))?;
s.execute("effect.apply", json!({"comp": cube, "layers": [l], "effect": "ec.vr.converter"}))?;
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "effects/#1/sourceProjection", "value": 0}))?;
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "effects/#1/targetProjection", "value": 2}))?;
Ok(json!({"cubeMap": cube, "layer": l, "faceSize": face}))
})?;
s.open_comp(ItemId(r["cubeMap"].as_u64().unwrap_or(0)));
Ok(r)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history),
cmd!("file.importRecent", "Import Recent Footage", [], None, "{index? | path?}", has_recent_footage, import_recent),
cmd!("file.clearRecentFootage", "Clear Recent Footage", ["File", "Import Recent Footage"], None, "{}", has_recent_footage, clear_recent_footage),
cmd!("anim.applyRecentPreset", "Recent Animation Presets", [], None, "{index? | path?, layers?}", has_recent_presets, apply_recent_preset),
cmd!(
"anim.clearRecentPresets",
"Clear Recent Presets",
["Animation", "Recent Animation Presets"],
None,
"{}",
|s| if s.prefs.recent_presets.is_empty() { Err("no recent animation presets".into()) } else { Ok(()) },
clear_recent_presets
),
cmd!("file.saveCopyAsXml", "Save a Copy As XML...", ["File", "Save As"], None, "{path: .ecprojx}", always, save_copy_xml),
cmd!("file.replaceWithLayeredComp", "With Layered Comp", ["File", "Replace Footage"], None, "{item?}", has_footage, replace_with_layered_comp),
cmd!("file.executeFile", "Execute File", [], None, "{path, confirmed?}", always, execute_file),
cmd!("view.assign3dShortcut", "Assign Shortcut to 3D View", [], None, "{slot: F10|F11|F12, comp?}", has_comp, assign_view_shortcut),
cmd!(
"window.assignWorkspaceShortcut",
"Assign Shortcut to Workspace",
[],
None,
"{slot: Shift+F10|Shift+F11|Shift+F12, workspace?}",
always,
assign_workspace_shortcut
),
cmd!("help.enableLogging", "Enable Logging", ["Help"], None, "{on?}", always, enable_logging),
cmd!("help.revealLogFile", "Reveal Logging File", ["Help"], None, "{}", always, reveal_log),
cmd!("help.systemReport", "System Compatibility Report...", ["Help"], None, "{quiet?}", always, report),
cmd!(
"comp.vr.createEnvironment",
"Create VR Environment...",
["Composition", "VR"],
None,
"{comp?, size?: face pixels (1024), position?: [x,y,z]}",
has_comp,
create_vr_environment
),
cmd!("comp.vr.extractCubemap", "Extract Cubemap...", ["Composition", "VR"], None, "{comp?, faceSize?}", has_comp, extract_cubemap),
query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))),
]
}
+211
View File
@@ -0,0 +1,211 @@
//! Layer ▸ Auto-trace…: masks from a layer's alpha, a colour channel or its luminance, at the
//! current frame or over the work area (one Mask Path key per frame). The tracing is
//! `effectcraft_path::trace` (marching squares, Douglas–Peucker, Bezier fitting).
use effectcraft_color::Label;
use effectcraft_keyframe::{Interp, Keyframe};
use effectcraft_path::trace::{TraceOpts, Traced, trace};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{ItemKind, LayerSource, MaskMode, Solid, Value as KV};
use effectcraft_raster::Image;
use effectcraft_render::{RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_p, str_p};
use crate::{EngineError, Result, Session, cmd};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Channel {
Alpha,
Red,
Green,
Blue,
Luminance,
}
impl Channel {
fn from_name(s: &str) -> Option<Channel> {
Some(match s.to_ascii_lowercase().as_str() {
"alpha" | "a" => Channel::Alpha,
"red" | "r" => Channel::Red,
"green" | "g" => Channel::Green,
"blue" | "b" => Channel::Blue,
"luminance" | "luma" | "l" => Channel::Luminance,
_ => return None,
})
}
}
/// The traced scalar field of a frame (straight channel values, 0…1).
pub fn channel_field(img: &Image, ch: Channel, invert: bool) -> Vec<f32> {
img.data
.iter()
.map(|p| {
let a = p[3];
let s = |v: f32| if a > 1e-6 { v / a } else { 0.0 };
let v = match ch {
Channel::Alpha => a,
Channel::Red => s(p[0]),
Channel::Green => s(p[1]),
Channel::Blue => s(p[2]),
Channel::Luminance => 0.2126 * s(p[0]) + 0.7152 * s(p[1]) + 0.0722 * s(p[2]),
}
.clamp(0.0, 1.0);
if invert { 1.0 - v } else { v }
})
.collect()
}
/// Trace one frame: outlines in layer space.
fn trace_frame(img: &Image, offset: [f64; 2], ch: Channel, invert: bool, blur: f64, o: &TraceOpts) -> Vec<Traced> {
let src = if blur > 0.0 { effectcraft_raster::gaussian_blur(img, blur / 2.0, blur / 2.0, false) } else { img.clone() };
let field = channel_field(&src, ch, invert);
let mut out = trace(&field, img.width as usize, img.height as usize, o);
for t in &mut out {
for v in &mut t.path.vertices {
v[0] += offset[0];
v[1] += offset[1];
}
}
out
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.autoTrace";
let (cid, lid) = layer_p(s, p, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
if !layer.source.is_av() {
return Err(bad(c, "auto-trace needs a footage, solid, precomp, text or shape layer"));
}
let ch = match str_p(p, "channel") {
Some(x) => Channel::from_name(x).ok_or_else(|| bad(c, "channel: alpha|red|green|blue|luminance"))?,
None => Channel::Alpha,
};
let invert = b_p(p, "invert").unwrap_or(false);
let blur = f_p(p, "blur").unwrap_or(1.0).max(0.0);
let o = TraceOpts {
threshold: (f_p(p, "threshold").unwrap_or(50.0) / 100.0).clamp(0.0, 1.0) as f32,
tolerance: f_p(p, "tolerance").unwrap_or(1.0).max(0.01),
min_area: f_p(p, "minimumArea").unwrap_or(10.0).max(0.0),
corner_roundness: (f_p(p, "cornerRoundness").unwrap_or(50.0) / 100.0).clamp(0.0, 1.0),
};
let fr = comp.frame_rate;
let work_area = matches!(str_p(p, "timeSpan"), Some("workArea" | "work area" | "work_area"));
let times: Vec<Tick> = if work_area {
let a = comp.work_area.0.max(layer.in_point);
let b = comp.work_area.1.min(layer.out_point);
let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1)));
(fa..=fb).map(|f| fr.tick_of(f)).collect()
} else {
vec![s.time()]
};
if times.is_empty() {
return Err(bad(c, "the work area doesn't overlap the layer"));
}
// Trace every frame (in parallel).
let frames: Vec<Vec<Traced>> = {
let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default());
r.expr = s.expr.as_deref();
let r = &r;
let (project, expr) = (&s.project, s.expr.as_deref());
use rayon::prelude::*;
times
.par_iter()
.map(|t| match crate::tracking::source_frame(r, project, cid, &comp, &layer, *t, expr) {
Some((img, off)) => trace_frame(&img, off, ch, invert, blur, &o),
None => vec![],
})
.collect()
};
let n_masks = frames.iter().map(Vec::len).max().unwrap_or(0);
if n_masks == 0 {
return Err(bad(c, "nothing to trace: no pixels pass the threshold"));
}
let new_layer = b_p(p, "applyToNewLayer").unwrap_or(false);
let (sw, sh) = effectcraft_render::source_size(&s.project, &layer);
let layer_times: Vec<Tick> = times.iter().map(|t| layer.layer_time(*t)).collect();
let target = s.edit("Auto-trace", None, |proj, st| {
let target = if new_layer {
// A solid the size of the layer, in the same place, carries the masks.
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
let name = format!("Auto-traced {}", layer.name);
let sid = proj.add_item(
&name,
Label::Red,
Some(folder),
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: sw.max(1), height: sh.max(1), pixel_aspect: comp.pixel_aspect }),
);
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (sw.max(1), sh.max(1)), None);
l.start_time = layer.start_time;
l.in_point = layer.in_point;
l.out_point = layer.out_point;
l.stretch = layer.stretch;
l.parent = layer.parent;
if let (Some(dst), Some(src)) = (l.props.sub_mut("transform"), layer.transform()) {
let mut tr = src.clone();
tr.reassign_uids(&mut proj.next_id);
*dst = tr;
}
let id = l.id;
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = cm.layers.iter().position(|x| x.id == lid).unwrap_or(0);
cm.layers.insert(at, l);
id
} else {
lid
};
let mut next = proj.next_id;
let l = super::layer_mut(proj, cid, target)?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad(c, "this layer can't have masks"))?;
let base = masks.children.len();
for k in 0..n_masks {
// Depth decides the mode: outlines add, holes subtract.
let first = frames.iter().find_map(|f| f.get(k)).expect("mask");
let mode = if first.depth % 2 == 0 { MaskMode::Add } else { MaskMode::Subtract };
let mode = if k == 0 { MaskMode::Add } else { mode };
let mut g = build::mask(
&mut Ids(&mut next),
&format!("Auto-trace {}", k + 1),
first.path.clone(),
mode,
build::MASK_COLORS[(base + k) % build::MASK_COLORS.len()],
);
if frames.len() > 1 {
let path = g.get_mut("path").expect("path");
for (f, lt) in frames.iter().zip(&layer_times) {
if let Some(tr) = f.get(k) {
effectcraft_keyframe::set_key(&mut path.keys, Keyframe::new(*lt, KV::Path(tr.path.clone())));
}
}
let op = g.get_mut("opacity").expect("opacity");
if frames.iter().any(|f| f.get(k).is_none()) {
for (f, lt) in frames.iter().zip(&layer_times) {
let mut key = Keyframe::new(*lt, KV::Scalar(if f.get(k).is_some() { 100.0 } else { 0.0 }));
key.in_interp = Interp::Hold;
key.out_interp = Interp::Hold;
effectcraft_keyframe::set_key(&mut op.keys, key);
}
}
}
masks.children.push(g.into());
}
proj.next_id = next;
st.selected_layers = vec![target];
Ok(target)
})?;
Ok(json!({"layer": target.0, "masks": n_masks, "frames": times.len()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"layer.autoTrace",
"Auto-trace...",
["Layer"],
None,
"{layer?, timeSpan?: currentFrame|workArea, channel?: alpha|red|green|blue|luminance, invert?, blur? (px, 1), tolerance? (px, 1), threshold? (%, 50), minimumArea? (px, 10), cornerRoundness? (%, 50), applyToNewLayer?}",
has_layers,
run
)]
}
+344
View File
@@ -0,0 +1,344 @@
//! Content-Aware Fill (Window ▸ Content-Aware Fill, Layer ▸ New ▸ Content-Aware Fill Layer):
//! fill the transparent area of a layer (what its masks cut away) across the work area or the
//! whole layer, render the result as a PNG sequence and add it as a "Fill" layer above the
//! source, like After Effects. The algorithms are `effectcraft_raster::inpaint` (Object:
//! flow-guided propagation + PatchMatch; Surface: propagation + one synthesised fill carried by
//! the flow; Edge Blend: membrane fill). It runs as a background job (Window ▸ Progress).
use std::sync::Arc;
use effectcraft_color::Label;
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build};
use effectcraft_raster::Image;
use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate, fill_sequence};
use effectcraft_render::{EvalCtx, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::scene_detect::LayerFrames;
use super::{CommandSpec, always, b_p, bad, f_p, has_layers, layer_p, str_p};
use crate::jobs::Apply;
use crate::{EngineError, Result, Session, cmd};
/// Content-Aware Fill panel settings (serde, in [`crate::EditorState`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct FillSettings {
/// `object`, `surface` or `edgeBlend`.
pub method: String,
/// `workArea` or `entire` (the layer's duration).
pub range: String,
/// Grow the fill area by this many pixels.
pub alpha_expansion: f64,
/// `none`, `subtle`, `moderate`, `strong`.
pub lighting_correction: String,
/// Reference frame: a layer of the comp and the comp time of the frame to use.
pub reference_layer: Option<LayerId>,
pub reference_time: Option<f64>,
}
impl Default for FillSettings {
fn default() -> Self {
FillSettings {
method: "object".into(),
range: "workArea".into(),
alpha_expansion: 0.0,
lighting_correction: "none".into(),
reference_layer: None,
reference_time: None,
}
}
}
fn lighting(s: &str) -> Option<f32> {
Some(match s {
"none" | "off" => 0.0,
"subtle" => 0.35,
"moderate" => 0.65,
"strong" => 1.0,
_ => return None,
})
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let c = "contentFill.set";
let mut st = s.state.content_fill.clone();
if let Some(m) = str_p(p, "method") {
st.method = FillMethod::from_name(m).ok_or_else(|| bad(c, "method: object|surface|edgeBlend"))?.name().into();
}
if let Some(r) = str_p(p, "range") {
st.range = match r {
"workArea" | "work area" => "workArea".into(),
"entire" | "entireDuration" | "layer" => "entire".into(),
_ => return Err(bad(c, "range: workArea|entire")),
};
}
if let Some(a) = f_p(p, "alphaExpansion") {
st.alpha_expansion = a.clamp(0.0, 100.0);
}
if let Some(l) = str_p(p, "lightingCorrection") {
lighting(l).ok_or_else(|| bad(c, "lightingCorrection: none|subtle|moderate|strong"))?;
st.lighting_correction = l.into();
}
match p.get("referenceLayer") {
Some(Value::Null) => st.reference_layer = None,
Some(v) => {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
st.reference_layer = Some(super::resolve_layer(comp, v).ok_or_else(|| bad(c, format!("no layer {v}")))?);
st.reference_time = Some(f_p(p, "referenceTime").unwrap_or(s.time().seconds()));
}
None => {
if let Some(t) = f_p(p, "referenceTime") {
st.reference_time = Some(t);
}
}
}
s.state.content_fill = st.clone();
Ok(serde_json::to_value(st).unwrap_or_default())
}
/// A layer's masked source at comp time `t` on a `w × h` canvas in layer pixels (straight colour
/// with alpha 1 where kept), and the hole (`true` where the alpha is under one half).
fn masked_frame(r: &Renderer, ctx: &EvalCtx, layer: &effectcraft_project::Layer, w: u32, h: u32) -> (Image, Vec<bool>) {
let mut img = Image::new(w, h);
let mut hole = vec![true; (w * h) as usize];
if let Some(buf) = r.layer_input(ctx, layer, 0) {
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
for y in 0..h {
for x in 0..w {
let (bx, by) = ((x as f64 + 0.5) * sc - buf.offset[0], (y as f64 + 0.5) * sc - buf.offset[1]);
let p = if (sc - 1.0).abs() < 1e-9 { buf.img.get(bx.floor() as i64, by.floor() as i64) } else { buf.img.sample_bilinear(bx, by) };
let i = (y * w + x) as usize;
if p[3] >= 0.5 {
img.data[i] = [p[0] / p[3], p[1] / p[3], p[2] / p[3], 1.0];
hole[i] = false;
}
}
}
}
(img, hole)
}
/// Encode a straight-alpha frame (alpha 1) as 8-bit PNG.
fn png(img: &Image) -> std::result::Result<Vec<u8>, String> {
let rgba: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2], p[3]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
super::comp_more::encode_png(&rgba, img.width, img.height)
}
fn default_dir(s: &Session, name: &str) -> String {
let base = s
.path
.as_deref()
.and_then(|p| std::path::Path::new(p).parent().map(|d| d.join("Fill")))
.unwrap_or_else(|| std::env::temp_dir().join("effectcraft-fill"));
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
let mut k = 1;
loop {
let d = base.join(format!("{safe}_Fill_{k}"));
if !d.exists() {
return d.to_string_lossy().to_string();
}
k += 1;
}
}
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
let c = "contentFill.generate";
let (cid, lid) = layer_p(s, p, c)?;
// Parameters override the panel settings.
let mut st = s.state.content_fill.clone();
{
let saved = s.state.content_fill.clone();
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.retain(|k, _| ["method", "range", "alphaExpansion", "lightingCorrection", "referenceLayer", "referenceTime"].contains(&k.as_str()));
}
set(s, &q)?;
std::mem::swap(&mut st, &mut s.state.content_fill);
s.state.content_fill = saved;
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
if !layer.source.is_av() || layer.source.item().is_none() {
return Err(bad(c, "Content-Aware Fill needs a footage, solid or precomp layer"));
}
let (w, h) = effectcraft_render::source_size(&s.project, &layer);
if w == 0 || h == 0 {
return Err(bad(c, "the layer has no pixels"));
}
let fr = comp.frame_rate;
let (a, b) =
if st.range == "entire" { (layer.in_point, layer.out_point) } else { (comp.work_area.0.max(layer.in_point), comp.work_area.1.min(layer.out_point)) };
let (a, b) = (a.max(Tick::ZERO), b.min(comp.duration));
if b <= a {
return Err(bad(c, "the range doesn't overlap the layer"));
}
let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1)));
let times: Vec<Tick> = (fa..=fb).map(|f| fr.tick_of(f)).collect();
let opts = FillOpts {
method: FillMethod::from_name(&st.method).unwrap_or_default(),
lighting: lighting(&st.lighting_correction).unwrap_or(0.0),
..Default::default()
};
let expand = st.alpha_expansion.round().max(0.0) as usize;
let reference = st.reference_layer.filter(|r| comp.layer(*r).is_some()).map(|r| (r, Tick::from_seconds_f64(st.reference_time.unwrap_or(0.0))));
let dir = str_p(p, "outputDir").map(str::to_string).unwrap_or_else(|| default_dir(s, &layer.name));
let lf = LayerFrames::new(s, cid, lid);
let services = s.services.clone();
let importer = s.importer.clone();
let wait = b_p(p, "wait").unwrap_or(false);
let name = layer.name.clone();
s.spawn_task("contentFill", format!("Content-Aware Fill: {name}"), wait, move |ctl| {
ctl.message("Reading frames");
let project = lf.project.clone();
let comp = project.comp(cid).ok_or("composition missing")?;
let layer = comp.layer(lid).ok_or("layer missing")?;
let mut r = Renderer::new(&project, lf.footage.as_ref(), RenderOpts::default());
r.expr = lf.expr.as_deref();
r.cache = Some(&lf.cache);
let ctx0 = EvalCtx { expr: lf.expr.as_deref(), ..EvalCtx::new(&project, cid, comp, Tick::ZERO) };
let n = times.len() as u64;
let mut frames = Vec::with_capacity(times.len());
let mut holes = Vec::with_capacity(times.len());
for (k, t) in times.iter().enumerate() {
let (img, hole) = masked_frame(&r, &ctx0.at(*t), layer, w, h);
holes.push(dilate(&hole, w as usize, h as usize, expand));
frames.push(img);
if !ctl.progress(k as u64 + 1, n * 3) {
return Err(crate::render_queue::CANCELLED.into());
}
}
if !holes.iter().any(|h| h.iter().any(|b| *b)) {
return Err("the layer has no transparent area to fill: draw a mask (Subtract) around what to remove".into());
}
let mut references = vec![];
if let Some((rl, rt)) = reference
&& let Some(l) = comp.layer(rl)
{
let (img, hole) = masked_frame(&r, &ctx0.at(rt), l, w, h);
if hole.iter().all(|b| !*b) {
let k = times.iter().enumerate().min_by_key(|(_, t)| (t.0 - rt.0).abs()).map(|(k, _)| k).unwrap_or(0);
references.push((k, img));
}
}
ctl.message("Filling");
let input = FillInput { frames, holes, references };
let out = fill_sequence(&input, &opts, &mut |d, t| ctl.progress(n + (d as u64 * n / t.max(1) as u64), n * 3)).ok_or(crate::render_queue::CANCELLED)?;
ctl.message("Writing the fill layer");
std::fs::create_dir_all(&dir).map_err(|e| format!("{dir}: {e}"))?;
let mut paths = vec![];
for (k, img) in out.iter().enumerate() {
let path = std::path::Path::new(&dir).join(format!("fill_{k:05}.png")).to_string_lossy().to_string();
services.write_file(&path, &png(img)?).map_err(|e| format!("{path}: {e}"))?;
paths.push(path);
if !ctl.progress(2 * n + k as u64 + 1, n * 3) {
return Err(crate::render_queue::CANCELLED.into());
}
}
let start = times[0];
let count = times.len();
let apply: Apply = Box::new(move |s: &mut Session| add_fill_layer(s, cid, lid, &name, paths, (w, h), start, count, importer));
Ok(apply)
})
}
/// Import the rendered sequence and put it in a layer above the source.
#[allow(clippy::too_many_arguments)]
fn add_fill_layer(
s: &mut Session,
cid: ItemId,
lid: LayerId,
name: &str,
paths: Vec<String>,
size: (u32, u32),
start: Tick,
count: usize,
importer: Option<Arc<dyn crate::Importer>>,
) -> Result<Value> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let src = comp.layer(lid).ok_or_else(|| bad("contentFill.generate", "the source layer is gone"))?.clone();
let fr = comp.frame_rate;
let dur = fr.tick_of(count as i64);
let mut footage = importer.and_then(|i| i.probe(&paths[0]).ok()).filter(|f| f.has_video).unwrap_or_else(|| Footage {
path: paths[0].clone(),
kind: FootageKind::Sequence,
width: size.0,
height: size.1,
has_video: true,
codec: "PNG".into(),
..Default::default()
});
footage.kind = FootageKind::Sequence;
footage.sequence = paths.clone();
footage.frame_rate = fr;
footage.duration = dur;
let (lname, item, layer) = s.edit("Content-Aware Fill", None, |proj, st| {
let folder = proj.folder_named("Fill").unwrap_or_else(|| proj.add_item("Fill", Label::Yellow, None, ItemKind::Folder));
let n = proj.items.values().filter(|i| i.parent == Some(folder)).count() + 1;
let lname = format!("Fill {n} [{name}]");
let iid = proj.add_item(&lname, Label::Lavender, Some(folder), ItemKind::Footage(footage));
let mut l = build::layer(proj, &comp, &lname, LayerSource::Footage { item: iid }, size, Some(dur));
l.start_time = start;
l.in_point = start;
l.out_point = (start + dur).min(comp.duration);
l.parent = src.parent;
l.switches.three_d = src.switches.three_d;
if let (Some(dst), Some(t)) = (l.props.sub_mut("transform"), src.transform()) {
let mut tr = t.clone();
tr.reassign_uids(&mut proj.next_id);
*dst = tr;
}
let id = l.id;
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = cm.layers.iter().position(|x| x.id == lid).unwrap_or(0);
cm.layers.insert(at, l);
st.selected_layers = vec![id];
Ok((lname, iid, id))
})?;
Ok(json!({"layer": layer.0, "item": item.0, "name": lname, "frames": count, "files": paths}))
}
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
// Parameters given: generate straight away (agents); else show the panel.
if p.as_object().is_some_and(|o| !o.is_empty()) {
return generate(s, p);
}
s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "contentAwareFill"}) });
Ok(json!({"frontend": "window.panel"}))
}
pub fn specs() -> Vec<CommandSpec> {
const P: &str = "{layer?, method?: object|surface|edgeBlend, range?: workArea|entire, alphaExpansion? (px), lightingCorrection?: none|subtle|moderate|strong, referenceLayer?, referenceTime? (s), outputDir?, wait?: bool}";
vec![
cmd!(
"contentFill.set",
"Content-Aware Fill Settings",
[],
None,
"{method?: object|surface|edgeBlend, range?: workArea|entire, alphaExpansion? (px), lightingCorrection?: none|subtle|moderate|strong, referenceLayer?: layer|null, referenceTime? (s)}",
always,
set
),
CommandSpec {
id: "contentFill.generate",
label: "Generate Fill Layer",
menu: &[],
shortcut: None,
params: P,
enabled: has_layers,
run: generate,
journal: true,
},
CommandSpec {
id: "layer.newContentAwareFill",
label: "Content-Aware Fill Layer...",
menu: &["Layer", "New"],
shortcut: None,
params: P,
enabled: always,
run: open_panel,
journal: true,
},
]
}
+9 -2
View File
@@ -384,6 +384,8 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
// Settings ▸ General ▸ Create Split Layers Above Original Layer.
let above = s.prefs.general.create_split_layers_above;
let new = s.edit("Split Layer", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
@@ -398,7 +400,7 @@ fn split(s: &mut Session, p: &Value) -> Result<Value> {
b.in_point = t;
comp.layers[i].out_point = t;
created.push(b.id);
comp.layers.insert(i, b);
comp.layers.insert(if above { i } else { i + 1 }, b);
}
proj.next_id = next;
st.selected_layers = created.clone();
@@ -408,6 +410,11 @@ fn split(s: &mut Session, p: &Value) -> Result<Value> {
}
fn label(s: &mut Session, p: &Value) -> Result<Value> {
// Keyframe colour labels: selected keyframes take the label (unless layers are named).
let explicit_layers = p.get("layers").is_some() || p.get("layer").is_some();
if p.get("keys").is_some() || (!s.state.selected_keys.is_empty() && !explicit_layers && str_p(p, "target") != Some("layers")) {
return super::keys_more::label_keys(s, p);
}
let name = str_p(p, "label").unwrap_or("Red");
let lab = s.prefs.label_from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?;
let (cid, ids) = layers_p(s, p)?;
@@ -624,7 +631,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all),
cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always_ok, deselect_all),
cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?}", always_ok, label),
cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?, keys?, target?: layers}", always_ok, label),
cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group),
cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge),
cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches),
+73 -5
View File
@@ -40,6 +40,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
// XML copies (File ▸ Save a Copy As XML…) open like the JSON project.
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
let proj = Project::from_json(&text)?;
s.replace_project(proj, Some(path.to_string()));
s.note_project_path(path);
@@ -48,6 +50,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
fn save_to(s: &mut Session, path: &str) -> Result<Value> {
let json = s.project.to_json();
// A project opened from an XML copy saves as XML again.
let json = if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other)? } else { json };
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.path = Some(path.to_string());
s.saved_revision = s.revision;
@@ -78,14 +82,20 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
open(s, &json!({"path": path}))
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
let paths: Vec<String> = match p.get("paths").or(p.get("path")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(Value::String(x)) => vec![x.clone()],
_ => return Err(bad("file.import", "missing `paths`")),
};
// Photoshop documents as compositions (Import As: Composition / – Retain Layer Sizes).
let import_as = str_p(p, "importAs").unwrap_or("footage");
// Files dragged in follow Settings ▸ Import ▸ Default Drag Import As.
let drag = p.get("drag").and_then(Value::as_bool).unwrap_or(false);
let import_as = str_p(p, "importAs").unwrap_or(if drag { s.prefs.drag_import_as() } else { "footage" });
for path in &paths {
s.prefs.push_recent_footage(path);
}
s.save_prefs();
let retain = match import_as {
"footage" => None,
"composition" | "comp" => Some(false),
@@ -137,6 +147,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let mut probed = vec![];
let psd_layer = p.get("layer").cloned();
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
let mut ask_alpha = vec![];
let mut warnings = vec![];
for path in &paths {
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
if let Some(f) = data_footage(s, path) {
@@ -152,6 +164,20 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
};
match importer.probe(path) {
Ok(mut f) => {
// Settings ▸ Import ▸ Report Missing Frames.
if f.kind == FootageKind::Sequence
&& s.prefs.import.report_missing_frames
&& let Some(gaps) = missing_frames(&f.sequence)
{
warnings.push(format!("{path}: {gaps}"));
}
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As.
if f.alpha != effectcraft_project::AlphaMode::Ignore && !alpha_is_labeled(path, &f.codec) {
match s.prefs.unlabeled_alpha(f.kind == FootageKind::Video) {
Some(a) => f.alpha = a,
None => ask_alpha.push(paths.iter().position(|x| x == path).unwrap_or(0)),
}
}
// Settings ▸ Import ▸ Sequence Footage frames per second.
if f.kind == FootageKind::Sequence {
let r = seq_rate;
@@ -178,6 +204,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
Err(e) => errors.push(format!("{path}: {e}")),
}
}
let mut asked: Vec<u64> = vec![];
s.edit("Import", None, |proj, st| {
for (path, f) in probed {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
@@ -191,8 +218,12 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
FootageKind::Video | FootageKind::Model => Label::Aqua,
FootageKind::Data => Label::Sandstone,
};
let ask = ask_alpha.contains(&paths.iter().position(|x| *x == path).unwrap_or(usize::MAX));
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
ids.push(id.0);
if ask {
asked.push(id.0);
}
st.project_selection = vec![id];
}
Ok(())
@@ -200,7 +231,44 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(c) = out_comps.first() {
s.open_comp(effectcraft_project::ItemId(*c));
}
Ok(json!({"items": ids, "comps": out_comps, "errors": errors}))
for w in &warnings {
s.events.push(crate::Event::Toast { message: w.clone(), error: true });
}
// Ask User: the frontend opens Interpret Footage for footage with unlabeled alpha.
if let Some(first) = asked.first() {
s.events.push(crate::Event::Frontend { command: "file.interpretFootage".into(), params: json!({"item": first, "reason": "unlabeledAlpha"}) });
}
Ok(json!({"items": ids, "comps": out_comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked}))
}
/// Whether a file format states how its alpha is stored (PNG, GIF, WebP and PSD are straight by
/// definition, OpenEXR premultiplied); TGA, TIFF and movies with alpha don't.
fn alpha_is_labeled(path: &str, codec: &str) -> bool {
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
matches!(ext.as_str(), "png" | "gif" | "webp" | "psd" | "psb" | "exr" | "svg" | "jpg" | "jpeg" | "ai" | "pdf" | "eps") || codec.eq_ignore_ascii_case("PSD")
}
/// Gaps in an image sequence's frame numbers ("missing frames 4–6, 9"), from the digit run at the
/// end of each file name. `None` when the numbering is continuous (or there is none).
pub(crate) fn missing_frames(files: &[String]) -> Option<String> {
let num = |f: &String| -> Option<i64> {
let stem = std::path::Path::new(f).file_stem()?.to_string_lossy().to_string();
let digits: String = stem.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
digits.parse().ok()
};
let mut ns: Vec<i64> = files.iter().filter_map(num).collect();
ns.sort_unstable();
ns.dedup();
let mut gaps = vec![];
let mut total = 0;
for w in ns.windows(2) {
if w[1] - w[0] > 1 {
let (a, b) = (w[0] + 1, w[1] - 1);
total += b - a + 1;
gaps.push(if a == b { a.to_string() } else { format!("{a}–{b}") });
}
}
(!gaps.is_empty()).then(|| format!("{total} missing frame{} ({})", if total == 1 { "" } else { "s" }, gaps.join(", ")))
}
/// A Photoshop layer by index or name (pixel layers only).
@@ -214,7 +282,7 @@ fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, Strin
}
/// Import a Photoshop document as a composition (one undo step). Returns (comp, items, warnings).
fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retain: bool) -> Result<(u64, Vec<u64>, Vec<String>)> {
pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retain: bool) -> Result<(u64, Vec<u64>, Vec<String>)> {
let psd = effectcraft_psd::Psd::parse(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Photoshop".into());
let rate = effectcraft_time::FrameRate::FPS_29_97;
@@ -410,7 +478,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer)}",
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
always,
import
),
+224
View File
@@ -0,0 +1,224 @@
//! The Footage panel (double-click footage in the Project panel): plays a footage item on its own
//! time ruler, marks In and Out points in source time, and edits the marked range into the active
//! composition with **Overlay Edit** (a new layer at the current time, on top) or **Ripple Insert
//! Edit** (the same, after pushing later layers — and the later part of layers crossing the
//! current time — back by the clip's duration).
use effectcraft_project::{ItemId, ItemKind, LayerSource, build};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p};
use crate::{EngineError, Result, Session, cmd};
/// What the Footage panel shows (source time).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FootageView {
pub item: ItemId,
pub time: Tick,
#[serde(default)]
pub in_point: Option<Tick>,
#[serde(default)]
pub out_point: Option<Tick>,
}
fn footage_item(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
let id = match p.get("item") {
Some(Value::Number(n)) => n.as_u64().map(ItemId),
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id),
_ => s.state.footage_panel.as_ref().map(|f| f.item).or_else(|| s.state.project_selection.first().copied()),
}
.ok_or_else(|| bad(cmd, "no footage: pass `item` or open one in the Footage panel"))?;
match s.project.item(id).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Data => Ok(id),
Some(ItemKind::Solid(_)) => Ok(id),
_ => Err(bad(cmd, format!("item {} is not footage", id.0))),
}
}
/// Source duration and frame rate of a footage / solid item (stills: `None`).
fn source_timing(s: &Session, item: ItemId) -> (Option<Tick>, effectcraft_time::FrameRate) {
match s.project.item(item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still && f.duration > Tick::ZERO => (Some(f.duration), f.frame_rate),
Some(ItemKind::Footage(f)) => (None, f.frame_rate),
_ => (None, s.active_comp().map(|c| c.frame_rate).unwrap_or(effectcraft_time::FrameRate::FPS_30)),
}
}
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
let (_, fr) = source_timing(s, item);
if let Some(t) = f_p(p, "time") {
return Some(fr.snap_nearest(Tick::from_seconds_f64(t.max(0.0))));
}
p.get("frame").and_then(Value::as_i64).map(|f| fr.tick_of(f.max(0)))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let item = footage_item(s, p, "footage.open")?;
if s.state.footage_panel.as_ref().is_none_or(|f| f.item != item) {
s.state.footage_panel = Some(FootageView { item, time: Tick::ZERO, in_point: None, out_point: None });
}
s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "footage"}) });
Ok(json!(s.state.footage_panel))
}
fn view_mut<'a>(s: &'a mut Session, cmd: &str) -> Result<&'a mut FootageView> {
s.state.footage_panel.as_mut().ok_or_else(|| bad(cmd, "the Footage panel shows no footage (footage.open)"))
}
fn clamp_time(s: &Session, item: ItemId, t: Tick) -> Tick {
let (dur, fr) = source_timing(s, item);
match dur {
Some(d) => t.clamp(Tick::ZERO, (d - fr.frame_duration()).max(Tick::ZERO)),
None => t.max(Tick::ZERO),
}
}
fn set_time(s: &mut Session, p: &Value) -> Result<Value> {
let item = view_mut(s, "footage.setTime")?.item;
let t = source_time(s, p, item).ok_or_else(|| bad("footage.setTime", "missing `time` or `frame`"))?;
let t = clamp_time(s, item, t);
view_mut(s, "footage.setTime")?.time = t;
Ok(json!(t.seconds()))
}
fn set_mark(s: &mut Session, p: &Value, out: bool) -> Result<Value> {
let c = if out { "footage.setOut" } else { "footage.setIn" };
let v = view_mut(s, c)?.clone();
let t = source_time(s, p, v.item).unwrap_or(v.time);
let t = clamp_time(s, v.item, t);
let (_, fr) = source_timing(s, v.item);
let view = view_mut(s, c)?;
if out {
// The Out point marks the last frame shown: the range ends after it.
let end = t + fr.frame_duration();
view.out_point = Some(end);
if view.in_point.is_some_and(|i| i >= end) {
view.in_point = None;
}
} else {
view.in_point = Some(t);
if view.out_point.is_some_and(|o| o <= t) {
view.out_point = None;
}
}
Ok(json!(s.state.footage_panel))
}
fn clear_marks(s: &mut Session, _: &Value) -> Result<Value> {
let v = view_mut(s, "footage.clearInOut")?;
v.in_point = None;
v.out_point = None;
Ok(Value::Null)
}
/// Overlay / Ripple Insert Edit of the marked range at the comp's current time.
fn edit_into_comp(s: &mut Session, p: &Value, ripple: bool) -> Result<Value> {
let c = if ripple { "footage.rippleInsertEdit" } else { "footage.overlayEdit" };
let item = footage_item(s, p, c)?;
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let view = s.state.footage_panel.clone().filter(|v| v.item == item);
let (dur, _) = source_timing(s, item);
let src_in = f_p(p, "in").map(Tick::from_seconds_f64).or(view.as_ref().and_then(|v| v.in_point)).unwrap_or(Tick::ZERO);
let fr = comp.frame_rate;
let at = fr.snap_nearest(f_p(p, "at").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid))).max(Tick::ZERO);
let src_out = f_p(p, "out").map(Tick::from_seconds_f64).or(view.as_ref().and_then(|v| v.out_point)).or(dur).unwrap_or(src_in + (comp.duration - at));
if src_out <= src_in {
return Err(bad(c, "the Out point must be after the In point"));
}
let len = fr.snap_nearest(src_out - src_in).max(fr.frame_duration());
if at >= comp.duration {
return Err(bad(c, "the current time is past the end of the composition"));
}
let it = s.project.item(item).cloned().ok_or_else(|| bad(c, "no such item"))?;
let (src, size) = match &it.kind {
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height)),
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height)),
_ => return Err(bad(c, "not footage")),
};
let label = if ripple { "Ripple Insert Edit" } else { "Overlay Edit" };
let id = s.edit(label, None, |proj, st| {
let mut next = proj.next_id;
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
if ripple {
let mut added = vec![];
for i in 0..cm.layers.len() {
let l = &mut cm.layers[i];
if l.in_point >= at {
l.start_time += len;
l.in_point += len;
l.out_point += len;
} else if l.out_point > at {
// Split: the part after the current time moves back.
let mut b = l.clone();
super::edit::reid(&mut b, &mut next);
l.out_point = at;
b.in_point = at + len;
b.out_point += len;
b.start_time += len;
added.push((i, b));
}
}
for (i, b) in added.into_iter().rev() {
cm.layers.insert(i, b);
}
}
proj.next_id = next;
let mut l = build::layer(proj, &comp, &it.name, src, size, Some(len));
l.start_time = at - src_in;
l.in_point = at;
l.out_point = (at + len).min(comp.duration);
let lid = l.id;
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
cm.layers.insert(0, l);
st.selected_layers = vec![lid];
st.selected_props.clear();
st.selected_keys.clear();
Ok(lid)
})?;
Ok(json!({"layer": id.0, "in": at.seconds(), "duration": len.seconds()}))
}
fn has_view_or_footage(s: &Session) -> std::result::Result<(), String> {
if s.state.footage_panel.is_some() { Ok(()) } else { Err("open footage in the Footage panel first".into()) }
}
fn can_edit(s: &Session) -> std::result::Result<(), String> {
has_view_or_footage(s)?;
super::has_comp(s)
}
fn info(s: &mut Session, _: &Value) -> Result<Value> {
let Some(v) = &s.state.footage_panel else { return Ok(Value::Null) };
let (dur, fr) = source_timing(s, v.item);
let name = s.project.item(v.item).map(|i| i.name.clone()).unwrap_or_default();
Ok(
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": fr.frame_at(v.time), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("footage.open", "Open in Footage Panel", [], None, "{item: id|name}", always, open),
crate::query!("footage.info", "Footage Panel State", "{}", info),
cmd!("footage.setTime", "Footage Panel Time", [], None, "{time? (s, source) | frame?}", has_view_or_footage, set_time),
cmd!("footage.setIn", "Set In Point", [], None, "{time? | frame? (default: the panel's time)}", has_view_or_footage, |s, p| set_mark(s, p, false)),
cmd!("footage.setOut", "Set Out Point", [], None, "{time? | frame? (default: the panel's time)}", has_view_or_footage, |s, p| set_mark(s, p, true)),
cmd!("footage.clearInOut", "Clear In and Out", [], None, "{}", has_view_or_footage, clear_marks),
cmd!("footage.overlayEdit", "Overlay Edit", [], None, "{item?, comp?, in? (s), out? (s), at? (comp s; default the current time)}", can_edit, |s, p| {
edit_into_comp(s, p, false)
}),
cmd!(
"footage.rippleInsertEdit",
"Ripple Insert Edit",
[],
None,
"{item?, comp?, in? (s), out? (s), at? (comp s; default the current time)}",
can_edit,
|s, p| edit_into_comp(s, p, true)
),
]
}
+1 -1
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show Panel",
[],
None,
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools}",
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill}",
always
),
fe!(
+251
View File
@@ -0,0 +1,251 @@
//! Keyframe features of M14.5: Animation ▸ Keyframe Assistant ▸ Convert Audio to Keyframes and
//! RPF Camera Import, and Edit ▸ Label on selected keyframes.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::LayerId;
use effectcraft_project::build::Ids;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::app_more::grouped;
use super::{CommandSpec, bad, comp_id, has_comp, layer_mut, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd};
// ---------------------------------------------------------------- keyframe labels
/// The label index (0 = None, 1–16) of a label name (built-in or renamed in Settings ▸ Labels).
fn label_index(s: &Session, name: &str) -> Option<u8> {
let l = if name.eq_ignore_ascii_case("none") { Label::None } else { s.prefs.label_from_name(name)? };
Label::ALL.iter().position(|x| *x == l).map(|i| i as u8)
}
fn keys_p(s: &Session, p: &Value) -> Vec<KeyRef> {
match p.get("keys") {
Some(Value::Array(a)) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
_ => s.state.selected_keys.clone(),
}
}
/// Edit ▸ Label on keyframes: colour the selected (or given) keyframes (label names as renamed
/// in Settings ▸ Labels; `keys.setLabel` is the Timeline context menu's command).
pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "label").unwrap_or("Red");
let idx = label_index(s, name).ok_or_else(|| bad("keys.label", format!("unknown label `{name}`")))?;
let keys = keys_p(s, p);
if keys.is_empty() {
return Err(bad("keys.label", "select keyframes first"));
}
let cid = comp_id(s, p)?;
let n = s.edit("Keyframe Label", None, |proj, _| {
let mut n = 0;
for k in &keys {
let Ok(l) = layer_mut(proj, cid, k.layer) else { continue };
let Some(pr) = l.props.find_mut(k.prop) else { continue };
for key in pr.keys.iter_mut().filter(|x| x.time == k.time) {
key.label = idx;
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"keys": n, "label": idx}))
}
// ---------------------------------------------------------------- Convert Audio to Keyframes
/// Per-frame amplitude of interleaved stereo: (left, right, both), mean absolute sample × 100.
pub fn amplitudes(buf: &[f32]) -> (f64, f64, f64) {
let n = (buf.len() / 2).max(1) as f64;
let (mut l, mut r) = (0.0f64, 0.0f64);
for c in buf.chunks_exact(2) {
l += c[0].abs() as f64;
r += c[1].abs() as f64;
}
let (l, r) = (l / n * 100.0, r / n * 100.0);
(l, r, (l + r) / 2.0)
}
/// Animation ▸ Keyframe Assistant ▸ Convert Audio to Keyframes: an "Audio Amplitude" null with
/// Left Channel, Right Channel and Both Channels sliders keyed on every frame of the work area
/// with the amplitude of the comp's audible layers. One undo step.
fn audio_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let fr = comp.frame_rate;
let (a, b) = comp.work_area;
let (f0, f1) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(fr.snap_nearest(b)).max(fr.frame_at(fr.snap_nearest(a)) + 1));
const RATE: u32 = 48_000;
let per = ((RATE as f64 / fr.as_f64()).round() as usize).max(1);
let mut rows = vec![];
for f in f0..f1 {
let t = fr.tick_of(f);
let buf = effectcraft_render::audio::mix_comp(&s.project, s.footage.as_ref(), s.expr.as_deref(), cid, t, per, RATE);
rows.push((t, amplitudes(&buf)));
}
let spec = effectcraft_effects::lookup("ec.control.slider").ok_or_else(|| EngineError::Other("Slider Control is missing".into()))?;
let peak = rows.iter().map(|(_, (_, _, both))| *both).fold(0.0, f64::max);
let r = grouped(s, "Convert Audio to Keyframes", |s| {
let nl = s.execute("layer.newNull", json!({"comp": cid.0, "name": "Audio Amplitude"}))?["layer"].as_u64().unwrap_or(0);
let lid = LayerId(nl);
s.edit("Convert Audio to Keyframes", None, |proj, st| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let start = l.start_time;
let fx = l.props.sub_mut("effects").ok_or_else(|| bad("keys.audioToKeyframes", "the null has no effects group"))?;
for (i, name) in ["Left Channel", "Right Channel", "Both Channels"].into_iter().enumerate() {
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), name, [comp.width as f64, comp.height as f64]);
if let Some(pr) = g.get_mut("slider") {
pr.keys = rows
.iter()
.map(|(t, amp)| {
let v = [amp.0, amp.1, amp.2][i];
Keyframe::new(*t - start, KV::Scalar((v * 100.0).round() / 100.0))
})
.collect();
}
fx.children.push(g.into());
}
proj.next_id = next;
st.selected_layers = vec![lid];
Ok(())
})?;
Ok(json!({"layer": nl, "frames": rows.len(), "peak": peak}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- RPF Camera Import
/// One camera sample: time, position, orientation (degrees, X/Y/Z) and zoom (px).
#[derive(Clone, Debug, PartialEq)]
pub struct CamSample {
pub time: Tick,
pub position: [f64; 3],
pub orientation: [f64; 3],
pub zoom: Option<f64>,
}
fn zoom_of(fov: Option<f64>, zoom: Option<f64>, comp_w: f64) -> Option<f64> {
zoom.or_else(|| fov.map(|a| comp_w * 0.5 / (a.clamp(0.1, 179.0).to_radians() * 0.5).tan()))
}
/// Parse camera data: JSON `{frameRate?, frames: [{frame | time, position, orientation |
/// rotation, zoom? | fov?}]}` or CSV with a header naming `frame`/`time`, `px py pz`
/// (or `x y z`), `rx ry rz` and `zoom`/`fov` columns.
pub fn parse_camera(text: &str, rate: effectcraft_time::FrameRate, comp_w: f64) -> std::result::Result<Vec<CamSample>, String> {
let t = text.trim_start();
if t.starts_with('{') || t.starts_with('[') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("camera JSON: {e}"))?;
let rate = v.get("frameRate").and_then(Value::as_f64).map(effectcraft_time::FrameRate::from_f64).unwrap_or(rate);
let frames = v.get("frames").or(Some(&v)).and_then(Value::as_array).ok_or("camera JSON: expected `frames`")?;
let v3 = |x: Option<&Value>| -> Option<[f64; 3]> {
let a = x?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
};
return frames
.iter()
.enumerate()
.map(|(i, f)| {
let time = match (f.get("time").and_then(Value::as_f64), f.get("frame").and_then(Value::as_i64)) {
(Some(s), _) => Tick::from_seconds_f64(s),
(None, Some(n)) => rate.tick_of(n),
_ => rate.tick_of(i as i64),
};
Ok(CamSample {
time,
position: v3(f.get("position")).ok_or(format!("camera JSON: frame {i} needs `position` [x,y,z]"))?,
orientation: v3(f.get("orientation").or(f.get("rotation"))).unwrap_or([0.0; 3]),
zoom: zoom_of(f.get("fov").and_then(Value::as_f64), f.get("zoom").and_then(Value::as_f64), comp_w),
})
})
.collect();
}
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#'));
let head: Vec<String> = lines.next().ok_or("camera CSV: empty")?.split([',', ';', '\t']).map(|h| h.trim().to_ascii_lowercase()).collect();
let col = |names: &[&str]| head.iter().position(|h| names.contains(&h.as_str()));
let (cf, ct) = (col(&["frame", "f"]), col(&["time", "seconds", "t"]));
let pos = [col(&["px", "x", "posx", "position x"]), col(&["py", "y", "posy", "position y"]), col(&["pz", "z", "posz", "position z"])];
let rot = [col(&["rx", "rotx", "orientation x", "tilt"]), col(&["ry", "roty", "orientation y", "pan"]), col(&["rz", "rotz", "orientation z", "roll"])];
let (cz, cfov) = (col(&["zoom"]), col(&["fov", "angle", "angleofview"]));
if pos.iter().any(Option::is_none) {
return Err("camera CSV: needs px, py, pz (or x, y, z) columns".into());
}
lines
.enumerate()
.map(|(i, l)| {
let cells: Vec<f64> = l.split([',', ';', '\t']).map(|c| c.trim().parse().unwrap_or(f64::NAN)).collect();
let get = |c: Option<usize>| c.and_then(|c| cells.get(c).copied()).filter(|v| v.is_finite());
let time = match (get(ct), get(cf)) {
(Some(s), _) => Tick::from_seconds_f64(s),
(None, Some(n)) => rate.tick_of(n as i64),
_ => rate.tick_of(i as i64),
};
Ok(CamSample {
time,
position: pos.map(|c| get(c).unwrap_or(0.0)),
orientation: rot.map(|c| get(c).unwrap_or(0.0)),
zoom: zoom_of(get(cfov), get(cz), comp_w),
})
})
.collect()
}
/// Animation ▸ Keyframe Assistant ▸ RPF Camera Import: a camera layer keyed from camera data.
/// RPF/RLA files carry their camera in an undocumented block, so EffectCraft reads the same data
/// from JSON or CSV exports (see `docs/preferences.md` ▸ RPF Camera Import). One undo step.
fn rpf_camera_import(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("keys.rpfCameraImport", "missing `path` (.json or .csv camera data)"))?;
let lower = path.to_ascii_lowercase();
if lower.ends_with(".rpf") || lower.ends_with(".rla") {
return Err(bad(
"keys.rpfCameraImport",
"the camera block of RPF/RLA files is not publicly documented: export the camera from your 3D application as JSON or CSV (frame, px, py, pz, rx, ry, rz, zoom|fov)",
));
}
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let samples = parse_camera(&String::from_utf8_lossy(&bytes), comp.frame_rate, comp.width as f64).map_err(|e| bad("keys.rpfCameraImport", e))?;
if samples.is_empty() {
return Err(bad("keys.rpfCameraImport", "no camera samples"));
}
let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "RPF Camera".into());
grouped(s, "RPF Camera Import", |s| {
let cam = s.execute("layer.newCamera", json!({"comp": cid.0, "name": name, "type": "oneNode"}))?["layer"].as_u64().unwrap_or(0);
let lid = LayerId(cam);
s.edit("RPF Camera Import", None, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let start = l.start_time;
let keyed = |vals: Vec<(Tick, KV)>| -> Vec<Keyframe> { vals.into_iter().map(|(t, v)| Keyframe::new(t - start, v)).collect() };
if let Some(pr) = l.props.prop_mut("transform/position") {
pr.keys = keyed(samples.iter().map(|c| (c.time, KV::Vec3(c.position))).collect());
}
if let Some(pr) = l.props.prop_mut("transform/orientation") {
pr.keys = keyed(samples.iter().map(|c| (c.time, KV::Vec3(c.orientation))).collect());
}
if samples.iter().any(|c| c.zoom.is_some())
&& let Some(pr) = l.props.prop_mut("cameraOptions/zoom")
{
pr.keys = keyed(samples.iter().filter_map(|c| Some((c.time, KV::Scalar(c.zoom?)))).collect());
}
Ok(())
})?;
Ok(json!({"layer": cam, "keyframes": samples.len()}))
})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], None, "{comp?}", has_comp, audio_to_keys),
cmd!(
"keys.rpfCameraImport",
"RPF Camera Import",
["Animation", "Keyframe Assistant"],
None,
"{path: .json|.csv camera data, comp?}",
has_comp,
rpf_camera_import
),
]
}
+7 -1
View File
@@ -826,12 +826,13 @@ fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
_ => ShapePath::rect([cx, cy], rw, rh),
};
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let cycle = s.prefs.appearance.cycle_mask_colors;
let uid = s.edit("New Mask", None, |proj, _| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.addMask", "this layer can't have masks"))?;
let n = masks.children.len();
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, build::MASK_COLORS[n % build::MASK_COLORS.len()]);
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, crate::prefs::mask_color(cycle, n));
let uid = g.uid;
masks.children.push(g.into());
proj.next_id = next;
@@ -913,6 +914,11 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setText")?;
// The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display).
if let Some(f) = str_p(p, "font") {
s.prefs.push_recent_font(f);
s.save_prefs();
}
let t = s.time();
let range = text_range_p(p);
// Character attributes with only a caret (no selected text) set the insertion style.
+28 -23
View File
@@ -652,33 +652,38 @@ fn reveal_comp(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"item": cid.0}))
}
fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = s.time();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut errors = vec![];
for l in &comp.layers {
let mut has = vec![];
l.props.walk("", &mut |_, pr| {
if pr.has_expression() {
has.push(pr);
}
});
for pr in has {
let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default();
let err = match &s.expr {
Some(host) => {
let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
ctx.expr = Some(host.as_ref());
host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))).err()
impl Session {
/// Expressions of comp `cid` that fail at its current time: `{layer, prop, name, expression,
/// error}` (Reveal Expression Errors, the viewer's expression error banner).
pub fn expression_errors(&self, cid: effectcraft_project::ItemId) -> Vec<Value> {
let t = self.time_of(cid);
let Some(comp) = self.project.comp(cid) else { return vec![] };
let Some(host) = &self.expr else { return vec![] };
let mut errors = vec![];
for l in &comp.layers {
let mut has = vec![];
l.props.walk("", &mut |_, pr| {
if pr.has_expression() {
has.push(pr);
}
});
for pr in has {
let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default();
let mut ctx = effectcraft_render::EvalCtx::new(&self.project, cid, comp, t);
ctx.expr = Some(host.as_ref());
if let Err(e) = host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))) {
errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e}));
}
None => None,
};
if let Some(e) = err {
errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e}));
}
}
errors
}
}
fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.project.comp(cid).ok_or(EngineError::NoComp)?;
let errors = s.expression_errors(cid);
let props: Vec<Value> = errors.iter().map(|e| json!({"layer": e["layer"], "prop": e["prop"]})).collect();
frontend(s, "timeline.revealProps", &json!({"props": props}))?;
if errors.is_empty() {
+11 -2
View File
@@ -101,7 +101,7 @@ fn quote(s: &str) -> String {
}
/// The AE expression reference to property `uid` of `target` as seen from a property of `from`.
pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) -> Option<String> {
pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option<String> {
let chain = target.props.node_chain(uid)?;
let mut parts: Vec<String> = vec![];
let mut pending: Option<&str> = None;
@@ -125,6 +125,15 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) ->
}
let mut out = if from.id == target.id { String::new() } else { format!("thisComp.layer({}).", quote(&target.name)) };
let _ = comp;
if !compact {
// Match names: language-independent, like After Effects with Compact English off.
let chain = target.props.node_chain(uid)?;
let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) };
for n in chain {
out.push_str(&format!("({})", quote(n.match_id())));
}
return Some(out);
}
for (i, p) in parts.iter().enumerate() {
if i > 0 && !p.starts_with('(') {
out.push('.');
@@ -152,7 +161,7 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let from = comp.layer(lid).ok_or(EngineError::NoComp)?;
let target = comp.layer(tl).ok_or(EngineError::NoComp)?;
let r = reference(comp, from, target, tu).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
let (dm, dt) = (dims(from, me), dims(target, them));
let text = if dt == 1 && dm > 1 {
+90 -6
View File
@@ -54,9 +54,76 @@ pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&'
/// Edit the mask path of (layer, mask) at the current time.
fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<T>) -> Result<(Uid, T)> {
edit_path_counted(s, p, label, |sp| f(sp).map(|r| (r, None)))
}
/// A change of a path's vertex count, repeated on the other keyframes of an animated path when
/// Settings ▸ General ▸ Preserve Constant Vertex and Feather Point Count is on.
#[derive(Clone, Debug)]
pub(crate) enum CountOp {
/// A vertex was inserted at this index.
Insert(usize),
/// These vertices were removed.
Remove(Vec<usize>),
}
/// Apply a vertex-count change to another keyframe's path: an inserted vertex splits the
/// matching segment in half (the shape doesn't change), removed vertices go.
pub(crate) fn apply_count_op(sp: &mut ShapePath, op: &CountOp) {
match op {
CountOp::Insert(at) => {
let n = sp.vertices.len();
if n == 0 {
return;
}
if *at > 0 && split_segment(sp, at - 1, 0.5).is_some() {
return;
}
let at = (*at).min(n);
let v = sp.vertices[at.min(n - 1)];
sp.in_tangents.resize(n, [0.0; 2]);
sp.out_tangents.resize(n, [0.0; 2]);
sp.vertices.insert(at, v);
sp.in_tangents.insert(at, [0.0; 2]);
sp.out_tangents.insert(at, [0.0; 2]);
}
CountOp::Remove(idx) => {
let mut idx = idx.clone();
idx.sort_unstable();
idx.dedup();
for &i in idx.iter().rev() {
if i < sp.vertices.len() {
sp.vertices.remove(i);
if i < sp.in_tangents.len() {
sp.in_tangents.remove(i);
}
if i < sp.out_tangents.len() {
sp.out_tangents.remove(i);
}
}
}
if sp.vertices.len() < 3 {
sp.closed = false;
}
}
}
}
/// Repeat `op` on every keyframe of `pr` other than the one at `lt`.
fn sync_keys(pr: &mut effectcraft_project::Property, lt: effectcraft_time::Tick, op: &CountOp) {
for k in pr.keys.iter_mut().filter(|k| k.time != lt) {
if let KV::Path(sp) = &mut k.value {
apply_count_op(sp, op);
}
}
}
/// [`edit_path`] whose edit may change the vertex count (`f` says how).
fn edit_path_counted<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<(T, Option<CountOp>)>) -> Result<(Uid, T)> {
let (cid, lid) = layer_p(s, p, label)?;
let key = p.get("mask").cloned().ok_or_else(|| bad(label, "missing `mask` (uid, index or name)"))?;
let t = s.time();
let preserve = s.prefs.general.preserve_constant_vertex_count;
s.edit(label, merge_p(p), |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
@@ -65,11 +132,14 @@ fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha
let roto = super::paths::is_roto(g);
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) };
let r = f(&mut sp)?;
let (r, op) = f(&mut sp)?;
if roto {
super::paths::roto_smooth(&mut sp);
}
pr.set_value_at(lt, KV::Path(sp));
if let Some(op) = op.filter(|_| preserve) {
sync_keys(pr, lt, &op);
}
Ok((uid, r))
})
}
@@ -88,12 +158,13 @@ fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
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), feather: Vec::new() };
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let cycle = s.prefs.appearance.cycle_mask_colors;
let uid = s.edit("New Mask", None, |proj, st| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.new", "this layer can't have masks"))?;
let k = masks.children.len();
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, build::MASK_COLORS[k % build::MASK_COLORS.len()]);
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, crate::prefs::mask_color(cycle, k));
let uid = g.uid;
masks.children.push(g.into());
proj.next_id = next;
@@ -109,13 +180,13 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
let tout = pt(p.get("out")).unwrap_or([0.0; 2]);
let at = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
let (_, lid) = layer_p(s, p, "mask.addVertex")?;
let (uid, i) = edit_path(s, p, "mask.addVertex", |sp| {
let (uid, i) = edit_path_counted(s, p, "mask.addVertex", |sp| {
let i = at.unwrap_or(sp.vertices.len()).min(sp.vertices.len());
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)
Ok((i, Some(CountOp::Insert(i))))
})?;
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
Ok(json!(i))
@@ -197,9 +268,16 @@ fn has_vertices(s: &Session) -> std::result::Result<(), String> {
/// Apply `f` to the selected vertices' paths (each mask once).
fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapePath, &[usize])) -> Result<Value> {
edit_vertices_counted(s, p, label, false, f)
}
/// [`edit_vertices`]; `removes`: `f` deletes the given vertices (repeated on the other keyframes
/// when Preserve Constant Vertex Count is on).
fn edit_vertices_counted(s: &mut Session, p: &Value, label: &str, removes: bool, f: impl Fn(&mut ShapePath, &[usize])) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let groups = vertex_groups(s, p)?;
let t = s.time();
let preserve = removes && s.prefs.general.preserve_constant_vertex_count;
s.edit(label, merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for ((l, m), idx) in &groups {
@@ -214,6 +292,9 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP
super::paths::roto_smooth(&mut sp);
}
pr.set_value_at(lt, KV::Path(sp));
if preserve {
sync_keys(pr, lt, &CountOp::Remove(idx.clone()));
}
}
Ok(())
})?;
@@ -233,7 +314,7 @@ fn move_vertices(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
let n = edit_vertices(s, p, "Delete Mask Vertices", |sp, idx| {
let n = edit_vertices_counted(s, p, "Delete Mask Vertices", true, |sp, idx| {
let mut idx = idx.to_vec();
idx.sort_unstable();
idx.dedup();
@@ -525,7 +606,10 @@ fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize;
let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0);
let (_, lid) = layer_p(s, p, "mask.insertVertex")?;
let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?;
let (uid, i) = edit_path_counted(s, p, "Add Vertex", |sp| {
let i = split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment"))?;
Ok((i, Some(CountOp::Insert(i))))
})?;
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
Ok(json!(i))
}
+28 -2
View File
@@ -1,12 +1,16 @@
//! The command registry. Ids follow After Effects' menu structure; every menu item, panel button,
//! shortcut and viewer/timeline gesture maps to one of these.
mod align_data;
mod anim;
pub mod anim_tools;
mod animation;
mod app_more;
mod autotrace;
mod camera_cmds;
mod comp;
mod comp_more;
pub(crate) mod comp_more;
pub mod content_fill;
mod create;
mod edit;
mod effect;
@@ -15,10 +19,12 @@ pub mod expr_tools;
mod file;
pub(crate) mod file_more;
mod focus;
pub mod footage_panel;
mod frontend;
mod help;
mod key_labels;
mod key_transform;
mod keys_more;
mod layer;
mod layer_menu;
mod layer_time;
@@ -30,6 +36,7 @@ mod mask;
pub mod mask_interp;
pub(crate) mod model3d;
pub mod paint;
pub mod panels_cmds;
mod paths;
mod project_items;
mod prop;
@@ -40,6 +47,7 @@ mod query;
mod render_queue;
pub(crate) mod rig3d;
pub mod roto_cmds;
mod scene_detect;
mod settings;
mod shape_stroke;
mod stubs;
@@ -53,6 +61,13 @@ mod view;
pub mod viewer_cmds;
mod warp_cmds;
#[cfg(test)]
pub(crate) use app_more::report_text as report_text_for_tests;
pub(crate) use app_more::view_command;
#[cfg(test)]
pub(crate) use file::missing_frames as missing_frames_for_tests;
#[cfg(test)]
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
#[cfg(test)]
pub(crate) use mask::split_segment as split_segment_for_tests;
use std::sync::OnceLock;
@@ -155,9 +170,17 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(puppet::specs());
v.extend(liquify::specs());
v.extend(settings::specs());
v.extend(app_more::specs());
v.extend(keys_more::specs());
v.extend(essential::specs());
v.extend(expr_tools::specs());
v.extend(proxy::specs());
v.extend(panels_cmds::specs());
v.extend(footage_panel::specs());
v.extend(autotrace::specs());
v.extend(scene_detect::specs());
v.extend(align_data::specs());
v.extend(content_fill::specs());
v.extend(stubs::specs());
v
})
@@ -257,12 +280,15 @@ pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), Stri
if s.state.project_selection.is_empty() { Err("select an item in the Project panel first".into()) } else { Ok(()) }
}
/// Disabled-command enablement (menu entries whose implementation lands with another milestone).
/// Disabled-command enablement (menu entries whose implementation lands with another milestone;
/// none are left, kept for future stubs).
#[allow(dead_code)]
pub(crate) fn not_yet(_: &Session) -> std::result::Result<(), String> {
Err("not available yet in EffectCraft".into())
}
/// `run` of a not-yet-available command.
#[allow(dead_code)]
pub(crate) fn not_yet_run(_: &mut Session, _: &Value) -> Result<Value> {
Err(EngineError::Other("not available yet in EffectCraft".into()))
}
+214
View File
@@ -0,0 +1,214 @@
//! Commands behind the Progress, Media Browser, Metadata and Lumetri Scopes panels.
use effectcraft_project::ItemId;
use effectcraft_raster::Image;
use effectcraft_raster::scopes::{self, ColorStandard, ScopeKind, ScopeOpts};
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, f_p, str_p};
use crate::{EngineError, Result, Session, cmd, media_browser as mb, query};
// ---------------------------------------------------------------- Progress
fn jobs_list(s: &mut Session, _: &Value) -> Result<Value> {
s.poll_jobs();
Ok(json!({"running": s.jobs(), "finished": s.job_log}))
}
fn jobs_cancel(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_p(p, "job").unwrap_or("all");
Ok(json!({"cancelled": s.cancel_job(id)}))
}
fn jobs_wait(s: &mut Session, _: &Value) -> Result<Value> {
s.wait_jobs();
Ok(json!({"finished": s.job_log}))
}
// ---------------------------------------------------------------- Media Browser
fn browser_dir(s: &Session, p: &Value) -> String {
str_p(p, "path").map(str::to_string).or_else(|| s.state.media_browser.folder.clone()).unwrap_or_else(mb::home)
}
fn browser_list(s: &mut Session, p: &Value) -> Result<Value> {
let dir = browser_dir(s, p);
let only = b_p(p, "importableOnly").unwrap_or(s.state.media_browser.importable_only);
let entries = mb::list(&dir, only).map_err(EngineError::Other)?;
Ok(json!({"path": dir, "parent": mb::parent(&dir), "entries": entries, "favorites": s.state.media_browser.favorites}))
}
fn browser_enabled(_: &Session) -> std::result::Result<(), String> {
if mb::available() { Ok(()) } else { Err("the Media Browser needs the desktop app".into()) }
}
fn browser_go(s: &mut Session, p: &Value) -> Result<Value> {
let dir = match str_p(p, "path") {
Some("..") => s.state.media_browser.folder.clone().and_then(|d| mb::parent(&d)).unwrap_or_else(mb::home),
Some(d) => d.to_string(),
None => mb::home(),
};
if let Some(b) = b_p(p, "importableOnly") {
s.state.media_browser.importable_only = b;
}
// Validate before switching.
mb::list(&dir, false).map_err(EngineError::Other)?;
s.state.media_browser.folder = Some(dir.clone());
browser_list(s, &json!({"path": dir}))
}
fn browser_favorite(s: &mut Session, p: &Value, add: bool) -> Result<Value> {
let dir = browser_dir(s, p);
let favs = &mut s.state.media_browser.favorites;
favs.retain(|f| f != &dir);
if add {
favs.push(dir);
}
Ok(json!({"favorites": favs}))
}
fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
let paths = p.get("paths").or(p.get("path")).cloned().ok_or_else(|| bad("mediaBrowser.import", "missing `paths`"))?;
let r = s.execute("file.import", json!({"paths": paths}))?;
// Add to the active comp (drag into the timeline).
if b_p(p, "addToComp").unwrap_or(false)
&& s.active_comp_id().is_some()
&& let Some(items) = r.get("items").and_then(Value::as_array)
{
for it in items {
s.execute("layer.addItem", json!({"item": it}))?;
}
}
Ok(r)
}
fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.fileInfo", "missing `path`"))?;
let (created, modified, size) = mb::file_dates(path);
let mut v = json!({"path": path, "kind": mb::kind_of(path), "size": size, "created": created.map(mb::iso_date), "modified": modified.map(mb::iso_date)});
if let Some(imp) = s.importer.clone()
&& matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model"))
&& let Ok(f) = imp.probe(path)
{
v["footage"] = mb::footage_metadata(&f);
}
Ok(v)
}
// ---------------------------------------------------------------- Metadata
fn item_ref(s: &Session, p: &Value) -> Option<ItemId> {
match p.get("item") {
Some(Value::Number(n)) => n.as_u64().map(ItemId),
Some(Value::String(name)) => s.project.items.values().find(|i| &i.name == name).map(|i| i.id),
_ => s.state.project_selection.first().copied(),
}
}
fn metadata(s: &mut Session, p: &Value) -> Result<Value> {
let project = mb::project_metadata(&s.project, s.path.as_deref());
let item = match item_ref(s, p) {
Some(id) => Some(mb::item_metadata(&s.project, s.project.item(id).ok_or_else(|| bad("item.metadata", format!("no item {}", id.0)))?)),
None => None,
};
Ok(json!({"project": project, "item": item}))
}
fn project_comment(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_p(p, "comment").ok_or_else(|| bad("project.setProjectComment", "missing `comment`"))?.to_string();
s.edit("Project Comment", super::merge_p(p), |proj, _| {
proj.settings.comment = text;
Ok(())
})?;
Ok(Value::Null)
}
// ---------------------------------------------------------------- Lumetri Scopes
/// The comp frame the scopes read (the viewer's image: over the comp background), at most
/// `max_side` pixels on the long side.
pub fn scope_frame(s: &Session, comp: ItemId, t: Tick, max_side: u32) -> Option<Image> {
let c = s.project.comp(comp)?;
let long = c.width.max(c.height).max(1) as f64;
let scale = (max_side as f64 / long).min(1.0);
let mut img = s.render(comp, t, RenderOpts { scale, ..Default::default() });
let bg = c.background;
for p in &mut img.data {
let k = 1.0 - p[3].clamp(0.0, 1.0);
*p = [p[0] + bg[0] * k, p[1] + bg[1] * k, p[2] + bg[2] * k, 1.0];
}
Some(img)
}
/// Scope options from parameters.
pub fn scope_opts(p: &Value) -> std::result::Result<(ScopeKind, ScopeOpts), String> {
let kind = match str_p(p, "scope") {
Some(k) => ScopeKind::from_name(k).ok_or_else(|| format!("unknown scope `{k}`; one of {}", ScopeKind::ALL.map(|k| k.name()).join(", ")))?,
None => ScopeKind::WaveformRgb,
};
let standard = match str_p(p, "standard") {
Some(x) => ColorStandard::from_name(x).ok_or_else(|| format!("standard: rec601|rec709|rec2020, not `{x}`"))?,
None => ColorStandard::Rec709,
};
let size = f_p(p, "size").unwrap_or(256.0).clamp(16.0, 1024.0) as u32;
Ok((kind, ScopeOpts { standard, float: b_p(p, "float").unwrap_or(false), clamp: b_p(p, "clamp").unwrap_or(true), width: size, height: size }))
}
fn scopes_analyze(s: &mut Session, p: &Value) -> Result<Value> {
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = super::time_p(s, p, Some(comp));
let (kind, o) = scope_opts(p).map_err(|e| bad("scopes.analyze", e))?;
let img = scope_frame(s, cid, t, 512).ok_or(EngineError::NoComp)?;
let sc = scopes::compute(&img, kind, &o);
// Signal statistics of the frame.
let n = img.data.len().max(1) as f64;
let mut mean = [0.0f64; 3];
let (mut ymin, mut ymax, mut ysum) = (f64::MAX, f64::MIN, 0.0);
for px in &img.data {
for c in 0..3 {
mean[c] += px[c] as f64 / n;
}
let y = o.standard.ycbcr([px[0], px[1], px[2]])[0] as f64;
ymin = ymin.min(y);
ymax = ymax.max(y);
ysum += y;
}
let mut v = json!({
"scope": kind.name(),
"width": sc.width,
"height": sc.height,
"peaks": [sc.peak(0), sc.peak(1), sc.peak(2)],
"totals": [sc.total(0), sc.total(1), sc.total(2)],
"mean": mean,
"luma": {"min": ymin, "max": ymax, "mean": ysum / n},
});
if kind == ScopeKind::Histogram {
v["histogram"] = json!(scopes::histogram_bars(&sc));
}
Ok(v)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("jobs.list", "Background Jobs", "{}", jobs_list),
cmd!("jobs.cancel", "Cancel Job", [], None, "{job?: render|track|maskTrack|warp|camera|roto|task:<n>|all}", always, jobs_cancel),
query!("jobs.wait", "Wait for Background Jobs", "{}", jobs_wait),
query!("mediaBrowser.list", "List Folder", "{path?, importableOnly?}", browser_list),
cmd!("mediaBrowser.go", "Go to Folder", [], None, "{path?: folder | \"..\", importableOnly?}", browser_enabled, browser_go),
cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)),
cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)),
cmd!("mediaBrowser.import", "Import", [], None, "{paths, addToComp?}", browser_enabled, browser_import),
query!("mediaBrowser.fileInfo", "File Info", "{path}", file_info),
query!("item.metadata", "Metadata", "{item?: id|name}", metadata),
cmd!("project.setProjectComment", "Project Comment", [], None, "{comment}", always, project_comment),
query!(
"scopes.analyze",
"Lumetri Scopes",
"{comp?, time?|frame?, scope?: waveformRgb|waveformLuma|waveformYc|vectorscopeYuv|vectorscopeHls|histogram|paradeRgb|paradeYuv, standard?: rec601|rec709|rec2020, float?, clamp?, size?}",
scopes_analyze
),
]
}
+1 -1
View File
@@ -184,7 +184,7 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
enabled = Some(false);
}
// Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`).
let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid))).unwrap_or_else(|| "value".into());
let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid, true))).unwrap_or_else(|| "value".into());
let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| {
match (&text, enabled) {
(Some(t), _) if t.trim().is_empty() => pr.expr = None,
+9 -2
View File
@@ -499,8 +499,15 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
let mut item = RenderQueueItem::new(0, cid);
item.output = OutputModule::for_format(format);
item.output.output = path.clone();
let job =
crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), accel: s.accel.as_deref(), item: &item, path: &path };
let job = crate::ExportJob {
project: &s.project,
footage: s.footage.as_ref(),
expr: s.expr.as_deref(),
accel: s.accel.as_deref(),
item: &item,
path: &path,
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
s.toast(format!("Saved preview to {}", r.path));
Ok(serde_json::to_value(&r).unwrap_or_default())
+182
View File
@@ -0,0 +1,182 @@
//! Layer ▸ Scene Edit Detection…: find the cuts in a footage (or precomp) layer and Create
//! Markers, Split Layers, or Split and Precompose at them. The analysis
//! (`effectcraft_raster::cuts`: colour histograms + motion-compensated difference, adaptive peak
//! threshold) runs as a background job (Window ▸ Progress).
use std::sync::Arc;
use effectcraft_project::{ItemId, LayerId, Marker, Project};
use effectcraft_raster::cuts::{self, CutOpts};
use effectcraft_render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_p, str_p};
use crate::jobs::Apply;
use crate::{EngineError, Result, Session, cmd};
/// What a background job needs to render a layer's frames.
#[derive(Clone)]
pub(crate) struct LayerFrames {
pub project: Arc<Project>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
pub cache: Arc<LayerCache>,
pub comp: ItemId,
pub layer: LayerId,
}
impl LayerFrames {
pub fn new(s: &Session, comp: ItemId, layer: LayerId) -> LayerFrames {
LayerFrames { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), cache: s.layer_cache.clone(), comp, layer }
}
/// Run `f` with a frame getter: comp time → (layer source pixels, offset in layer space).
pub fn with<R>(&self, f: impl FnOnce(&(dyn Fn(Tick) -> Option<(Arc<effectcraft_raster::Image>, [f64; 2])> + Sync)) -> R) -> Option<R> {
let comp = self.project.comp(self.comp)?;
let layer = comp.layer(self.layer)?;
let mut r = Renderer::new(&self.project, self.footage.as_ref(), RenderOpts::default());
r.expr = self.expr.as_deref();
r.cache = Some(&self.cache);
let r = &r;
let get = |t: Tick| crate::tracking::source_frame(r, &self.project, self.comp, comp, layer, t, self.expr.as_deref());
Some(f(&get))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mode {
Markers,
Split,
SplitPrecompose,
}
/// Cut times (comp time) of the layer's frames `times` (first frame never a cut).
pub(crate) fn analyse(
lf: &LayerFrames,
times: &[Tick],
o: &CutOpts,
progress: &dyn Fn(u64, u64) -> bool,
) -> std::result::Result<(Vec<Tick>, Vec<f32>), String> {
lf.with(|get| {
let mut sigs = Vec::with_capacity(times.len());
// Signatures in batches (parallel), with progress and cancel between batches.
for (k, chunk) in times.chunks(8).enumerate() {
use rayon::prelude::*;
let batch: Vec<cuts::Signature> = chunk.par_iter().map(|t| get(*t).map(|(img, _)| cuts::signature(&img)).unwrap_or_default()).collect();
sigs.extend(batch);
if !progress(((k + 1) * 8).min(times.len()) as u64, times.len() as u64) {
return Err(crate::render_queue::CANCELLED.to_string());
}
}
let mut scores = vec![0.0f32];
for i in 1..sigs.len() {
scores.push(cuts::dissimilarity(&sigs[i - 1], &sigs[i]));
}
let cuts = cuts::detect(&scores, o);
Ok((cuts.into_iter().map(|i| times[i]).collect(), scores))
})
.ok_or_else(|| "layer missing".to_string())?
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.sceneEditDetection";
let (cid, lid) = layer_p(s, p, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
if layer.source.item().is_none() {
return Err(bad(c, "scene edit detection needs a footage or precomp layer"));
}
let mode = match str_p(p, "mode").unwrap_or("markers") {
"markers" | "createMarkers" => Mode::Markers,
"split" | "splitLayers" => Mode::Split,
"splitPrecompose" | "splitAndPrecompose" => Mode::SplitPrecompose,
x => return Err(bad(c, format!("mode: markers|split|splitPrecompose, not `{x}`"))),
};
let fr = comp.frame_rate;
let (a, b) = (fr.frame_at(fr.snap_nearest(layer.in_point.max(Tick::ZERO))), fr.frame_at(layer.out_point.min(comp.duration) - Tick(1)));
let times: Vec<Tick> = (a..=b).map(|f| fr.tick_of(f)).collect();
if times.len() < 2 {
return Err(bad(c, "the layer is too short"));
}
let o = CutOpts { threshold: f_p(p, "threshold").map(|t| (t as f32).clamp(0.01, 1.0)).unwrap_or(CutOpts::default().threshold), ..Default::default() };
let lf = LayerFrames::new(s, cid, lid);
let name = layer.name.clone();
let wait = b_p(p, "wait").unwrap_or(false);
s.spawn_task("sceneDetect", format!("Scene Edit Detection: {name}"), wait, move |ctl| {
let (cut_times, _) = analyse(&lf, &times, &o, &|d, t| ctl.progress(d, t))?;
let apply: Apply = Box::new(move |s: &mut Session| apply(s, cid, lid, mode, &cut_times));
Ok(apply)
})
}
fn apply(s: &mut Session, cid: ItemId, lid: LayerId, mode: Mode, cuts: &[Tick]) -> Result<Value> {
let secs: Vec<f64> = cuts.iter().map(|t| t.seconds()).collect();
if cuts.is_empty() {
return Ok(json!({"cuts": secs, "layers": [lid.0]}));
}
if mode == Mode::Markers {
s.edit("Scene Edit Detection", None, |proj, _| {
let l = super::layer_mut(proj, cid, lid)?;
for (i, t) in cuts.iter().enumerate() {
let lt = l.layer_time(*t);
l.markers.retain(|m| m.time != lt);
l.markers.push(Marker { time: lt, comment: format!("Scene {}", i + 2), ..Default::default() });
}
l.markers.sort_by_key(|m| m.time);
Ok(())
})?;
return Ok(json!({"cuts": secs, "layers": [lid.0]}));
}
let snap = s.project.clone();
// Split from the source layer: each piece ends at the next cut (pieces stacked like Split
// Layer: later pieces above).
let pieces = s.edit("Scene Edit Detection", None, |proj, st| {
let mut next = proj.next_id;
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let i = cm.layers.iter().position(|l| l.id == lid).ok_or(EngineError::NoComp)?;
let mut ids = vec![lid];
for t in cuts {
let cur = cm.layers.iter().position(|l| l.id == *ids.last().expect("piece")).unwrap_or(i);
let l = &mut cm.layers[cur];
if !(*t > l.in_point && *t < l.out_point) {
continue;
}
let mut b = l.clone();
super::edit::reid(&mut b, &mut next);
b.in_point = *t;
l.out_point = *t;
ids.push(b.id);
cm.layers.insert(cur, b);
}
proj.next_id = next;
st.selected_layers = ids.clone();
Ok(ids)
})?;
let mut out = pieces.clone();
if mode == Mode::SplitPrecompose {
let base = s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| l.name.clone()).unwrap_or_default();
out.clear();
for (k, id) in pieces.iter().enumerate() {
let r = s.execute(
"layer.precompose",
json!({"comp": cid.0, "layers": [id.0], "mode": "move", "adjustDuration": true, "name": format!("{base} Scene {}", k + 1)}),
)?;
let new = r.get("layer").and_then(Value::as_u64).map(LayerId).unwrap_or(*id);
out.push(new);
}
s.collapse_undo("Scene Edit Detection", &snap);
}
Ok(json!({"cuts": secs, "layers": out.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"layer.sceneEditDetection",
"Scene Edit Detection...",
["Layer"],
None,
"{layer?, mode?: markers|split|splitPrecompose, threshold? (0…1, 0.25), wait?: bool}",
has_layers,
run
)]
}
+4 -15
View File
@@ -4,8 +4,10 @@
//! always disabled. When a feature lands, delete its line here and register the real command
//! with the same id (the menu tree in `menus.rs` already points at it).
#[allow(unused_imports)]
use super::{CommandSpec, not_yet, not_yet_run};
#[allow(unused_macros)]
macro_rules! stub {
($id:literal, $label:literal, [$($m:literal),*], $params:literal) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true }
@@ -16,19 +18,6 @@ macro_rules! stub {
}
pub fn specs() -> Vec<CommandSpec> {
vec![
// Layer ▸ New (3D / content-aware fill).
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
// Time (timeline milestone).
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
// Cameras / lights / materials / 3D views (3D milestone).
// Keyframes / text / tracking.
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
// Workspaces and panels that don't exist yet.
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
]
// Every After Effects menu entry has a real command now.
vec![]
}
+320
View File
@@ -0,0 +1,320 @@
//! Background jobs: one list (Window ▸ Progress, `jobs.list`, `jobs.cancel`) over the render
//! queue, the analyses (tracker, mask tracker, Warp Stabilizer, 3D Camera Tracker, Roto Brush) and
//! the generic background tasks started here (Content-Aware Fill, Scene Edit Detection).
//!
//! A generic task runs a closure on a background thread (inline on wasm32 or when the caller
//! waits). The closure reports progress through [`TaskCtl`] and returns an [`Apply`]: a function
//! the session runs on the UI thread when [`Session::poll_jobs`] sees the task finish (that is
//! where the project is edited, as one undo step).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use serde::Serialize;
use serde_json::{Value, json};
use crate::Session;
use crate::tracking::lock;
/// Applied to the session when a task finishes successfully.
pub type Apply = Box<dyn FnOnce(&mut Session) -> crate::Result<Value> + Send>;
/// Progress reporting and cancellation for a running task.
#[derive(Default)]
pub struct TaskCtl {
cancel: AtomicBool,
state: Mutex<TaskState>,
}
#[derive(Clone, Debug, Default)]
struct TaskState {
done: u64,
total: u64,
message: String,
finished: bool,
}
impl TaskCtl {
/// Report progress; returns `false` once the task was cancelled.
pub fn progress(&self, done: u64, total: u64) -> bool {
{
let mut s = lock(&self.state);
s.done = done;
s.total = total;
}
crate::offload::report(done, total);
!self.cancelled()
}
pub fn message(&self, m: impl Into<String>) {
lock(&self.state).message = m.into();
}
pub fn cancelled(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
}
type Work = Box<dyn FnOnce(&TaskCtl) -> Result<Apply, String> + Send>;
/// A generic background task.
pub struct Task {
pub id: u64,
pub kind: &'static str,
pub label: String,
pub ctl: Arc<TaskCtl>,
started: web_time::Instant,
result: Arc<Mutex<Option<Result<Apply, String>>>>,
thread: Option<std::thread::JoinHandle<()>>,
}
/// A finished job (kept for the Progress panel and agents).
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JobRecord {
pub id: String,
pub kind: String,
pub label: String,
/// `done`, `cancelled` or `failed`.
pub status: String,
pub message: String,
pub result: Value,
pub seconds: f64,
}
/// One row of the Progress panel.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JobInfo {
/// `render`, `track`, `maskTrack`, `warp`, `camera`, `roto`, or `task:<n>`.
pub id: String,
pub kind: String,
pub label: String,
pub done: u64,
pub total: u64,
/// 0…1 (`None` when the total is not known yet).
pub fraction: Option<f64>,
pub running: bool,
pub message: String,
pub cancellable: bool,
}
fn info(id: &str, kind: &str, label: String, done: u64, total: u64, running: bool, message: String) -> JobInfo {
JobInfo {
id: id.into(),
kind: kind.into(),
label,
done,
total,
fraction: (total > 0).then(|| (done as f64 / total as f64).clamp(0.0, 1.0)),
running,
message,
cancellable: running,
}
}
impl Session {
/// Start a background task. `wait` (and wasm32) runs it to completion and applies it before
/// returning; the result is then the apply's value.
pub fn spawn_task(
&mut self,
kind: &'static str,
label: impl Into<String>,
wait: bool,
work: impl FnOnce(&TaskCtl) -> Result<Apply, String> + Send + 'static,
) -> crate::Result<Value> {
let id = self.next_task_id;
self.next_task_id += 1;
let ctl = Arc::new(TaskCtl::default());
let result: Arc<Mutex<Option<Result<Apply, String>>>> = Arc::new(Mutex::new(None));
let label = label.into();
let wait = wait || cfg!(target_arch = "wasm32");
let work: Work = Box::new(work);
let run = {
let (ctl, result) = (ctl.clone(), result.clone());
move || {
let r = work(&ctl);
*lock(&result) = Some(r);
lock(&ctl.state).finished = true;
}
};
let thread = if wait {
run();
None
} else {
Some(std::thread::Builder::new().name(format!("task-{kind}")).spawn(run).map_err(|e| crate::EngineError::Other(e.to_string()))?)
};
self.tasks.push(Task { id, kind, label, ctl, started: web_time::Instant::now(), result, thread });
if wait {
let rec = self.finish_task(self.tasks.len() - 1);
return match rec.status.as_str() {
"done" => Ok(rec.result),
_ => Err(crate::EngineError::Other(if rec.message.is_empty() { rec.status } else { rec.message })),
};
}
Ok(json!({"job": format!("task:{id}"), "started": true}))
}
fn finish_task(&mut self, i: usize) -> JobRecord {
let mut t = self.tasks.remove(i);
if let Some(th) = t.thread.take() {
let _ = th.join();
}
let r = lock(&t.result).take();
let cancelled = t.ctl.cancelled();
let (status, message, result) = match r {
Some(Ok(apply)) if !cancelled => match apply(self) {
Ok(v) => ("done", String::new(), v),
Err(e) => ("failed", e.to_string(), Value::Null),
},
Some(Ok(_)) => ("cancelled", String::new(), Value::Null),
Some(Err(e)) if cancelled || e == crate::render_queue::CANCELLED => ("cancelled", String::new(), Value::Null),
Some(Err(e)) => ("failed", e, Value::Null),
None => ("failed", "the task stopped".into(), Value::Null),
};
let rec = JobRecord {
id: format!("task:{}", t.id),
kind: t.kind.into(),
label: t.label.clone(),
status: status.into(),
message: message.clone(),
result,
seconds: t.started.elapsed().as_secs_f64(),
};
let toast = match status {
"done" => format!("{} finished", t.label),
"cancelled" => format!("{} cancelled", t.label),
_ => format!("{} failed: {message}", t.label),
};
self.events.push(crate::Event::Toast { message: toast, error: status == "failed" });
self.job_log.push(rec.clone());
if self.job_log.len() > 50 {
self.job_log.remove(0);
}
rec
}
/// Apply finished tasks. Frontends call this every frame while [`Session::tasks`] is not empty.
pub fn poll_jobs(&mut self) -> bool {
let mut any = false;
while let Some(i) = self.tasks.iter().position(|t| lock(&t.ctl.state).finished) {
self.finish_task(i);
any = true;
}
any
}
/// Wait for every running task (CLI, tests).
pub fn wait_jobs(&mut self) {
while !self.tasks.is_empty() {
if let Some(th) = self.tasks.iter_mut().find_map(|t| t.thread.take()) {
let _ = th.join();
}
self.poll_jobs();
if self.tasks.iter().all(|t| t.thread.is_none() && !lock(&t.ctl.state).finished) {
break;
}
}
}
/// Every running background job.
pub fn jobs(&self) -> Vec<JobInfo> {
let mut out = vec![];
if let Some(st) = self.render_progress()
&& !st.finished
{
let name = st
.current
.and_then(|id| self.project.render_queue.iter().find(|i| i.id == id))
.and_then(|i| self.project.comp(i.comp).map(|_| i.comp))
.and_then(|c| self.project.item(c))
.map(|i| i.name.clone());
out.push(info(
"render",
"render",
format!("Render Queue{}", name.map(|n| format!(": {n}")).unwrap_or_default()),
st.done,
st.total,
true,
format!("item {} of {}", (st.items_done + 1).min(st.items_total.max(1)), st.items_total),
));
}
if let Some(p) = self.track_progress()
&& !p.finished
{
out.push(info("track", "track", "Track Motion".into(), p.done, p.total, true, String::new()));
}
if let Some(p) = self.mask_track_progress()
&& !p.finished
{
out.push(info("maskTrack", "maskTrack", "Track Mask".into(), p.done, p.total, true, String::new()));
}
if let Some(p) = self.warp_progress()
&& !p.finished
{
out.push(info("warp", "warp", "Warp Stabilizer analysis".into(), p.done, p.total, true, format!("step {}", p.step)));
}
if let Some(p) = self.camera_progress()
&& !p.finished
{
out.push(info("camera", "camera", "3D Camera Tracker analysis".into(), p.done, p.total, true, format!("step {}", p.step)));
}
if let Some(p) = self.roto_progress()
&& !p.finished
{
out.push(info("roto", "roto", "Roto Brush propagation".into(), p.done, p.total, true, String::new()));
}
for t in &self.tasks {
let s = lock(&t.ctl.state).clone();
out.push(info(&format!("task:{}", t.id), t.kind, t.label.clone(), s.done, s.total, !s.finished, s.message));
}
out
}
/// Cancel a job by id (`render`, `track`, …, `task:<n>`; `all` cancels everything).
pub fn cancel_job(&mut self, id: &str) -> bool {
match id {
"render" => self.stop_render(),
"track" => self.stop_track(),
"maskTrack" => self.stop_mask_track(),
"warp" => self.stop_warp(),
"camera" => self.stop_camera(),
"roto" => self.stop_roto(),
"all" => {
let ids: Vec<String> = self.jobs().into_iter().map(|j| j.id).collect();
ids.iter().fold(false, |a, j| self.cancel_job(j) | a)
}
_ => {
let Some(n) = id.strip_prefix("task:").and_then(|n| n.parse::<u64>().ok()) else { return false };
match self.tasks.iter().find(|t| t.id == n) {
Some(t) => {
t.ctl.cancel.store(true, Ordering::Relaxed);
true
}
None => false,
}
}
}
}
/// Fold the undo steps recorded since the project was `snap` into one step called `name`
/// (commands built from several commands).
pub fn collapse_undo(&mut self, name: &str, snap: &Arc<effectcraft_project::Project>) {
let h = &mut self.history;
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { return };
let first = h.undo[start].1.clone();
h.undo.truncate(start);
h.undo.push((name.to_string(), first));
h.merge_key = None;
}
/// Cancel and drop every task (new/open project).
pub(crate) fn drop_tasks(&mut self) {
for t in &self.tasks {
t.ctl.cancel.store(true, Ordering::Relaxed);
}
for mut t in std::mem::take(&mut self.tasks) {
if let Some(th) = t.thread.take() {
let _ = th.join();
}
}
}
}
+140
View File
@@ -13,8 +13,12 @@ pub mod camera_track;
pub mod commands;
pub mod config;
pub mod demo;
pub mod jobs;
pub mod links;
pub mod logging;
pub mod mask_track;
pub mod media_browser;
pub mod media_cache;
pub mod menus;
pub mod offload;
pub mod prefs;
@@ -23,10 +27,12 @@ pub mod render_queue;
pub mod roto;
mod session_settings;
pub mod shortcuts;
pub mod sysinfo;
pub mod tracking;
pub mod vector;
pub mod viewer;
pub mod warp;
pub mod xml_project;
use std::sync::Arc;
@@ -43,6 +49,7 @@ pub use effectcraft_effects as effects;
pub use effectcraft_geom as geom;
pub use effectcraft_keyframe as keyframe;
pub use effectcraft_project as project;
pub use effectcraft_raster as raster;
pub use effectcraft_render as render;
pub use effectcraft_text as text;
pub use effectcraft_time as time;
@@ -216,6 +223,15 @@ pub struct EditorState {
/// Essential Graphics can expose).
#[serde(default)]
pub essential_solo: bool,
/// The Footage panel: the footage shown, its time and In/Out marks (source time).
#[serde(default)]
pub footage_panel: Option<commands::footage_panel::FootageView>,
/// Media Browser: the folder shown and the favourites.
#[serde(default)]
pub media_browser: media_browser::BrowserState,
/// Content-Aware Fill panel settings.
#[serde(default)]
pub content_fill: commands::content_fill::FillSettings,
/// View ▸ Split with New Locked Viewer: the second viewer's comp and 3D view.
#[serde(default)]
pub locked_viewer: Option<commands::viewer_cmds::LockedViewer>,
@@ -320,11 +336,21 @@ 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>,
/// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown
/// (headless sessions don't query it, see [`Session::memory_tick`]).
pub sys_memory: Option<sysinfo::SysMemory>,
/// Switches Affect Nested Comps as last applied (a change empties the layer cache).
pub applied_nested_switches: Option<bool>,
/// 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>,
/// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`].
pub tasks: Vec<jobs::Task>,
/// Finished tasks, newest last (Progress panel, `jobs.list`).
pub job_log: Vec<jobs::JobRecord>,
pub next_task_id: u64,
}
/// A script to run (see [`Session::script`]).
@@ -379,8 +405,13 @@ impl Default for Session {
autosave: autosave::AutoSaveState::default(),
snapshot: None,
script: None,
sys_memory: None,
applied_nested_switches: None,
offload: None,
offloaded: vec![],
tasks: vec![],
job_log: vec![],
next_task_id: 1,
}
}
}
@@ -548,6 +579,47 @@ impl Session {
None => t,
};
self.state.times.insert(c, t);
if self.prefs.general.sync_time_related_items {
self.sync_related_times(c, t);
}
}
}
/// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of
/// `comp` moves the current time of the comps nested in it (through their precomp layers'
/// timing) and of the comps it is nested in, so every related viewer shows the same moment.
pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) {
let mut seen = std::collections::BTreeSet::from([comp]);
let mut todo = vec![(comp, t)];
let mut found = vec![];
while let Some((cid, ct)) = todo.pop() {
if cid != comp {
found.push((cid, ct));
}
let Some(c) = self.project.comp(cid) else { continue };
// Down: the comps this one nests.
for l in &c.layers {
if let effectcraft_project::LayerSource::Comp { item } = &l.source
&& seen.insert(*item)
{
todo.push((*item, l.layer_time(ct)));
}
}
// Up: the comps nesting this one.
for iid in self.project.items.keys() {
if let Some(pc) = self.project.comp(*iid)
&& let Some(l) = pc.layers.iter().find(|l| matches!(&l.source, effectcraft_project::LayerSource::Comp { item } if *item == cid))
&& seen.insert(*iid)
{
todo.push((*iid, l.comp_time(ct)));
}
}
}
for (cid, ct) in found {
if let Some(c) = self.project.comp(cid) {
let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO)));
self.state.times.insert(cid, ct);
}
}
}
@@ -589,6 +661,7 @@ impl Session {
/// Render a comp frame with the session's footage + expression hosts.
pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image {
let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts };
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
r.expr = self.expr.as_deref();
r.cache = Some(&self.layer_cache);
@@ -637,6 +710,7 @@ impl Session {
self.stop_roto();
self.roto_job = None;
self.roto_pending.clear();
self.drop_tasks();
p.fix_next_id();
upgrade_effects(&mut p);
self.project = Arc::new(p);
@@ -704,6 +778,8 @@ mod tests_fidelity;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_m145;
#[cfg(test)]
mod tests_markers;
#[cfg(test)]
mod tests_mask_warp;
@@ -752,7 +828,71 @@ pub fn text_families() -> Vec<String> {
pub fn font_features(family: &str, style: &str) -> Vec<String> {
effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features()
}
/// One row of the Character panel's font menu.
#[derive(Clone, Debug, PartialEq)]
pub struct FontRow {
/// The family to apply (empty for the separator after the recent fonts).
pub family: String,
/// What the menu shows: the English family name, or the font's own-language name when
/// Settings ▸ Type ▸ Show Font Names in English is off.
pub display: String,
pub recent: bool,
}
/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for
/// the font menu's preview (Settings ▸ Type ▸ Show Font Preview).
pub fn font_preview(family: &str, size: f64) -> Vec<Vec<[f32; 2]>> {
use kurbo::PathEl;
let st = effectcraft_keyframe::text_doc::CharStyle { font: family.to_string(), size, ..Default::default() };
let mut out = vec![];
let mut x = 0.0;
for ch in "Sample".chars() {
let (path, adv) = effectcraft_text::char_glyph_style(&st, ch);
let mut cur: Vec<[f32; 2]> = vec![];
let pt = |p: kurbo::Point| [(p.x + x) as f32, p.y as f32];
kurbo::flatten(&path, 0.2, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur = vec![pt(p)];
}
PathEl::LineTo(p) => cur.push(pt(p)),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
}
_ => {}
});
if cur.len() > 1 {
out.push(cur);
}
x += adv;
}
out
}
/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts
/// to Display), a separator, then every family.
pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
let all = text_families();
let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() };
let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent };
let mut out: Vec<FontRow> = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect();
if !out.is_empty() {
out.push(FontRow { family: String::new(), display: "-".into(), recent: false });
}
out.extend(all.iter().map(|f| row(f, false)));
out
}
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_roto;
+99
View File
@@ -0,0 +1,99 @@
//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends
//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder),
//! and always keeps the last few hundred warnings and errors in memory for the System
//! Compatibility Report.
//!
//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging`
//! command) turns file logging on and off.
use std::collections::VecDeque;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
static ENABLED: AtomicBool = AtomicBool::new(false);
static STATE: Mutex<State> = Mutex::new(State { file: None, recent: VecDeque::new() });
struct State {
file: Option<PathBuf>,
recent: VecDeque<String>,
}
struct Logger;
const RECENT: usize = 300;
impl log::Log for Logger {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft"))
}
fn log(&self, r: &log::Record) {
if !self.enabled(r.metadata()) {
return;
}
let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args());
let Ok(mut st) = STATE.lock() else { return };
if r.level() <= log::Level::Warn {
st.recent.push_back(line.clone());
if st.recent.len() > RECENT {
st.recent.pop_front();
}
}
if ENABLED.load(Ordering::Relaxed)
&& let Some(f) = &st.file
&& let Ok(mut out) = std::fs::OpenOptions::new().create(true).append(true).open(f)
{
let _ = writeln!(out, "{line}");
}
}
fn flush(&self) {}
}
fn unix_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// Install the logger (once; later calls and other installed loggers are left alone).
pub fn install() {
static LOGGER: Logger = Logger;
if log::set_logger(&LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Info);
}
}
/// The log file inside `config_dir` (the settings folder), or the temporary folder.
pub fn log_path(config_dir: Option<&Path>) -> PathBuf {
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt")
}
/// Turn file logging on (writing to `file`) or off.
pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> {
install();
if on {
if let Some(d) = file.parent() {
std::fs::create_dir_all(d)?;
}
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?;
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
}
if let Ok(mut st) = STATE.lock() {
st.file = Some(file);
}
ENABLED.store(on, Ordering::Relaxed);
Ok(())
}
pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// The current log file (set once logging was enabled).
pub fn current_file() -> Option<PathBuf> {
STATE.lock().ok().and_then(|s| s.file.clone())
}
/// Recent warnings and errors, oldest first.
pub fn recent() -> Vec<String> {
STATE.lock().map(|s| s.recent.iter().cloned().collect()).unwrap_or_default()
}
+270
View File
@@ -0,0 +1,270 @@
//! Media Browser (Window ▸ Media Browser): browse the local file system for footage, keep
//! favourite folders, read file metadata, and import (`file.import`). Desktop only: the web build
//! has no file system to browse ([`available`] is false there).
//!
//! Also the metadata the Metadata panel shows for project items and files (codec, size, rate,
//! duration, colour profile, dates).
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use effectcraft_project::{Footage, FootageKind, Item, ItemKind, Project};
/// Media Browser state (in [`crate::EditorState`], serde for agents).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserState {
/// The folder shown (`None` = the home folder).
#[serde(default)]
pub folder: Option<String>,
/// Favourite folders.
#[serde(default)]
pub favorites: Vec<String>,
/// Show only importable files.
#[serde(default)]
pub importable_only: bool,
}
/// Whether this build can browse files.
pub fn available() -> bool {
!cfg!(target_arch = "wasm32")
}
/// One directory entry.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Entry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: u64,
/// Seconds since the Unix epoch.
pub modified: Option<u64>,
/// Footage kind guessed from the extension (`video`, `image`, `audio`, `data`, `model`,
/// `project`), `None` for other files and folders.
pub kind: Option<&'static str>,
}
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"];
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
const MODEL: &[&str] = &["gltf", "glb", "obj"];
/// Kind of file from its extension.
pub fn kind_of(path: &str) -> Option<&'static str> {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
let e = ext.as_str();
if VIDEO.contains(&e) {
Some("video")
} else if IMAGE.contains(&e) {
Some("image")
} else if AUDIO.contains(&e) {
Some("audio")
} else if DATA.contains(&e) {
Some("data")
} else if MODEL.contains(&e) {
Some("model")
} else if matches!(e, "ecproj" | "ectemplate" | "json5") {
Some("project")
} else {
None
}
}
/// The user's home folder (or `/`).
pub fn home() -> String {
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/".into())
}
fn unix(t: std::io::Result<std::time::SystemTime>) -> Option<u64> {
t.ok()?.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs())
}
/// List a folder: folders first, then files, by name (hidden entries skipped).
pub fn list(dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
if !available() {
return Err("the Media Browser needs the desktop app".into());
}
let rd = std::fs::read_dir(dir).map_err(|e| format!("{dir}: {e}"))?;
let mut out: Vec<Entry> = rd
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
return None;
}
let md = e.metadata().ok()?;
let path = e.path().to_string_lossy().to_string();
let kind = if md.is_dir() { None } else { kind_of(&path) };
if importable_only && !md.is_dir() && kind.is_none() {
return None;
}
Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind })
})
.collect();
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(out)
}
/// The parent folder of `dir`.
pub fn parent(dir: &str) -> Option<String> {
std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty())
}
/// Format seconds since the epoch as an ISO 8601 UTC date-time.
pub fn iso_date(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
// Civil-from-days (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}
/// File dates and size: (created, modified, size).
pub fn file_dates(path: &str) -> (Option<u64>, Option<u64>, Option<u64>) {
if !available() || path.is_empty() {
return (None, None, None);
}
match std::fs::metadata(path) {
Ok(md) => (unix(md.created()), unix(md.modified()), Some(md.len())),
Err(_) => (None, None, None),
}
}
fn kind_name(k: FootageKind) -> &'static str {
match k {
FootageKind::Video => "video",
FootageKind::Still => "still",
FootageKind::Sequence => "sequence",
FootageKind::Audio => "audio",
FootageKind::Model => "model",
FootageKind::Data => "data",
}
}
/// Metadata of footage (the fields the Metadata panel lists).
pub fn footage_metadata(f: &Footage) -> Value {
let (created, modified, size) = file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path));
json!({
"path": f.path,
"kind": kind_name(f.kind),
"codec": f.codec,
"width": f.width,
"height": f.height,
"pixelAspect": f.pixel_aspect,
"frameRate": f.frame_rate.as_f64(),
"duration": f.duration.seconds(),
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.duration)) } else { None },
"hasVideo": f.has_video,
"hasAudio": f.has_audio,
"alpha": format!("{:?}", f.alpha),
"colorProfile": f.color_profile.map(|c| format!("{c:?}")).unwrap_or_else(|| "sRGB (default)".into()),
"linearLight": f.linear_light,
"fields": format!("{:?}", f.fields),
"sequenceFiles": f.sequence.len(),
"dataRows": f.data.as_ref().map(|d| d.lines().count().saturating_sub(1)),
"created": created.map(iso_date),
"modified": modified.map(iso_date),
"fileSize": size,
"missing": f.missing,
})
}
/// Metadata of a project item.
pub fn item_metadata(p: &Project, it: &Item) -> Value {
let mut v = json!({
"id": it.id.0,
"name": it.name,
"type": it.type_name(),
"comment": it.comment,
"label": format!("{:?}", it.label),
});
let extra = match &it.kind {
ItemKind::Footage(f) => footage_metadata(f),
ItemKind::Comp(c) => json!({
"width": c.width,
"height": c.height,
"pixelAspect": c.pixel_aspect,
"frameRate": c.frame_rate.as_f64(),
"duration": c.duration.seconds(),
"layers": c.layers.len(),
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()],
}),
ItemKind::Solid(s) => json!({"width": s.width, "height": s.height, "color": s.color}),
ItemKind::Folder => json!({"items": p.items.values().filter(|i| i.parent == Some(it.id)).count()}),
};
if let (Value::Object(a), Value::Object(b)) = (&mut v, extra) {
a.extend(b);
}
if let Some(px) = &it.proxy {
v["proxy"] = json!(px.footage.path);
}
v
}
/// Project-level metadata.
pub fn project_metadata(p: &Project, path: Option<&str>) -> Value {
let st = &p.settings;
let count = |f: &dyn Fn(&ItemKind) -> bool| p.items.values().filter(|i| f(&i.kind)).count();
json!({
"path": path,
"comment": st.comment,
"bitDepth": st.bit_depth.label(),
"workingSpace": st.working_space.map(|c| format!("{c:?}")),
"linearize": st.linearize,
"timeDisplay": format!("{:?}", st.time_display),
"audioSampleRate": st.audio_sample_rate,
"items": p.items.len(),
"compositions": count(&|k| matches!(k, ItemKind::Comp(_))),
"footage": count(&|k| matches!(k, ItemKind::Footage(_))),
"solids": count(&|k| matches!(k, ItemKind::Solid(_))),
"folders": count(&|k| matches!(k, ItemKind::Folder)),
"renderQueue": p.render_queue.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_a_folder() {
let dir = std::env::temp_dir().join(format!("ec-mb-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Shots")).unwrap();
std::fs::write(dir.join("b.png"), b"x").unwrap();
std::fs::write(dir.join("A.mov"), b"xy").unwrap();
std::fs::write(dir.join("notes.txt"), b"xyz").unwrap();
std::fs::write(dir.join(".hidden"), b"").unwrap();
let d = dir.to_string_lossy().to_string();
let all = list(&d, false).unwrap();
let names: Vec<&str> = all.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["Shots", "A.mov", "b.png", "notes.txt"]);
assert!(all[0].is_dir);
assert_eq!(all[1].kind, Some("video"));
assert_eq!(all[2].kind, Some("image"));
assert_eq!(all[1].size, 2);
let media = list(&d, true).unwrap();
assert_eq!(media.len(), 3);
assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str()));
assert!(list(&dir.join("nope").to_string_lossy(), false).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn iso_dates() {
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z");
assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z");
}
}
+65
View File
@@ -0,0 +1,65 @@
//! The media cache (Settings ▸ Disk ▸ Database and Cache Folder): audio waveform summaries kept
//! on disk so a footage file is summarised once, not in every session.
//!
//! A summary is stored as `Peaks/<hash>_<bins>.ecpk` (raw little-endian `f32` quadruples
//! `[min L, max L, min R, max R]` per bin), named by a hash of the footage path, its size and
//! modification time, so an edited file is summarised again.
use std::path::{Path, PathBuf};
/// The peak file of footage `path` at `bins` bins per second in the media cache `folder`
/// (`None` when the file can't be inspected).
pub fn peaks_path(folder: &Path, path: &str, bins: u32) -> Option<PathBuf> {
use std::hash::{Hash, Hasher};
let meta = std::fs::metadata(path).ok()?;
let mut h = std::collections::hash_map::DefaultHasher::new();
(path, meta.len(), meta.modified().ok()).hash(&mut h);
Some(folder.join("Peaks").join(format!("{:016x}_{bins}.ecpk", h.finish())))
}
/// Read a stored summary.
pub fn load_peaks(file: &Path) -> Option<Vec<[f32; 4]>> {
let b = std::fs::read(file).ok()?;
if b.len() % 16 != 0 {
return None;
}
let f = |c: &[u8]| f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
Some(b.chunks_exact(16).map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect())
}
/// Store a summary (atomically: written beside, then renamed).
pub fn store_peaks(file: &Path, peaks: &[[f32; 4]]) -> std::io::Result<()> {
if let Some(d) = file.parent() {
std::fs::create_dir_all(d)?;
}
let mut b = Vec::with_capacity(peaks.len() * 16);
for p in peaks {
for v in p {
b.extend_from_slice(&v.to_le_bytes());
}
}
crate::config::atomic_write(file, &b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peaks_round_trip_and_follow_the_file() {
let d = std::env::temp_dir().join(format!("ec-media-cache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let src = d.join("a.wav");
std::fs::write(&src, b"one").unwrap();
let p = peaks_path(&d, src.to_str().unwrap(), 200).unwrap();
assert!(p.starts_with(d.join("Peaks")));
let peaks = vec![[-0.5, 0.5, -0.25, 0.25], [0.0, 0.1, -0.1, 0.0]];
store_peaks(&p, &peaks).unwrap();
assert_eq!(load_peaks(&p).unwrap(), peaks);
// A changed file gets another name.
std::fs::write(&src, b"longer").unwrap();
assert_ne!(peaks_path(&d, src.to_str().unwrap(), 200).unwrap(), p);
let _ = std::fs::remove_dir_all(&d);
}
}
+174 -9
View File
@@ -12,6 +12,8 @@
//! Next Blending Mode | layer.setBlendMode {"step":1} | Shift+=
//! --- separator
//! @effects the effect categories (Effect menu)
//! @dynamic:history entries computed from the session when the menu
//! opens ([`dynamic`]: recent files, undo history…)
//! [mac] Quit EffectCraft | app.quit only on macOS ([!mac] = everywhere else)
//! ```
//!
@@ -35,11 +37,120 @@ use crate::commands::{self, CommandSpec};
pub enum MenuNode {
Item(MenuEntry),
Separator,
Submenu { label: String, children: Vec<MenuNode> },
Submenu {
label: String,
children: Vec<MenuNode>,
},
/// Entries computed when the menu opens (see [`dynamic`]).
Dynamic {
name: String,
},
}
/// Frontend state dynamic menus depend on.
#[derive(Clone, Copy, Debug, Default)]
pub struct DynCtx<'a> {
/// The current workspace (Window ▸ Assign Shortcut to "…" Workspace).
pub workspace: Option<&'a str>,
}
fn file_name(p: &str) -> String {
std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())
}
fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry {
MenuEntry { label, command: command.into(), params, shortcut: None }
}
/// The bindable holding shortcut `keys` in the active preset (its label).
fn holder_of(s: &Session, keys: &str) -> Option<String> {
let t = s.shortcuts();
t.bindables.iter().find(|b| t.keys.get(&b.key).is_some_and(|k| k.iter().any(|k| k == keys))).map(|b| b.label.clone())
}
/// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
/// first), `view3dShortcuts`, `workspaceShortcuts` and `openViewers` (open compositions other
/// than the active one).
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
use serde_json::json;
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
list.iter()
.enumerate()
.map(|(i, p)| {
let n =
if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) };
dyn_entry(n, cmd, json!({"index": i}))
})
.collect()
};
match name {
"recentProjects" => (indexed(&s.prefs.recent_projects, "file.openRecent", false), Some("No Recent Projects")),
"recentFootage" => (indexed(&s.prefs.recent_footage, "file.importRecent", false), Some("No Recent Footage")),
"recentPresets" => (indexed(&s.prefs.recent_presets, "anim.applyRecentPreset", true), Some("No Recent Presets")),
"history" => (
s.history.undo.iter().rev().enumerate().map(|(i, (l, _))| dyn_entry(format!("Undo {l}"), "edit.history", json!({"steps": i + 1}))).collect(),
Some("No History"),
),
"view3dShortcuts" => (
["F10", "F11", "F12"]
.iter()
.map(|k| {
let label = match holder_of(s, k) {
Some(h) => format!("{k} (Replace “{h}”)"),
None => k.to_string(),
};
dyn_entry(label, "view.assign3dShortcut", json!({"slot": k}))
})
.collect(),
None,
),
"workspaceShortcuts" => (
["Shift+F10", "Shift+F11", "Shift+F12"]
.iter()
.map(|k| {
let label = match holder_of(s, k) {
Some(h) => format!("{k} (Replace “{h}”)"),
None => k.to_string(),
};
let mut p = json!({"slot": k});
if let Some(w) = cx.workspace {
p["workspace"] = json!(w);
}
dyn_entry(label, "window.assignWorkspaceShortcut", p)
})
.collect(),
None,
),
"openViewers" => (
s.state
.open_comps
.iter()
.filter(|c| Some(**c) != s.state.active_comp)
.filter_map(|c| s.project.item(*c).map(|i| dyn_entry(format!("Composition: {}", i.name), "comp.open", json!({"comp": c.0}))))
.collect(),
None,
),
_ => (vec![], None),
}
}
/// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or
/// workspace, like After Effects).
pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String {
match label {
"Assign Shortcut to 3D View" => {
let v = s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
let name = crate::commands::find(crate::commands::view_command(v)).map(|c| c.label).unwrap_or("Active Camera");
format!("Assign Shortcut to “{name}”")
}
"Assign Shortcut to Workspace" => format!("Assign Shortcut to “{}” Workspace", cx.workspace.unwrap_or("Default")),
_ => label.to_string(),
}
}
/// A clickable menu entry.
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct MenuEntry {
pub label: String,
pub command: String,
@@ -81,7 +192,7 @@ pub fn entries() -> Vec<(Vec<String>, &'static MenuEntry)> {
for n in nodes {
match n {
MenuNode::Item(e) => out.push((path.clone(), e)),
MenuNode::Separator => {}
MenuNode::Separator | MenuNode::Dynamic { .. } => {}
MenuNode::Submenu { label, children } => {
path.push(label.clone());
rec(children, path, out);
@@ -97,7 +208,27 @@ pub fn entries() -> Vec<(Vec<String>, &'static MenuEntry)> {
/// Display label of an entry: label names come from Settings ▸ Labels.
pub fn entry_label(s: &Session, e: &MenuEntry) -> String {
let comp_name = || s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "(none)".into());
let layer_name =
|| s.active_comp().and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l))).map(|l| l.name.clone()).unwrap_or_else(|| "(none)".into());
match e.command.as_str() {
// Window menu: the panels' current viewer instances.
"window.panel" => match e.params.get("panel").and_then(Value::as_str) {
Some("composition") => format!("Composition: {}", comp_name()),
Some("timeline") => format!("Timeline: {}", comp_name()),
Some("flowchart") => format!("Flowchart: {}", comp_name()),
Some("layer") => format!("Layer: {}", layer_name()),
Some("effectControls") if e.label == "Effect Controls" => format!("Effect Controls: {}", layer_name()),
// The footage shown in the Footage panel (else the selected footage).
Some("footage") => {
let shown = s.state.footage_panel.as_ref().and_then(|f| s.project.item(f.item));
let f = shown.or_else(|| {
s.state.project_selection.iter().filter_map(|i| s.project.item(*i)).find(|i| matches!(i.kind, effectcraft_project::ItemKind::Footage(_)))
});
format!("Footage: {}", f.map(|i| i.name.clone()).unwrap_or_else(|| "(none)".into()))
}
_ => e.label.clone(),
},
"edit.label" => match e.params.get("label").and_then(Value::as_str).and_then(effectcraft_color::Label::from_name) {
Some(l) if l != effectcraft_color::Label::None => s.prefs.label_name(l),
_ => e.label.clone(),
@@ -109,6 +240,9 @@ pub fn entry_label(s: &Session, e: &MenuEntry) -> String {
/// Check-mark state of an entry (`None` = not a toggle / radio entry). Frontend-only toggles
/// (rulers, grid…) are answered by the frontend.
pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
if command == "help.enableLogging" {
return Some(crate::logging::is_enabled());
}
let comp = s.active_comp()?;
let layer = s.state.selected_layers.first().and_then(|l| comp.layer(*l));
let pstr = |k: &str| params.get(k).and_then(Value::as_str);
@@ -248,6 +382,10 @@ pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
out.extend(effect_categories());
continue;
}
if let Some(name) = l.body.strip_prefix("@dynamic:") {
out.push(MenuNode::Dynamic { name: name.trim().to_string() });
continue;
}
let mut parts = l.body.split(" | ");
let label = parts.next().unwrap_or_default().trim().to_string();
match parts.next() {
@@ -333,6 +471,8 @@ File
New Folder | project.newFolder
Open Project... | file.open
Open Recent
@dynamic:recentProjects
---
Clear Recent Projects | file.clearRecent
---
Close | file.close
@@ -341,6 +481,7 @@ File
Save As
Save As... | file.saveAs
Save a Copy... | file.saveCopy
Save a Copy As XML... | file.saveCopyAsXml
Increment and Save | file.incrementAndSave
Revert | file.revert
---
@@ -351,6 +492,10 @@ File
Solid... | file.importSolid
Lottie... | file.importLottie
Essential Graphics Template... | essential.importTemplate
Import Recent Footage
@dynamic:recentFootage
---
Clear Recent Footage | file.clearRecentFootage
Export
Add to Render Queue | renderQueue.add
Lottie JSON... | file.exportLottie
@@ -388,6 +533,7 @@ File
File... | file.replaceFootage
Placeholder... | file.replaceWithPlaceholder
Solid... | file.replaceWithSolid
With Layered Comp | file.replaceWithLayeredComp
Reload Footage | file.reloadFootage
Reveal in Finder | file.revealInFinder
---
@@ -397,6 +543,8 @@ File
Edit
Undo | edit.undo
Redo | edit.redo
History
@dynamic:history
---
Quick Apply... | app.commandPalette
---
@@ -508,6 +656,10 @@ Composition
---
Composition Flowchart | comp.flowchart
Composition Mini-Flowchart | comp.miniFlowchart
---
VR
Create VR Environment... | comp.vr.createEnvironment
Extract Cubemap... | comp.vr.extractCubemap
Layer
New
Text | layer.newText
@@ -760,6 +912,10 @@ Effect
Animation
Save Animation Preset... | anim.savePreset
Apply Animation Preset... | anim.applyPreset
Recent Animation Presets
@dynamic:recentPresets
---
Clear Recent Presets | anim.clearRecentPresets
Browse Presets... | anim.browsePresets
Text Animation Presets
Typewriter | layer.applyTextPreset {"preset":"typewriter"}
@@ -922,6 +1078,8 @@ View
Custom View 3 | view.3d.custom3
---
Reset 3D View | view.reset3DView
Assign Shortcut to 3D View
@dynamic:view3dShortcuts
Switch to Last 3D View | view.3d.last | Escape
Look at Selected Layers | view.lookAtSelected
Look at All Layers | view.lookAtAll
@@ -951,24 +1109,26 @@ Window
Save Changes to this Workspace | window.saveWorkspace
Save as New Workspace... | window.saveWorkspaceAs
Edit Workspaces... | window.editWorkspaces
Assign Shortcut to Workspace
@dynamic:workspaceShortcuts
---
Align | window.panel {"panel":"align"}
Audio | window.panel {"panel":"audio"} | Cmd+4
Brushes | window.panel {"panel":"brushes"} | Cmd+9
Character | window.panel {"panel":"character"} | Cmd+6
Content-Aware Fill | window.unavailablePanel {"panel":"contentAwareFill"}
Content-Aware Fill | window.panel {"panel":"contentAwareFill"}
Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5
Essential Graphics | window.panel {"panel":"essentialGraphics"}
Info | window.panel {"panel":"info"} | Cmd+2
Lumetri Scopes | window.unavailablePanel {"panel":"lumetriScopes"}
Lumetri Scopes | window.panel {"panel":"lumetriScopes"}
Mask Interpolation | window.panel {"panel":"maskInterpolation"}
Media Browser | window.unavailablePanel {"panel":"mediaBrowser"}
Metadata | window.unavailablePanel {"panel":"metadata"}
Media Browser | window.panel {"panel":"mediaBrowser"}
Metadata | window.panel {"panel":"metadata"}
Motion Sketch | window.panel {"panel":"motionSketch"}
Paint | window.panel {"panel":"paint"} | Cmd+8
Paragraph | window.panel {"panel":"paragraph"} | Cmd+7
Preview | window.panel {"panel":"preview"} | Cmd+3
Progress | window.unavailablePanel {"panel":"progress"}
Progress | window.panel {"panel":"progress"}
Properties | window.panel {"panel":"properties"}
Script Console | window.panel {"panel":"scriptConsole"}
Smoother | window.panel {"panel":"smoother"}
@@ -979,11 +1139,12 @@ Window
Composition | window.panel {"panel":"composition"}
Effect Controls | window.panel {"panel":"effectControls"} | F3
Flowchart | window.panel {"panel":"flowchart"}
Footage | window.unavailablePanel {"panel":"footage"}
Footage | window.panel {"panel":"footage"}
Layer | window.panel {"panel":"layer"}
Project | window.panel {"panel":"project"} | Cmd+0
Render Queue | window.panel {"panel":"renderQueue"} | Cmd+Alt+0
Timeline | window.panel {"panel":"timeline"}
@dynamic:openViewers
Help
EffectCraft Help... | help.docs {"page":"help"} | F1
Scripting Help... | help.docs {"page":"scripting"}
@@ -992,6 +1153,10 @@ Help
Animation Presets... | anim.browsePresets
Keyboard Shortcuts... | app.keyboardShortcuts
---
System Compatibility Report... | help.systemReport
Enable Logging | help.enableLogging
Reveal Logging File | help.revealLogFile
---
Join the ArtCraft Discord... | help.discord
Provide Feedback... | help.reportIssue
---
+1 -1
View File
@@ -6,7 +6,7 @@ fn all_entries(nodes: &[MenuNode], path: &mut Vec<String>, out: &mut Vec<(Vec<St
for n in nodes {
match n {
MenuNode::Item(e) => out.push((path.clone(), e.clone())),
MenuNode::Separator => {}
MenuNode::Separator | MenuNode::Dynamic { .. } => {}
MenuNode::Submenu { label, children } => {
path.push(label.clone());
all_entries(children, path, out);
+245 -58
View File
@@ -13,6 +13,7 @@
use std::collections::BTreeMap;
use effectcraft_color::Label;
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
@@ -273,6 +274,13 @@ pub struct Prefs {
pub scripting: Scripting,
/// File ▸ Open Recent, newest first.
pub recent_projects: Vec<String>,
/// File ▸ Import Recent Footage, newest first.
pub recent_footage: Vec<String>,
/// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest
/// first.
pub recent_presets: Vec<String>,
/// Character panel: recently used font families, newest first.
pub recent_fonts: Vec<String>,
/// Top-level keys this version doesn't know (kept for newer versions).
#[serde(flatten)]
pub extra: Extra,
@@ -301,6 +309,9 @@ impl Default for Prefs {
three_d: ThreeD::default(),
scripting: Scripting::default(),
recent_projects: vec![],
recent_footage: vec![],
recent_presets: vec![],
recent_fonts: vec![],
extra: Extra::new(),
}
}
@@ -430,7 +441,13 @@ impl Prefs {
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
let d = Prefs::default();
let Some(page) = page else {
*self = Prefs { recent_projects: std::mem::take(&mut self.recent_projects), ..d };
*self = Prefs {
recent_projects: std::mem::take(&mut self.recent_projects),
recent_footage: std::mem::take(&mut self.recent_footage),
recent_presets: std::mem::take(&mut self.recent_presets),
recent_fonts: std::mem::take(&mut self.recent_fonts),
..d
};
return Ok(());
};
let keys: Vec<&str> = match page {
@@ -490,6 +507,174 @@ impl Prefs {
_ => 0.125,
}
}
/// The Memory & CPU cache budgets in effect: the configured layer / footage / preview
/// budgets, scaled down so together they leave **RAM Reserved for Other Applications** free,
/// and halved while the system is low on memory when **Reduce Cache Size When System Is Low
/// on Memory** is on. `mem` = the system's memory (`None` = unknown: configured budgets).
pub fn cache_budgets(&self, mem: Option<crate::sysinfo::SysMemory>) -> CacheBudgets {
let (mut l, mut m, mut p) = (self.layer_cache_bytes() as u64, self.media_cache_bytes() as u64, self.preview_cache_bytes() as u64);
let floor = 64u64 << 20;
let mut capped = false;
let mut reduced = false;
if let Some(mem) = mem.filter(|m| m.total > 0) {
let reserved = (self.memory.ram_reserved_gb as u64) << 30;
let cap = mem.total.saturating_sub(reserved).max(3 * floor);
let sum = l + m + p;
if sum > cap {
let k = cap as f64 / sum as f64;
(l, m, p) = ((l as f64 * k) as u64, (m as f64 * k) as u64, (p as f64 * k) as u64);
capped = true;
}
if self.memory.reduce_cache_when_low && mem.is_low() {
(l, m, p) = (l / 2, m / 2, p / 2);
reduced = true;
}
}
CacheBudgets { layer: l.max(floor) as usize, media: m.max(floor) as usize, preview: p.max(floor) as usize, capped, reduced }
}
/// Composition ▸ Motion Path: the layer-time span of a motion path to draw around `now`
/// (layer time), for keyframes at `keys` (sorted). `None` = No Motion Path.
pub fn motion_path_span(&self, keys: &[Tick], now: Tick) -> Option<(Tick, Tick)> {
let (first, last) = (*keys.first()?, *keys.last()?);
let c = &self.composition;
match c.motion_path.as_str() {
"none" => None,
"seconds" => {
let half = Tick::from_seconds_f64(c.motion_path_seconds.max(0.0) / 2.0);
let (a, b) = ((now - half).max(first), (now + half).min(last));
(a < b).then_some((a, b))
}
"keyframes" => {
let n = (c.motion_path_keyframes.max(1) as usize).min(keys.len());
if n < 2 {
return None;
}
// The n keyframes centred on the current time.
let at = keys.partition_point(|k| *k < now);
let start = at.saturating_sub(n / 2).min(keys.len() - n);
Some((keys[start], keys[start + n - 1]))
}
_ => Some((first, last)),
}
}
/// Previews ▸ Viewer Zoom Quality: smooth (bilinear) scaling of the viewer image, or nearest
/// neighbour ("Faster").
pub fn viewer_zoom_smooth(&self) -> bool {
self.previews.zoom_quality != "faster"
}
/// Import ▸ Interpret Unlabeled Alpha As, for footage whose file has an alpha channel that
/// doesn't state how it is stored. `None` = Ask User (keep straight and open Interpret
/// Footage); `guess` takes premultiplied for movies (codecs with alpha are usually written
/// premultiplied) and straight for stills.
pub fn unlabeled_alpha(&self, movie: bool) -> Option<effectcraft_project::AlphaMode> {
use effectcraft_project::AlphaMode::*;
Some(match self.import.unlabeled_alpha.as_str() {
"ignore" => Ignore,
"straight" => Straight,
"premultiplied" => Premultiplied,
"guess" if movie => Premultiplied,
"guess" => Straight,
_ => return None,
})
}
/// Import ▸ Default Drag Import As, as `file.import`'s `importAs`.
pub fn drag_import_as(&self) -> &'static str {
match self.import.drag_import_as.as_str() {
"comp" => "composition",
"compLayerSizes" => "compositionLayerSizes",
_ => "footage",
}
}
/// Export ▸ Append Bit Depth to File Name: `Comp 1.png` → `Comp 1_16bpc.png` (sequence frame
/// runs and folders are kept).
pub fn output_name(&self, path: &str, bits: u32) -> String {
if !self.export.append_bits_to_name {
return path.to_string();
}
let p = std::path::Path::new(path);
let (Some(stem), dir) = (p.file_stem().map(|s| s.to_string_lossy().to_string()), p.parent()) else { return path.to_string() };
let tag = format!("_{bits}bpc");
if stem.ends_with(&tag) {
return path.to_string();
}
let name = match p.extension() {
Some(e) => format!("{stem}{tag}.{}", e.to_string_lossy()),
None => format!("{stem}{tag}"),
};
match dir.filter(|d| !d.as_os_str().is_empty()) {
Some(d) => d.join(name).to_string_lossy().to_string(),
None => name,
}
}
/// Export ▸ Segment Sequences / Segment Movie Files: how many frames go in each segment of an
/// output of `frames` frames (`None` = one file / folder). Sequences split every Files per
/// Segment frames; movies every Segment Size MB at the output's data rate
/// (`bytes_per_frame`, estimated from the format's bitrate).
pub fn segment_frames(&self, frames: u64, sequence: bool, bytes_per_frame: f64) -> Option<u64> {
let e = &self.export;
let per = if sequence {
e.segment_sequences.then_some(e.segment_sequence_files.max(1) as u64)?
} else {
if !e.segment_movies || bytes_per_frame <= 0.0 {
return None;
}
(((e.segment_movie_mb.max(1) as f64) * 1_048_576.0 / bytes_per_frame).floor() as u64).max(1)
};
(per < frames).then_some(per)
}
/// Remember a font in the Character panel's recent fonts (Type ▸ Number of Recent Fonts).
pub fn push_recent_font(&mut self, family: &str) {
push_mru(&mut self.recent_fonts, family, 30);
}
/// The recent fonts the Character panel shows (at most Number of Recent Fonts to Display).
pub fn recent_fonts_shown(&self) -> &[String] {
let n = (self.type_.recent_fonts as usize).min(self.recent_fonts.len());
&self.recent_fonts[..n]
}
/// Remember imported footage for File ▸ Import Recent Footage.
pub fn push_recent_footage(&mut self, path: &str) {
push_mru(&mut self.recent_footage, path, self.general.recent_items as usize);
}
/// Remember an applied animation preset for Animation ▸ Recent Animation Presets.
pub fn push_recent_preset(&mut self, path: &str) {
push_mru(&mut self.recent_presets, path, self.general.recent_items as usize);
}
}
fn push_mru(list: &mut Vec<String>, v: &str, max: usize) {
list.retain(|x| x != v);
list.insert(0, v.to_string());
list.truncate(max.max(1));
}
/// Effective cache budgets in bytes (see [`Prefs::cache_budgets`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct CacheBudgets {
pub layer: usize,
pub media: usize,
pub preview: usize,
/// Scaled down to leave the reserved RAM free.
pub capped: bool,
/// Halved because the system is low on memory.
pub reduced: bool,
}
/// Colour of the `k`-th mask of a layer: Appearance ▸ Cycle Mask Colors cycles through the mask
/// colours, otherwise every new mask takes the first one.
pub fn mask_color(cycle: bool, k: usize) -> [u8; 3] {
use effectcraft_project::build::MASK_COLORS;
if cycle { MASK_COLORS[k % MASK_COLORS.len()] } else { MASK_COLORS[0] }
}
fn label_index(l: Label) -> Option<usize> {
@@ -659,13 +844,14 @@ pub fn pages() -> Vec<Page> {
Section("Options"),
s("general.showToolTips", "Show Tool Tips", B, true),
s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true),
s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, false),
s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true),
s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true),
s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, false),
s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, false),
s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, false),
s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, false),
s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, true),
s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, true),
s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, true),
s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, true),
s("general.useSystemColorPicker", "Use System Color Picker", B, false),
Note("EffectCraft always uses its own colour picker (the same on every platform and the web)."),
],
},
Page {
@@ -673,7 +859,7 @@ pub fn pages() -> Vec<Page> {
title: "Startup & Repair",
items: vec![
s("startup.showHomeOnLaunch", "Show Home Screen When Launching", B, true),
s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, false),
s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, true),
s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true),
Section("Repair"),
Button { label: "Reset Settings", command: "prefs.reset", params: "{}" },
@@ -712,13 +898,14 @@ pub fn pages() -> Vec<Page> {
("No More Than (Seconds)", "seconds"),
("No More Than (Keyframes)", "keyframes"),
]),
false,
true,
),
s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), false),
s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), false),
s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), true),
s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), true),
Section("Display"),
s("composition.showRenderingProgress", "Show Rendering Progress in Info Panel and Flowchart", B, true),
s("composition.hardwareAcceleratePanels", "Hardware Accelerate Composition, Layer and Footage Panels", B, false),
Note("Panels are always drawn by the GPU (wgpu)."),
],
},
Page {
@@ -726,10 +913,10 @@ pub fn pages() -> Vec<Page> {
title: "Previews",
items: vec![
s("previews.adaptiveResolutionLimit", "Adaptive Resolution Limit", Kind::Choice(ON_OFF_RES), true),
s("previews.showInternalWireframes", "Show Internal Wireframes", B, false),
s("previews.showInternalWireframes", "Show Internal Wireframes", B, true),
s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true),
s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true),
s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), false),
s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), true),
s("previews.displayProfile", "Display Color Space", Kind::Choice(&[("sRGB", "srgb"), ("Display P3", "p3")]), true),
Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" },
],
@@ -742,9 +929,9 @@ pub fn pages() -> Vec<Page> {
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
Section("Labels and Colors"),
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, false),
s("appearance.cycleMaskColors", "Cycle Mask Colors", B, false),
s("appearance.useGradients", "Use Gradients", B, false),
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
s("appearance.cycleMaskColors", "Cycle Mask Colors", B, true),
s("appearance.useGradients", "Use Gradients", B, true),
Section("Menu Bar"),
s("appearance.inWindowMenuBarMac", "Use In-Window Menu Bar on macOS", B, true),
],
@@ -757,13 +944,13 @@ pub fn pages() -> Vec<Page> {
s("grids.gridColor", "Color", Kind::Color, true),
s("grids.gridSpacing", "Gridline Every", Kind::Float(1.0, 10_000.0, "px"), true),
s("grids.gridSubdivisions", "Subdivisions", Kind::Int(1, 100, ""), true),
s("grids.gridStyle", "Style", Kind::Choice(STYLES), false),
s("grids.gridStyle", "Style", Kind::Choice(STYLES), true),
Section("Proportional Grid"),
s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), false),
s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), false),
s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), true),
s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), true),
Section("Guides"),
s("grids.guideColor", "Color", Kind::Color, true),
s("grids.guideStyle", "Style", Kind::Choice(STYLES), false),
s("grids.guideStyle", "Style", Kind::Choice(STYLES), true),
Section("Safe Margins"),
s("grids.actionSafe", "Action-safe", Kind::Float(0.0, 50.0, "%"), true),
s("grids.titleSafe", "Title-safe", Kind::Float(0.0, 50.0, "%"), true),
@@ -774,10 +961,10 @@ pub fn pages() -> Vec<Page> {
id: "type",
title: "Type",
items: vec![
s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), false),
s("type.fontPreview", "Show Font Preview", B, false),
s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), false),
s("type.fontNamesInEnglish", "Show Font Names in English", B, false),
s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), true),
s("type.fontPreview", "Show Font Preview", B, true),
s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), true),
s("type.fontNamesInEnglish", "Show Font Names in English", B, true),
],
},
Page {
@@ -787,7 +974,7 @@ pub fn pages() -> Vec<Page> {
s("import.stillFootage", "Still Footage", Kind::Choice(&[("Length of Composition", "compLength"), ("Duration (Seconds)", "seconds")]), true),
s("import.stillSeconds", "Still Duration", Kind::Float(0.04, 86_400.0, "s"), true),
s("import.sequenceFps", "Sequence Footage", Kind::Float(1.0, 999.0, "frames per second"), true),
s("import.reportMissingFrames", "Report Missing Frames", B, false),
s("import.reportMissingFrames", "Report Missing Frames", B, true),
s(
"import.unlabeledAlpha",
"Interpret Unlabeled Alpha As",
@@ -798,13 +985,13 @@ pub fn pages() -> Vec<Page> {
("Straight", "straight"),
("Premultiplied", "premultiplied"),
]),
false,
true,
),
s(
"import.dragImportAs",
"Default Drag Import As",
Kind::Choice(&[("Footage", "footage"), ("Composition", "comp"), ("Composition - Retain Layer Sizes", "compLayerSizes")]),
false,
true,
),
],
},
@@ -812,12 +999,12 @@ pub fn pages() -> Vec<Page> {
id: "export",
title: "Export",
items: vec![
s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, false),
s("export.segmentSequences", "Segment Sequences", B, false),
s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), false),
s("export.segmentMovies", "Segment Movie Files", B, false),
s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), false),
s("export.appendBitsToName", "Append Bit Depth to File Name", B, false),
s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, true),
s("export.segmentSequences", "Segment Sequences", B, true),
s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), true),
s("export.segmentMovies", "Segment Movie Files", B, true),
s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), true),
s("export.appendBitsToName", "Append Bit Depth to File Name", B, true),
],
},
Page {
@@ -826,7 +1013,7 @@ pub fn pages() -> Vec<Page> {
items: vec![
Section("Audio Hardware"),
AudioDevices { key: "audio.outputDevice" },
s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), false),
s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), true),
Section("Audio Output Mapping"),
s("audio.outputLeft", "Left", Kind::Int(1, 64, "channel"), true),
s("audio.outputRight", "Right", Kind::Int(1, 64, "channel"), true),
@@ -837,36 +1024,36 @@ pub fn pages() -> Vec<Page> {
title: "Disk",
items: vec![
Section("Disk Cache"),
s("disk.diskCacheEnabled", "Enable Disk Cache", B, false),
s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), false),
s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, false),
s("disk.diskCacheEnabled", "Enable Disk Cache", B, true),
s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), true),
s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, true),
Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# },
Section("Media Cache"),
s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, false),
s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, false),
s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, true),
s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, true),
],
},
Page {
id: "memory",
title: "Memory & CPU",
items: vec![
s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), false),
s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), true),
Section("Cache Budgets"),
s("memory.layerCacheMb", "Layer Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, false),
s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, true),
],
},
Page {
id: "video",
title: "Video",
items: vec![
s("video.enableOutput", "Enable Video Preview Output", B, false),
s("video.device", "Video Device", Kind::Text, false),
s("video.outputDuringPlayback", "Video Output During Playback", B, false),
s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, false),
s("video.disableWhenBackground", "Disable Video Output When in Background", B, false),
s("video.enableOutput", "Enable Video Preview Output", B, true),
s("video.device", "Video Device", Kind::Choice(&[("Floating Window", ""), ("Full Screen", "fullscreen")]), true),
s("video.outputDuringPlayback", "Video Output During Playback", B, true),
s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, true),
s("video.disableWhenBackground", "Disable Video Output When in Background", B, true),
],
},
Page {
@@ -874,9 +1061,9 @@ pub fn pages() -> Vec<Page> {
title: "3D",
items: vec![
s("threeD.defaultRenderer", "Default 3D Renderer", Kind::Choice(&[("Classic 3D", "classic"), ("Advanced 3D", "advanced")]), true),
s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, false),
s("threeD.extendedViewer", "Extended Viewer", B, false),
s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, false),
s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, true),
s("threeD.extendedViewer", "Extended Viewer", B, true),
s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, true),
],
},
Page {
@@ -884,18 +1071,18 @@ pub fn pages() -> Vec<Page> {
title: "Scripting & Expressions",
items: vec![
Section("Application Scripting"),
s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, false),
s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, false),
s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, true),
s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, true),
s("scripting.enableJsDebugger", "Enable JavaScript Debugger", B, false),
Section("Expressions Editor"),
s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), false),
s("scripting.syntaxHighlighting", "Syntax Highlighting", B, false),
s("scripting.lineNumbers", "Line Numbers", B, false),
s("scripting.autoComplete", "Auto-complete", B, false),
s("scripting.bracketMatching", "Bracket Matching", B, false),
s("scripting.wordWrap", "Word Wrap", B, false),
s("scripting.errorBanner", "Show Expression Error Banner", B, false),
Note("Expressions use the JavaScript engine."),
s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), true),
s("scripting.syntaxHighlighting", "Syntax Highlighting", B, true),
s("scripting.lineNumbers", "Line Numbers", B, true),
s("scripting.autoComplete", "Auto-complete", B, true),
s("scripting.bracketMatching", "Bracket Matching", B, true),
s("scripting.wordWrap", "Word Wrap", B, true),
s("scripting.errorBanner", "Show Expression Error Banner", B, true),
Note("Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger."),
],
},
]
+133 -7
View File
@@ -10,7 +10,7 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::render_queue::{OutputFormat, OutputModule, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{ExprHost, FootageSource};
use serde::{Deserialize, Serialize};
@@ -27,6 +27,8 @@ pub struct ExportJob<'a> {
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Resolved output path (templates expanded except the `#` frame-number run).
pub path: &'a str,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
pub nested_switches: bool,
}
/// What an export wrote.
@@ -127,8 +129,56 @@ struct Work {
expr: Option<Arc<dyn ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
exporter: Arc<dyn Exporter>,
/// Per queue item: one (item with that output module, resolved path) per output module.
/// Per queue item: one (item with that output module, resolved path) per output module
/// (and per segment, see [`Session::segments`]).
items: Vec<Vec<(RenderQueueItem, String)>>,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
nested_switches: bool,
}
/// Bits per channel an output format writes for a project bit depth (named in the file when
/// Settings ▸ Export ▸ Append Bit Depth to File Name is on).
pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 {
use effectcraft_project::BitDepth;
let project = match depth {
BitDepth::Bpc8 => 8,
BitDepth::Bpc16 => 16,
BitDepth::Bpc32 => 32,
};
match format {
OutputFormat::ExrSequence => 32,
OutputFormat::PngSequence | OutputFormat::TiffSequence => project.min(16),
OutputFormat::ProRes => 10,
OutputFormat::Wav | OutputFormat::Aiff => 16,
_ => 8,
}
}
/// The bytes an output writes per frame, estimated from its data rate (H.264 bitrate, Apple's
/// published ProRes target rates at 1080p29.97 scaled by frame size, typical rates otherwise).
pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
use effectcraft_project::render_queue::ProResProfile::*;
let fps = fps.max(1.0);
let px = (w as f64 * h as f64) / (1920.0 * 1080.0);
let mbps = |m: f64| m * 1_000_000.0 / 8.0;
match om.format {
OutputFormat::H264 => om.bitrate_kbps as f64 * 1000.0 / 8.0 / fps,
OutputFormat::ProRes => {
let rate = match om.prores_profile {
Proxy => 45.0,
Lt => 102.0,
Standard => 147.0,
Hq => 220.0,
P4444 => 330.0,
P4444Xq => 500.0,
};
mbps(rate) * px / 29.97
}
OutputFormat::WebM => mbps(8.0) * px / fps,
OutputFormat::Gif => w as f64 * h as f64 * 0.5,
OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,
_ => w as f64 * h as f64 * 3.0,
}
}
fn unix_now() -> u64 {
@@ -143,7 +193,15 @@ pub(crate) fn run_items(
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 };
let work = Work {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
accel: s.accel.clone(),
exporter,
items,
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
run_work(work, shared, notify);
}
@@ -174,7 +232,15 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
// 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 job = ExportJob {
project: &w.project,
footage: w.footage.as_ref(),
expr: w.expr.as_deref(),
accel: w.accel.as_deref(),
item: mitem,
path,
nested_switches: w.nested_switches,
};
let rr = w.exporter.export(&job, &mut |done, total| {
{
let mut s = lock(&shared.state);
@@ -248,12 +314,58 @@ impl Session {
if path.is_absolute() {
return Some(p);
}
let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty());
// Settings ▸ Export ▸ Default Output Folder, else the project's folder.
let folder = self.prefs.export.default_output_folder.trim();
let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder));
let base =
base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()));
// (the web has no working directory: relative outputs land in its virtual root)
let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?;
Some(base.join(path).to_string_lossy().to_string())
}
/// Settings ▸ Export ▸ Segment Sequences / Segment Movie Files: the (item, path) outputs one
/// output module writes. Sequences go into numbered folders of Files per Segment frames each
/// (`out/Comp 1_001/Comp 1_00000.png`…); movies into numbered files of about Segment Size MB
/// each (`Comp 1_001.mp4`…), cut at the frame count the output's data rate gives.
pub fn segments(&self, item: &RenderQueueItem, path: &str) -> Vec<(RenderQueueItem, String)> {
let Some(comp) = self.project.comp(item.comp) else { return vec![(item.clone(), path.to_string())] };
let fmt = item.output.format;
let frames = item.settings.frame_count(comp);
let (w, h) = item.settings.output_size(comp);
let bpf = bytes_per_frame(&item.output, w, h, item.settings.rate(comp).as_f64());
let Some(per) = self.prefs.segment_frames(frames, fmt.is_sequence(), bpf) else { return vec![(item.clone(), path.to_string())] };
let p = std::path::Path::new(path);
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let ext = p.extension().map(|s| s.to_string_lossy().to_string());
let rate = item.settings.rate(comp);
let f0 = item.settings.first_frame(comp);
let mut out = vec![];
let mut i = 0;
let mut k = 1;
while i < frames {
let n = per.min(frames - i);
let mut m = item.clone();
let (start, end) = (rate.tick_of(f0 + i as i64), rate.tick_of(f0 + (i + n) as i64));
m.settings.time_span = effectcraft_project::render_queue::TimeSpan::Custom { start, end };
let seg = if fmt.is_sequence() {
let base = stem.trim_end_matches(['#', '[', ']', '_']).to_string();
let folder = dir.join(format!("{base}_{k:03}"));
folder.join(p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())
} else {
dir.join(match &ext {
Some(e) => format!("{stem}_{k:03}.{e}"),
None => format!("{stem}_{k:03}"),
})
};
out.push((m, seg.to_string_lossy().to_string()));
i += n;
k += 1;
}
out
}
/// Whether a render job is running.
pub fn is_rendering(&self) -> bool {
self.render_job.as_ref().is_some_and(|j| !j.is_finished())
@@ -286,7 +398,13 @@ impl Session {
let mut m = it.clone();
m.output = om.clone();
match self.resolve_output(&m) {
Some(p) if formats.contains(&om.format) => modules.push((m, p)),
Some(p) if formats.contains(&om.format) => {
// Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences /
// Movie Files.
let bits = output_bits(om.format, self.project.settings.bit_depth);
let p = self.prefs.output_name(&p, bits);
modules.extend(self.segments(&m, &p));
}
Some(_) => problem = Some(RenderStatus::Failed(format!("{} export is not available", om.format.label()))),
None if self.project.comp(it.comp).is_none() => problem = Some(RenderStatus::Failed("composition missing".into())),
None => problem = Some(RenderStatus::NeedsOutput),
@@ -329,7 +447,15 @@ impl Session {
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 work = Work {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
accel: self.accel.clone(),
exporter,
items,
nested_switches: self.prefs.general.switches_affect_nested_comps,
};
let shared = Arc::new(JobShared::default());
if wait {
run_work(work, &shared, &mut |_| {});
+55 -2
View File
@@ -25,9 +25,15 @@ impl Session {
/// Settings changed: apply what the engine owns (cache budgets) and tell frontends.
pub fn prefs_changed(&mut self) {
self.prefs.normalize();
self.layer_cache.set_budget(self.prefs.layer_cache_bytes());
self.footage.set_cache_budget(self.prefs.media_cache_bytes());
self.apply_cache_budgets();
self.configure_disk_cache();
self.footage.set_conform_folder(self.conformed_audio_folder());
// Switches Affect Nested Comps changes what precomp layers render: drop cached pixels.
let nested = self.prefs.general.switches_affect_nested_comps;
if self.applied_nested_switches.replace(nested).is_some_and(|was| was != nested) {
self.layer_cache.clear();
self.events.push(crate::Event::PurgeCaches);
}
// Fewer undo levels apply right away.
let levels = self.prefs.general.undo_levels.max(1) as usize;
if self.history.undo.len() > levels {
@@ -37,6 +43,53 @@ impl Session {
self.prefs_revision += 1;
}
/// A Disk settings folder: the setting, or (for frontends with a settings store) `name` next
/// to the platform disk cache folder. Headless sessions without the setting stay off disk.
fn cache_folder(&self, setting: &str, name: &str) -> Option<std::path::PathBuf> {
let s = setting.trim();
if !s.is_empty() {
return Some(std::path::PathBuf::from(s));
}
let base = effectcraft_render::disk_cache::default_folder();
self.config.is_some().then(|| base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name)))
}
/// Settings ▸ Disk ▸ Database and Cache Folder (media cache: audio waveform summaries).
pub fn media_cache_folder(&self) -> Option<std::path::PathBuf> {
self.cache_folder(&self.prefs.disk.media_cache_folder, "Media Cache")
}
/// Settings ▸ Disk ▸ Conformed Audio Folder (decoded footage audio, see
/// `MediaPool::set_conform_folder`).
pub fn conformed_audio_folder(&self) -> Option<std::path::PathBuf> {
self.cache_folder(&self.prefs.disk.conformed_media_folder, "Conformed Audio")
}
/// Apply the Memory & CPU cache budgets, limited by RAM Reserved for Other Applications and
/// reduced while the system is low on memory (see [`crate::prefs::Prefs::cache_budgets`]).
pub fn apply_cache_budgets(&mut self) {
let b = self.prefs.cache_budgets(self.sys_memory);
self.layer_cache.set_budget(b.layer);
self.footage.set_cache_budget(b.media);
}
/// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply
/// the cache budgets when the reading changes them. Returns the effective budgets.
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
let before = self.prefs.cache_budgets(self.sys_memory);
self.sys_memory = crate::sysinfo::memory().or(self.sys_memory);
let after = self.prefs.cache_budgets(self.sys_memory);
if after != before {
self.apply_cache_budgets();
// Frontends re-apply their preview cache budget.
self.prefs_revision += 1;
if after.reduced && !before.reduced {
self.toast("The system is low on memory: cache sizes were reduced");
}
}
after
}
/// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache
/// and attach it to the layer cache. Without a folder setting the platform cache folder is
/// used, but only by frontends with a settings store (headless sessions stay off disk).
+140
View File
@@ -0,0 +1,140 @@
//! Host information for Settings ▸ Memory & CPU (RAM reserved for other applications, reducing
//! caches when the system is low on memory) and Help ▸ System Compatibility Report.
//!
//! Pure Rust and dependency-free: the values come from the operating system's own reporting
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every
//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their
//! configured budgets.
use serde::Serialize;
/// Physical memory in bytes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
pub struct SysMemory {
pub total: u64,
/// Memory the system can hand out without swapping (free + reclaimable caches).
pub available: u64,
}
impl SysMemory {
/// The system is low on memory: less than 10% (or less than 1 GB) is available.
pub fn is_low(&self) -> bool {
self.total > 0 && (self.available < self.total / 10 || self.available < 1 << 30)
}
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
fn run(cmd: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(cmd).args(args).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string())
}
/// Parse `/proc/meminfo` (kB values).
pub fn parse_meminfo(text: &str) -> Option<SysMemory> {
let field = |name: &str| -> Option<u64> {
let line = text.lines().find(|l| l.starts_with(name))?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
Some(kb * 1024)
};
let total = field("MemTotal:")?;
let available = field("MemAvailable:").or_else(|| Some(field("MemFree:")? + field("Cached:").unwrap_or(0)))?;
Some(SysMemory { total, available })
}
/// Parse macOS `vm_stat` output: free + inactive + speculative + purgeable pages.
pub fn parse_vm_stat(text: &str, total: u64) -> Option<SysMemory> {
let page: u64 = text.split("page size of ").nth(1)?.split_whitespace().next()?.parse().ok()?;
let pages = |name: &str| -> u64 {
text.lines()
.find(|l| l.starts_with(name))
.and_then(|l| l.rsplit(':').next())
.and_then(|v| v.trim().trim_end_matches('.').parse::<u64>().ok())
.unwrap_or(0)
};
let free = pages("Pages free") + pages("Pages inactive") + pages("Pages speculative") + pages("Pages purgeable");
Some(SysMemory { total, available: (free * page).min(total) })
}
/// Total and available physical memory, when the system reports them.
pub fn memory() -> Option<SysMemory> {
#[cfg(target_os = "linux")]
{
return parse_meminfo(&std::fs::read_to_string("/proc/meminfo").ok()?);
}
#[cfg(target_os = "macos")]
{
let total: u64 = run("sysctl", &["-n", "hw.memsize"])?.trim().parse().ok()?;
return parse_vm_stat(&run("vm_stat", &[])?, total).or(Some(SysMemory { total, available: total }));
}
#[cfg(target_os = "windows")]
{
let text = run(
"powershell",
&["-NoProfile", "-Command", "$o=Get-CimInstance Win32_OperatingSystem; \"$($o.TotalVisibleMemorySize) $($o.FreePhysicalMemory)\""],
)?;
let mut it = text.split_whitespace().filter_map(|v| v.parse::<u64>().ok());
return Some(SysMemory { total: it.next()? * 1024, available: it.next()? * 1024 });
}
#[allow(unreachable_code)]
None
}
/// CPU model name.
pub fn cpu_name() -> Option<String> {
#[cfg(target_os = "linux")]
{
let t = std::fs::read_to_string("/proc/cpuinfo").ok()?;
return t.lines().find(|l| l.starts_with("model name")).and_then(|l| l.split(':').nth(1)).map(|s| s.trim().to_string());
}
#[cfg(target_os = "macos")]
{
return run("sysctl", &["-n", "machdep.cpu.brand_string"]).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
}
#[cfg(target_os = "windows")]
{
return std::env::var("PROCESSOR_IDENTIFIER").ok();
}
#[allow(unreachable_code)]
None
}
/// Operating system name and version.
pub fn os_version() -> String {
#[cfg(target_os = "macos")]
if let Some(v) = run("sw_vers", &["-productVersion"]) {
return format!("macOS {}", v.trim());
}
#[cfg(target_os = "linux")]
if let Ok(t) = std::fs::read_to_string("/etc/os-release")
&& let Some(n) = t.lines().find_map(|l| l.strip_prefix("PRETTY_NAME="))
{
return n.trim_matches('"').to_string();
}
#[cfg(target_os = "windows")]
if let Some(v) = run("cmd", &["/C", "ver"]) {
return v.trim().to_string();
}
format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)
}
/// Logical CPU cores.
pub fn cpu_cores() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_proc_meminfo_and_vm_stat() {
let m = parse_meminfo("MemTotal: 16000000 kB\nMemFree: 1000 kB\nMemAvailable: 8000000 kB\n").unwrap();
assert_eq!(m, SysMemory { total: 16_000_000 * 1024, available: 8_000_000 * 1024 });
assert!(!m.is_low());
let vm = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 1000.\nPages active: 5.\nPages inactive: 3000.\nPages speculative: 100.\n";
let m = parse_vm_stat(vm, 8 << 30).unwrap();
assert_eq!(m.available, 4100 * 16384);
assert!(m.is_low());
}
}
+666
View File
@@ -0,0 +1,666 @@
//! M14.5: settings that change behaviour, the keyframe assistants and labels, and the menu
//! entries added for After Effects parity (History, recent footage / presets, XML copies, VR,
//! shortcut slots, logging and the compatibility report).
use std::path::PathBuf;
use serde_json::{Value, json};
use crate::render::RenderOpts;
use crate::{KeyRef, Session};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{ItemId, LayerId};
use effectcraft_time::Tick;
fn tmp(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let d = std::env::temp_dir().join(format!("ec-m145-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 48, "duration": 2, "frameRate": 10})).unwrap();
s
}
fn solid(s: &mut Session, color: &str) -> u64 {
s.execute("layer.newSolid", json!({"color": color, "width": 32, "height": 24})).unwrap()["layer"].as_u64().unwrap()
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
// ---------------------------------------------------------------- General
#[test]
fn split_layers_go_above_or_below_the_original() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
s.execute("time.set", json!({"time": 1.0})).unwrap();
let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
assert_eq!(order, vec![a, below], "off (default): the new part goes below");
s.execute("edit.undo", json!({})).unwrap();
s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap();
let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
assert_eq!(order, vec![above, a]);
}
#[test]
fn new_masks_cycle_colours_only_when_asked() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
let colors = |s: &Session| -> Vec<[u8; 3]> {
layer(s, a)
.props
.sub("masks")
.unwrap()
.groups()
.map(|g| match g.kind {
effectcraft_project::GroupKind::Mask { color, .. } => color,
_ => [0; 3],
})
.collect()
};
s.execute("layer.addMask", json!({"layer": a})).unwrap();
s.execute("layer.addMask", json!({"layer": a})).unwrap();
let c = colors(&s);
assert_ne!(c[0], c[1], "cycling by default");
s.execute("prefs.set", json!({"key": "appearance.cycleMaskColors", "value": false})).unwrap();
s.execute("layer.addMask", json!({"layer": a})).unwrap();
s.execute("mask.new", json!({"layer": a, "vertices": [[0, 0], [5, 5]]})).unwrap();
let c = colors(&s);
assert_eq!(c[2], c[0]);
assert_eq!(c[3], c[0]);
}
#[test]
fn preserve_constant_vertex_count_edits_every_mask_key() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
let m = s.execute("layer.addMask", json!({"layer": a})).unwrap()["mask"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": a, "prop": layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid})).unwrap();
let path_uid = layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid;
s.execute("prop.toggleAnimation", json!({"layer": a, "prop": path_uid})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("mask.setVertex", json!({"layer": a, "mask": m, "index": 0, "point": [1, 1]})).unwrap();
let counts = |s: &Session| -> Vec<usize> {
layer(s, a)
.props
.find(path_uid)
.unwrap()
.keys
.iter()
.map(|k| match &k.value {
KV::Path(p) => p.vertices.len(),
_ => 0,
})
.collect()
};
assert_eq!(counts(&s), vec![4, 4]);
// On (the default): a vertex added at one key is added to the other.
s.execute("mask.insertVertex", json!({"layer": a, "mask": m, "segment": 0})).unwrap();
assert_eq!(counts(&s), vec![5, 5]);
s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(a), mask: m, index: 1 }];
s.execute("mask.deleteVertices", json!({})).unwrap();
assert_eq!(counts(&s), vec![4, 4]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(counts(&s), vec![5, 5], "one undo step");
// Off: only the current key changes.
s.execute("prefs.set", json!({"key": "general.preserveConstantVertexCount", "value": false})).unwrap();
s.execute("mask.addVertex", json!({"layer": a, "mask": m, "point": [3, 3]})).unwrap();
assert_eq!(counts(&s), vec![5, 6]);
}
#[test]
fn switches_affect_nested_comps_passes_quality_down() {
let mut s = Session::default();
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let a = solid(&mut s, "#ff8040");
// A rotated, scaled layer: bilinear (Best) and nearest (Draft) sampling differ.
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 17})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [131, 131]})).unwrap();
let outer = s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let pre = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.quality", json!({"layers": [pre], "quality": "draft"})).unwrap();
let draw = |s: &Session| s.render(ItemId(outer), Tick::ZERO, RenderOpts::default());
let on = draw(&s);
s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap();
let off = draw(&s);
assert_ne!(on.data, off.data, "the precomp's Draft switch reaches the nested layer only when on");
// On = the nested layer itself set to Draft.
s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": true})).unwrap();
s.open_comp(ItemId(inner));
s.execute("layer.quality", json!({"layers": [a], "quality": "draft"})).unwrap();
s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap();
assert_eq!(draw(&s).data, on.data);
}
#[test]
fn related_comps_share_the_current_time() {
let mut s = Session::default();
let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap();
let outer = s.execute("comp.new", json!({"name": "Outer", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap();
let l = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setTiming", json!({"layer": l, "startTime": 2.0})).ok();
s.edit("t", None, |p, _| {
p.comp_mut(ItemId(outer)).unwrap().layer_mut(LayerId(l)).unwrap().start_time = Tick::from_seconds_f64(2.0);
Ok(())
})
.unwrap();
s.set_time(Tick::from_seconds_f64(5.0));
assert!((s.time_of(ItemId(inner)).seconds() - 3.0).abs() < 1e-9, "nested comp follows through the layer's start");
s.open_comp(ItemId(inner));
s.set_time(Tick::from_seconds_f64(1.0));
assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9, "and the other way");
s.execute("prefs.set", json!({"key": "general.syncTimeRelatedItems", "value": false})).unwrap();
s.set_time(Tick::from_seconds_f64(7.0));
assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9);
}
#[test]
fn pick_whip_writes_match_names_without_compact_english() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
let r = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": r, "effect": "Gaussian Blur"})).unwrap();
s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap();
let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/opacity", "target": {"layer": r, "path": "transform/opacity"}})).unwrap();
assert_eq!(e, json!("thisComp.layer(\"Other\")(\"transform\")(\"opacity\")"));
let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/rotation", "target": {"layer": a, "path": "transform/opacity"}})).unwrap();
assert_eq!(e, json!("thisLayer(\"transform\")(\"opacity\")"));
}
// ---------------------------------------------------------------- pure setting helpers
#[test]
fn cache_budgets_leave_reserved_ram_and_shrink_when_low() {
use crate::sysinfo::SysMemory;
let mut p = crate::prefs::Prefs::default();
p.memory.layer_cache_mb = 4096;
p.memory.media_cache_mb = 4096;
p.memory.preview_cache_mb = 8192;
p.memory.ram_reserved_gb = 6;
let b = p.cache_budgets(None);
assert_eq!((b.layer >> 20, b.preview >> 20, b.capped), (4096, 8192, false));
// 16 GB machine, 6 GB reserved: 16 GB of caches scaled into 10 GB.
let m = SysMemory { total: 16 << 30, available: 12 << 30 };
let b = p.cache_budgets(Some(m));
assert!(b.capped && !b.reduced);
assert!(((b.layer + b.media + b.preview) as i64 - (10i64 << 30)).abs() < 4, "{b:?}");
// Low on memory: halved; off: not.
let low = SysMemory { total: 16 << 30, available: 512 << 20 };
let r = p.cache_budgets(Some(low));
assert!(r.reduced && r.preview < b.preview / 2 + 2);
p.memory.reduce_cache_when_low = false;
assert!(!p.cache_budgets(Some(low)).reduced);
}
#[test]
fn motion_path_span_follows_the_composition_setting() {
let mut p = crate::prefs::Prefs::default();
let s = |x: f64| Tick::from_seconds_f64(x);
let keys: Vec<Tick> = (0..40).map(|i| s(i as f64)).collect();
// Default: no more than 15 keyframes, centred on the current time.
assert_eq!(p.motion_path_span(&keys, s(20.0)), Some((s(13.0), s(27.0))));
assert_eq!(p.motion_path_span(&keys, s(0.0)), Some((s(0.0), s(14.0))));
p.composition.motion_path = "all".into();
assert_eq!(p.motion_path_span(&keys, s(5.0)), Some((s(0.0), s(39.0))));
p.composition.motion_path = "none".into();
assert_eq!(p.motion_path_span(&keys, s(5.0)), None);
p.composition.motion_path = "seconds".into();
p.composition.motion_path_seconds = 4.0;
assert_eq!(p.motion_path_span(&keys, s(10.0)), Some((s(8.0), s(12.0))));
}
#[test]
fn import_export_helpers() {
use effectcraft_project::AlphaMode;
let mut p = crate::prefs::Prefs::default();
assert_eq!(p.unlabeled_alpha(false), None, "Ask User");
for (v, movie, want) in [
("ignore", false, AlphaMode::Ignore),
("premultiplied", false, AlphaMode::Premultiplied),
("guess", true, AlphaMode::Premultiplied),
("guess", false, AlphaMode::Straight),
] {
p.import.unlabeled_alpha = v.into();
assert_eq!(p.unlabeled_alpha(movie), Some(want));
}
assert_eq!(p.drag_import_as(), "footage");
p.import.drag_import_as = "compLayerSizes".into();
assert_eq!(p.drag_import_as(), "compositionLayerSizes");
assert_eq!(
super::commands::missing_frames_for_tests(&["a_001.png".into(), "a_002.png".into(), "a_005.png".into(), "a_007.png".into()]).unwrap(),
"3 missing frames (3–4, 6)"
);
assert!(super::commands::missing_frames_for_tests(&["a1.png".into(), "a2.png".into()]).is_none());
// Append Bit Depth to File Name.
assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1.png");
p.export.append_bits_to_name = true;
assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1_16bpc.png");
assert_eq!(p.output_name("Comp_[#####].exr", 32), "Comp_[#####]_32bpc.exr");
// Segments: sequences by file count, movies by size at the data rate.
assert_eq!(p.segment_frames(100, true, 0.0), None);
p.export.segment_sequences = true;
p.export.segment_sequence_files = 30;
assert_eq!(p.segment_frames(100, true, 0.0), Some(30));
p.export.segment_movies = true;
p.export.segment_movie_mb = 1;
assert_eq!(p.segment_frames(100, false, 1_048_576.0 / 10.0), Some(10));
assert_eq!(p.segment_frames(5, false, 1_048_576.0 / 10.0), None);
// Recent fonts.
for f in ["A", "B", "C", "A"] {
p.push_recent_font(f);
}
p.type_.recent_fonts = 2;
assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]);
}
#[test]
fn render_queue_uses_default_folder_bit_depth_names_and_segments() {
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
let mut s = session();
solid(&mut s, "#ff0000");
let dir = tmp("out");
s.execute("prefs.set", json!({"values": {"export.defaultOutputFolder": dir.to_string_lossy(), "export.appendBitsToName": true}})).unwrap();
let cid = s.active_comp_id().unwrap();
let mut item = RenderQueueItem::new(1, cid);
item.output.output = "Main.png".into();
item.output.format = OutputFormat::PngSequence;
let out = s.resolve_output(&item).unwrap();
assert_eq!(PathBuf::from(&out), dir.join("Main.png"));
assert_eq!(
s.prefs.output_name(&out, crate::render_queue::output_bits(OutputFormat::PngSequence, s.project.settings.bit_depth)),
dir.join("Main_8bpc.png").to_string_lossy()
);
// 20 frames in segments of 8 files: three folders, spans tiling the work area.
s.prefs.export.segment_sequences = true;
s.prefs.export.segment_sequence_files = 8;
let segs = s.segments(&item, &out);
assert_eq!(segs.len(), 3);
let comp = s.active_comp().unwrap().clone();
let n: Vec<u64> = segs.iter().map(|(i, _)| i.settings.frame_count(&comp)).collect();
assert_eq!(n, vec![8, 8, 4]);
assert!(segs[1].1.contains("Main_002"), "{}", segs[1].1);
assert_eq!(segs[2].0.settings.first_frame(&comp), 16);
// Movies: a ~1 MB file per segment at the H.264 bitrate.
s.prefs.export.segment_movies = true;
s.prefs.export.segment_movie_mb = 1;
item.output.format = OutputFormat::H264;
item.output.bitrate_kbps = 4000; // 50 kB per frame at 10 fps
let segs = s.segments(&item, "/o/Main.mp4");
assert_eq!(segs.len(), 1, "20 frames × 50 kB fit in 1 MB");
item.output.bitrate_kbps = 8000; // 100 kB per frame: 10 frames per MB
let segs = s.segments(&item, "/o/Main.mp4");
assert_eq!(segs.iter().map(|(_, p)| p.as_str()).collect::<Vec<_>>(), ["/o/Main_001.mp4", "/o/Main_002.mp4"]);
}
#[test]
fn every_wired_setting_is_live_and_display_only_ones_are_documented() {
let todo = crate::prefs::todo_keys();
assert_eq!(todo, ["general.useSystemColorPicker", "composition.hardwareAcceleratePanels", "scripting.enableJsDebugger"]);
let docs = include_str!("../../../docs/preferences.md");
assert!(docs.contains("### Display-only settings"));
assert!(!docs.contains("Not wired yet"));
}
// ---------------------------------------------------------------- scripting gate
#[test]
fn execute_file_needs_the_gate_and_asks_first() {
let mut s = session();
let f = tmp("run.txt");
std::fs::write(&f, "x").unwrap();
let p = json!({"path": f.to_string_lossy()});
assert!(s.execute("file.executeFile", p.clone()).is_err(), "off by default");
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
s.drain_events();
let r = s.execute("file.executeFile", p.clone()).unwrap();
assert!(r.get("needsConfirmation").is_some());
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.confirmExecute")));
let r = s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap();
assert!(r.get("opened").is_some());
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.starts_with("file://"))));
s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap();
assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some());
}
// ---------------------------------------------------------------- Edit ▸ History, recent lists
#[test]
fn history_submenu_jumps_back_several_steps() {
let mut s = session();
solid(&mut s, "#ff0000");
solid(&mut s, "#00ff00");
solid(&mut s, "#0000ff");
let list = s.execute("edit.history", json!({})).unwrap();
assert_eq!(list["undo"][0], json!("New Solid"));
let n = s.history.undo.len();
let (entries, _) = crate::menus::dynamic(&s, "history", &Default::default());
assert_eq!(entries.len(), n);
assert_eq!(entries[1].params, json!({"steps": 2}));
s.execute("edit.history", json!({"steps": 2})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
assert_eq!(s.history.redo.len(), 2);
s.execute("edit.history", json!({"redo": 2})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 3);
assert!(s.execute("edit.history", json!({"steps": 999})).is_err());
}
#[test]
fn recent_footage_and_presets_are_remembered() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
// Imports are remembered even when the build has no decoder (the error comes after).
let _ = s.execute("file.import", json!({"paths": ["/x/clip.mov", "/x/still.png"]}));
assert_eq!(s.prefs.recent_footage, ["/x/still.png", "/x/clip.mov"]);
let (e, _) = crate::menus::dynamic(&s, "recentFootage", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["still.png", "clip.mov"]);
s.execute("file.clearRecentFootage", json!({})).unwrap();
assert_eq!(crate::menus::dynamic(&s, "recentFootage", &Default::default()), (vec![], Some("No Recent Footage")));
// Presets.
let path = tmp("fade.ecpreset");
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
s.execute("anim.savePreset", json!({"path": path})).unwrap();
s.execute("anim.applyPreset", json!({"path": path, "layers": [a]})).unwrap();
assert_eq!(s.prefs.recent_presets, [path.to_string_lossy().to_string()]);
let (e, _) = crate::menus::dynamic(&s, "recentPresets", &Default::default());
assert_eq!(e[0].label, "fade");
s.state.selected_layers = vec![LayerId(a)];
s.execute("anim.applyRecentPreset", json!({"index": 0})).unwrap();
s.execute("anim.clearRecentPresets", json!({})).unwrap();
assert!(s.prefs.recent_presets.is_empty());
// Recent lists survive a settings round trip and a reset.
s.prefs.push_recent_footage("/y/a.wav");
let back = crate::prefs::Prefs::from_json(&s.prefs.to_json());
assert_eq!(back.recent_footage, ["/y/a.wav"]);
s.execute("prefs.reset", json!({})).unwrap();
assert_eq!(s.prefs.recent_footage, ["/y/a.wav"]);
}
#[test]
fn save_a_copy_as_xml_opens_again() {
let mut s = session();
solid(&mut s, "#ff0000");
let path = tmp("copy.ecprojx");
s.execute("file.saveCopyAsXml", json!({"path": path.to_string_lossy()})).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("<?xml") && text.contains("<EffectCraftProject"));
let before = s.project.to_json();
let mut t = Session::default();
t.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(t.project.to_json(), before);
// Saving a project opened from XML writes XML.
t.execute("file.save", json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().starts_with("<?xml"));
}
// ---------------------------------------------------------------- shortcut slots
#[test]
fn assign_shortcut_slots_move_keys_between_views_and_workspaces() {
let mut s = session();
s.execute("view.3d.top", json!({})).unwrap();
let label = crate::menus::submenu_label(&s, "Assign Shortcut to 3D View", &Default::default());
assert_eq!(label, "Assign Shortcut to “Top”");
let (e, _) = crate::menus::dynamic(&s, "view3dShortcuts", &Default::default());
assert_eq!(e[0].label, "F10 (Replace “Front”)");
let r = s.execute("view.assign3dShortcut", json!({"slot": "F10"})).unwrap();
assert_eq!(r["replaced"], json!(["Front"]));
assert_eq!(s.shortcuts().shortcut_of("view.3d.top", &Value::Null), Some("F10"));
assert_eq!(s.shortcuts().shortcut_of("view.3d.front", &Value::Null), None);
// Workspaces: Shift+F11 now opens Animation instead of Standard.
s.execute("window.assignWorkspaceShortcut", json!({"slot": "Shift+F11", "workspace": "Animation"})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("window.workspace", &json!({"name": "Animation"})), Some("Shift+F11"));
assert_eq!(s.shortcuts().shortcut_of("window.workspace", &json!({"name": "Standard"})), None);
assert!(s.execute("view.assign3dShortcut", json!({"slot": "F9"})).is_err());
// Without a workspace the frontend fills it in.
s.drain_events();
s.execute("window.assignWorkspaceShortcut", json!({"slot": "F10"})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "window.assignWorkspaceShortcut")));
}
// ---------------------------------------------------------------- Help
#[test]
fn compatibility_report_and_logging() {
let mut s = session();
let r = s.execute("help.systemReport", json!({"quiet": true})).unwrap();
for k in ["os", "cpu", "cores", "memory", "gpu", "import", "export", "issues"] {
assert!(r.get(k).is_some(), "{k} in {r}");
}
let text = crate::commands::report_text_for_tests(&r);
assert!(text.contains("Operating system") && text.contains("Import:"));
s.drain_events();
s.execute("help.systemReport", json!({})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.showReport")));
// Logging into a file in the settings folder.
let dir = tmp("cfg");
std::fs::create_dir_all(&dir).unwrap();
s.config = Some(std::sync::Arc::new(crate::config::DirConfig::new(dir.clone())));
let r = s.execute("help.enableLogging", json!({"on": true})).unwrap();
let file = PathBuf::from(r["path"].as_str().unwrap());
assert!(file.starts_with(&dir));
log::warn!(target: "effectcraft::tests", "hello log");
assert!(std::fs::read_to_string(&file).unwrap().contains("logging enabled"));
assert_eq!(crate::menus::checked(&s, "help.enableLogging", &Value::Null), Some(true));
let r = s.execute("help.revealLogFile", json!({})).unwrap();
assert!(r["url"].as_str().unwrap().starts_with("file://"));
s.execute("help.enableLogging", json!({"on": false})).unwrap();
assert!(!crate::logging::is_enabled());
}
// ---------------------------------------------------------------- VR
#[test]
fn create_vr_environment_and_extract_cubemap() {
let mut s = session();
let main = s.active_comp_id().unwrap();
let a = solid(&mut s, "#ff0000");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap();
let undo = s.history.undo.len();
let aname = layer(&s, a).name.clone();
let r = s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.history.undo.last().unwrap().0, "Create VR Environment");
let faces: Vec<u64> = r["faces"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(faces.len(), 6);
for f in &faces {
let c = s.project.comp(ItemId(*f)).unwrap();
assert_eq!((c.width, c.height), (64, 64));
assert!(c.layers.iter().any(|l| l.is_camera()));
assert!(c.layers.iter().any(|l| l.name == aname && l.is_3d()), "the scene's layers");
let _ = main;
}
let cube = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap();
assert_eq!((cube.width, cube.height, cube.layers.len()), (192, 128, 6));
let out = s.project.comp(ItemId(r["output"].as_u64().unwrap())).unwrap();
assert_eq!((out.width, out.height), (128, 64));
let fx = out.layers[0].props.sub("effects").unwrap().groups().next().unwrap().clone();
assert_eq!(fx.match_id, "ec.vr.converter");
// Every face camera looks a different way.
let mut dirs = vec![];
for f in &faces {
let c = s.project.comp(ItemId(*f)).unwrap();
let cam = c.layers.iter().find(|l| l.is_camera()).unwrap();
dirs.push(format!("{:?}", cam.props.prop("transform/orientation").unwrap().value));
}
dirs.sort();
dirs.dedup();
assert_eq!(dirs.len(), 6, "{dirs:?}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.comp(ItemId(faces[0])).is_none());
// Extract Cubemap of a 2:1 comp.
let eq = s.execute("comp.new", json!({"name": "Equi", "width": 128, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let r = s.execute("comp.vr.extractCubemap", json!({"comp": eq})).unwrap();
let c = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap();
assert_eq!((c.width, c.height), (96, 64));
}
/// The equirectangular output sees each wall of a box around the rig in the right direction.
#[test]
fn vr_environment_faces_look_the_right_way() {
// One wall at a time (centre 100,100,0; AE's −Y is up), and where the equirectangular output
// must show it: front +Z, back −Z, right +X, left −X, up, down.
let walls = [
("front", [100.0, 100.0, 150.0], [0.0, 0.0], (0.5, 0.5)),
("back", [100.0, 100.0, -150.0], [0.0, 180.0], (0.01, 0.5)),
("right", [250.0, 100.0, 0.0], [0.0, 90.0], (0.75, 0.5)),
("left", [-50.0, 100.0, 0.0], [0.0, 90.0], (0.25, 0.5)),
("up", [100.0, -50.0, 0.0], [90.0, 0.0], (0.5, 0.03)),
("down", [100.0, 250.0, 0.0], [90.0, 0.0], (0.5, 0.97)),
];
for (name, pos, rot, (fx, fy)) in walls {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Room", "width": 200, "height": 200, "duration": 1, "frameRate": 10})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": pos})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/rotationX", "value": rot[0]})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/rotationY", "value": rot[1]})).unwrap();
let r = s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap();
let img = s.render(ItemId(r["output"].as_u64().unwrap()), Tick::ZERO, RenderOpts::default());
let (w, h) = (img.width as f64, img.height as f64);
let alpha =
|x: f64, y: f64| img.data[((y * h) as usize).min(img.height as usize - 1) * img.width as usize + ((x * w) as usize).min(img.width as usize - 1)][3];
assert!(alpha(fx, fy) > 0.5, "{name}: the wall shows in its direction");
// The opposite direction is empty.
let (ox, oy) = if fy != 0.5 { (fx, 1.0 - fy) } else { ((fx + 0.5) % 1.0, fy) };
assert!(alpha(ox, oy) < 0.5, "{name}: nothing in the opposite direction");
}
}
// ---------------------------------------------------------------- keyframes
#[test]
fn convert_audio_to_keyframes_makes_the_amplitude_null() {
assert_eq!(crate::commands::amplitudes_for_tests(&[0.5, -0.25, -0.5, 0.25]), (50.0, 25.0, 37.5));
let mut s = session();
let undo = s.history.undo.len();
let r = s.execute("keys.audioToKeyframes", json!({})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1);
let l = layer(&s, r["layer"].as_u64().unwrap());
assert_eq!(l.name, "Audio Amplitude");
let fx: Vec<_> = l.props.sub("effects").unwrap().groups().map(|g| (g.name.clone(), g.get("slider").unwrap().keys.len())).collect();
assert_eq!(fx, [("Left Channel".to_string(), 20), ("Right Channel".to_string(), 20), ("Both Channels".to_string(), 20)]);
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layers.is_empty());
}
#[test]
fn keyframe_labels_and_label_groups() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
for t in [0.0, 0.5, 1.0] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": 50})).unwrap();
}
let uid = layer(&s, a).props.prop("transform/opacity").unwrap().uid;
let k = |t: f64| KeyRef { layer: LayerId(a), prop: uid, time: Tick::from_seconds_f64(t) };
s.state.selected_keys = vec![k(0.0), k(1.0)];
// Edit ▸ Label colours the selected keyframes.
s.execute("edit.label", json!({"label": "Blue"})).unwrap();
let labels: Vec<u8> = layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.label).collect();
let blue = effectcraft_color::Label::ALL.iter().position(|l| *l == effectcraft_color::Label::Blue).unwrap() as u8;
assert_eq!(labels, [blue, 0, blue]);
assert_eq!(layer(&s, a).label, effectcraft_color::Label::Red, "the layer keeps its label");
// Serde: labels round-trip; unlabeled keys don't write the field.
let json = s.project.to_json();
assert!(json.contains(&format!("\"label\": {blue}")) || json.contains(&format!("\"label\":{blue}")));
let back = effectcraft_project::Project::from_json(&json).unwrap();
assert_eq!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[2].label, blue);
// Moving a labelled key's value keeps the label.
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 80})).unwrap();
assert_eq!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[2].label, blue);
// Select Keyframe Label Group.
s.state.selected_keys = vec![k(0.0)];
let r = s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap();
assert_eq!(r["keys"], 2);
s.state.selected_keys = vec![k(0.5)];
s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap();
assert_eq!(s.state.selected_keys, vec![k(0.5)]);
s.state.selected_keys = vec![k(0.0)];
let r = s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [12345]})).unwrap();
assert_eq!(r["keys"], 0, "only the given (visible) properties");
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().all(|k| k.label == 0));
}
#[test]
fn rpf_camera_import_reads_json_and_csv_camera_data() {
let mut s = session();
let json_path = tmp("cam.json");
std::fs::write(
&json_path,
r#"{"frameRate": 10, "frames": [{"frame": 0, "position": [0, 0, -500], "rotation": [0, 0, 0], "fov": 90}, {"frame": 10, "position": [100, 0, -500], "orientation": [0, 30, 0], "zoom": 400}]}"#,
)
.unwrap();
let undo = s.history.undo.len();
let r = s.execute("keys.rpfCameraImport", json!({"path": json_path.to_string_lossy()})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1);
let cam = layer(&s, r["layer"].as_u64().unwrap());
assert!(cam.is_camera());
let pos = &cam.props.prop("transform/position").unwrap().keys;
assert_eq!(pos.len(), 2);
assert_eq!(pos[1].value, KV::Vec3([100.0, 0.0, -500.0]));
assert!((pos[1].time.seconds() - 1.0).abs() < 1e-9);
let zoom = &cam.props.prop("cameraOptions/zoom").unwrap().keys;
assert!((zoom[0].value.as_f64() - 32.0).abs() < 1e-6, "90° across 64 px = zoom 32: {:?}", zoom[0].value);
let csv = tmp("cam.csv");
std::fs::write(&csv, "frame,px,py,pz,rx,ry,rz,zoom\n0,1,2,3,0,0,0,100\n5,4,5,6,10,20,30,200\n").unwrap();
let r = s.execute("keys.rpfCameraImport", json!({"path": csv.to_string_lossy()})).unwrap();
let cam = layer(&s, r["layer"].as_u64().unwrap());
let o = &cam.props.prop("transform/orientation").unwrap().keys;
assert_eq!(o[1].value, KV::Vec3([10.0, 20.0, 30.0]));
assert!(s.execute("keys.rpfCameraImport", json!({"path": "/x/scene.rpf"})).unwrap_err().to_string().contains("JSON or CSV"));
}
// ---------------------------------------------------------------- menus
#[test]
fn dynamic_menus_and_window_viewer_labels() {
let mut s = session();
s.prefs.push_recent("/p/One.ecproj");
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent"));
assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len());
let a = solid(&mut s, "#ff0000");
s.state.selected_layers = vec![LayerId(a)];
let entry = |cmd: &str, panel: &str| crate::menus::MenuEntry { label: "x".into(), command: cmd.into(), params: json!({"panel": panel}), shortcut: None };
assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "composition")), "Composition: Main");
assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "timeline")), "Timeline: Main");
assert!(crate::menus::entry_label(&s, &entry("window.panel", "layer")).starts_with("Layer: "));
assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "footage")), "Footage: (none)");
// Other open comps are listed as viewers.
let other = s.execute("comp.new", json!({"name": "Second"})).unwrap()["comp"].as_u64().unwrap();
let (e, _) = crate::menus::dynamic(&s, "openViewers", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Composition: Main"]);
assert_eq!(e[0].command, "comp.open");
let _ = other;
// Every tree entry of the new menus resolves.
for (path, e) in crate::menus::entries() {
if ["History", "VR", "Recent Animation Presets", "Import Recent Footage"].iter().any(|p| path.contains(&p.to_string())) {
assert!(crate::commands::find(&e.command).is_some());
}
}
let labels: Vec<String> = crate::menus::entries().iter().map(|(_, e)| e.label.clone()).collect();
for l in [
"Save a Copy As XML...",
"With Layered Comp",
"Create VR Environment...",
"Extract Cubemap...",
"System Compatibility Report...",
"Enable Logging",
"Reveal Logging File",
] {
assert!(labels.iter().any(|x| x == l), "{l}");
}
}
+1 -1
View File
@@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
for id in ["keys.audioToKeyframes", "layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] {
for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}
+518
View File
@@ -0,0 +1,518 @@
//! M13.6 panels and tools through the commands: Progress (jobs), Media Browser, Metadata,
//! Lumetri Scopes, the Footage panel's Overlay / Ripple Insert edits, Content-Aware Fill, Scene
//! Edit Detection, Auto-trace and Align Video to Data.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use effectcraft_color::Label;
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build};
use effectcraft_raster::Image;
use effectcraft_raster::inpaint::psnr;
use effectcraft_render::FootageSource;
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use crate::Session;
const FPS: u32 = 25;
/// Footage computed per frame.
struct Gen {
make: Box<dyn Fn(u32) -> Image + Send + Sync>,
cache: Mutex<HashMap<u32, Arc<Image>>>,
}
impl FootageSource for Gen {
fn frame(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<Image>> {
let f = (t.seconds() * FPS as f64).round().max(0.0) as u32;
let mut c = self.cache.lock().unwrap();
Some(c.entry(f).or_insert_with(|| Arc::new((self.make)(f))).clone())
}
}
/// A comp `w × h`, `secs` long, with one footage layer drawn by `make` (returns session, comp,
/// layer, footage item).
fn setup(w: u32, h: u32, secs: f64, make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, ItemId, LayerId, ItemId) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Panels", "width": w, "height": h, "frameRate": FPS, "duration": secs})).unwrap();
let cid = s.active_comp_id().unwrap();
let (lid, item) = s
.edit("clip", None, |p, _| {
let footage = Footage {
path: "synthetic.mov".into(),
kind: FootageKind::Video,
width: w,
height: h,
frame_rate: FrameRate::new(FPS as i64, 1),
duration: Tick::from_seconds_f64(secs),
has_video: true,
codec: "SYN".into(),
..Default::default()
};
let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage));
let comp = p.comp(cid).unwrap().clone();
let l = build::layer(p, &comp, "Clip", LayerSource::Footage { item }, (w, h), None);
let id = l.id;
p.comp_mut(cid).unwrap().layers.insert(0, l);
Ok((id, item))
})
.unwrap();
s.footage = Arc::new(Gen { make: Box::new(make), cache: Mutex::default() });
s.execute("layer.select", json!({"layers": [lid.0]})).unwrap();
(s, cid, lid, item)
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("ec-panels-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
// ---------------------------------------------------------------- Progress
#[test]
fn progress_lists_and_cancels_jobs() {
let mut s = Session::default();
let r = s
.spawn_task("test", "Endless job", false, |ctl| {
let mut k = 0;
while ctl.progress(k % 100, 100) {
std::thread::sleep(std::time::Duration::from_millis(2));
k += 1;
}
Err(crate::render_queue::CANCELLED.into())
})
.unwrap();
let id = r["job"].as_str().unwrap().to_string();
let listed = s.execute("jobs.list", json!({})).unwrap();
let running = listed["running"].as_array().unwrap();
assert_eq!(running.len(), 1);
assert_eq!(running[0]["id"], json!(id));
assert_eq!(running[0]["label"], json!("Endless job"));
assert_eq!(running[0]["cancellable"], json!(true));
assert_eq!(s.execute("jobs.cancel", json!({"job": id})).unwrap()["cancelled"], json!(true));
s.execute("jobs.wait", json!({})).unwrap();
assert!(s.jobs().is_empty());
assert_eq!(s.job_log.last().unwrap().status, "cancelled");
// A finished job applies its edit (one undo step) and reports its result.
let v = s
.spawn_task("test", "Quick job", true, |ctl| {
ctl.progress(1, 1);
let apply: crate::jobs::Apply = Box::new(|s: &mut Session| {
s.edit("Quick", None, |p, _| {
p.settings.comment = "done".into();
Ok(())
})?;
Ok(json!(42))
});
Ok(apply)
})
.unwrap();
assert_eq!(v, json!(42));
assert_eq!(s.project.settings.comment, "done");
assert_eq!(s.job_log.last().unwrap().status, "done");
assert!(s.undo());
assert_eq!(s.project.settings.comment, "");
}
// ---------------------------------------------------------------- Media Browser & Metadata
#[test]
fn media_browser_and_metadata() {
let dir = tmp("browser");
std::fs::create_dir_all(dir.join("Footage")).unwrap();
std::fs::write(dir.join("shot.mov"), b"0123").unwrap();
std::fs::write(dir.join("data.csv"), "time,v\n0,1\n").unwrap();
std::fs::write(dir.join("readme.txt"), "x").unwrap();
let d = dir.to_string_lossy().to_string();
let mut s = Session::default();
let r = s.execute("mediaBrowser.go", json!({"path": d})).unwrap();
let names: Vec<&str> = r["entries"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Footage", "data.csv", "readme.txt", "shot.mov"]);
assert_eq!(s.state.media_browser.folder.as_deref(), Some(d.as_str()));
let r = s.execute("mediaBrowser.list", json!({"importableOnly": true})).unwrap();
assert_eq!(r["entries"].as_array().unwrap().len(), 3);
s.execute("mediaBrowser.addFavorite", json!({})).unwrap();
assert_eq!(s.state.media_browser.favorites, vec![d.clone()]);
s.execute("mediaBrowser.go", json!({"path": dir.join("Footage").to_string_lossy()})).unwrap();
s.execute("mediaBrowser.go", json!({"path": ".."})).unwrap();
assert_eq!(s.state.media_browser.folder.as_deref(), Some(d.as_str()));
s.execute("mediaBrowser.removeFavorite", json!({"path": d})).unwrap();
assert!(s.state.media_browser.favorites.is_empty());
// Import a data file from the browser; its metadata reads back.
let r = s.execute("mediaBrowser.import", json!({"paths": [dir.join("data.csv").to_string_lossy()]})).unwrap();
let id = r["items"][0].as_u64().unwrap();
let m = s.execute("item.metadata", json!({"item": id})).unwrap();
assert_eq!(m["item"]["type"], json!("Data"));
assert_eq!(m["item"]["codec"], json!("CSV"));
assert_eq!(m["item"]["dataRows"], json!(1));
assert!(m["item"]["modified"].as_str().unwrap().ends_with('Z'));
assert_eq!(m["project"]["footage"], json!(1));
// Editable comments: item (undoable) and project.
s.execute("project.setComment", json!({"items": [id], "comment": "telemetry"})).unwrap();
s.execute("project.setProjectComment", json!({"comment": "client cut"})).unwrap();
let m = s.execute("item.metadata", json!({"item": id})).unwrap();
assert_eq!(m["item"]["comment"], json!("telemetry"));
assert_eq!(m["project"]["comment"], json!("client cut"));
s.undo();
assert_eq!(s.project.settings.comment, "");
let fi = s.execute("mediaBrowser.fileInfo", json!({"path": dir.join("shot.mov").to_string_lossy()})).unwrap();
assert_eq!(fi["size"], json!(4));
assert_eq!(fi["kind"], json!("video"));
std::fs::remove_dir_all(&dir).unwrap();
}
// ---------------------------------------------------------------- Lumetri Scopes
#[test]
fn scopes_read_the_comp_frame() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 64, "height": 48, "frameRate": 25, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let v = s.execute("scopes.analyze", json!({"scope": "vectorscopeYuv", "size": 101})).unwrap();
let st = effectcraft_raster::scopes::ColorStandard::Rec709;
let [_, cb, cr] = st.ycbcr([1.0, 0.0, 0.0]);
assert_eq!(v["peaks"][0], json!([((cb + 0.5) * 100.0).round() as u32, ((0.5 - cr) * 100.0).round() as u32]));
let h = s.execute("scopes.analyze", json!({"scope": "histogram", "standard": "rec601"})).unwrap();
let bars = &h["histogram"];
assert_eq!(bars[0][255], json!(64 * 48));
assert_eq!(bars[1][0], json!(64 * 48));
assert!((h["luma"]["mean"].as_f64().unwrap() - 0.299).abs() < 1e-3);
assert!(s.execute("scopes.analyze", json!({"scope": "bogus"})).is_err());
}
// ---------------------------------------------------------------- Footage panel
#[test]
fn footage_panel_overlay_and_ripple_insert() {
let (mut s, cid, clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
s.execute("footage.open", json!({"item": item.0})).unwrap();
assert!(
s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, params } if command == "window.panel" && params["panel"] == "footage"))
);
s.execute("footage.setTime", json!({"time": 1.0})).unwrap();
s.execute("footage.setIn", json!({})).unwrap();
s.execute("footage.setOut", json!({"frame": 49})).unwrap();
let info = s.execute("footage.info", json!({})).unwrap();
assert_eq!(info["in"], json!(1.0));
assert_eq!(info["out"], json!(2.0));
// Overlay Edit at 0.6 s: a 1 s layer on top showing source 1–2 s.
s.set_time(Tick::from_seconds_f64(0.6));
let r = s.execute("footage.overlayEdit", json!({})).unwrap();
let ov = LayerId(r["layer"].as_u64().unwrap());
let comp = s.project.comp(cid).unwrap();
assert_eq!(comp.layers[0].id, ov);
let l = comp.layer(ov).unwrap();
assert_eq!((l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds()), (0.6, 1.6, -0.4));
assert_eq!(comp.layer(clip).unwrap().out_point.seconds(), 8.0, "overlay leaves other layers alone");
// Ripple Insert at 3 s: layers crossing 3 s are split, the rest moves back by 1 s.
s.set_time(Tick::from_seconds_f64(3.0));
let r = s.execute("footage.rippleInsertEdit", json!({})).unwrap();
let ins = LayerId(r["layer"].as_u64().unwrap());
let comp = s.project.comp(cid).unwrap();
assert_eq!(comp.layers.len(), 4, "overlay, insert, clip split in two");
let l = comp.layer(ins).unwrap();
assert_eq!((l.in_point.seconds(), l.out_point.seconds()), (3.0, 4.0));
let head = comp.layer(clip).unwrap();
assert_eq!((head.in_point.seconds(), head.out_point.seconds()), (0.0, 3.0));
let tail = comp.layers.iter().find(|x| x.id != clip && x.id != ins && x.id != ov).unwrap();
assert_eq!((tail.in_point.seconds(), tail.out_point.seconds(), tail.start_time.seconds()), (4.0, 9.0, 1.0));
// Source time continuity: the tail shows source 3 s at comp 4 s.
assert_eq!(tail.layer_time(Tick::from_seconds_f64(4.0)).seconds(), 3.0);
// The overlay layer (in before 3 s, out before 3 s) didn't move.
assert_eq!(comp.layer(ov).unwrap().in_point.seconds(), 0.6);
// One undo step each.
assert!(s.undo());
assert_eq!(s.project.comp(cid).unwrap().layers.len(), 2);
assert!(s.execute("footage.setOut", json!({"time": 0.5})).is_ok());
assert_eq!(s.state.footage_panel.as_ref().unwrap().in_point, None, "an Out before the In clears the In");
}
// ---------------------------------------------------------------- Content-Aware Fill
fn bg(x: f32, y: f32) -> [f32; 4] {
let s = 0.5 + 0.3 * (x * std::f32::consts::TAU / 10.0).sin();
let n = effectcraft_raster::hash_noise(((x.floor() as i64).rem_euclid(20) / 4) as u32, ((y as u32) % 20) / 4, 3);
[s, 0.3 + 0.4 * n, 0.6 - 0.3 * s * n, 1.0]
}
/// Panning textured background with a red "object" walking through the masked region.
fn caf_frame(f: u32, with_object: bool) -> Image {
let (w, h) = (96u32, 64u32);
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let mut p = bg(x as f32 + 2.0 * f as f32, y as f32);
let ox = 40 + (f % 4);
if with_object && (ox..ox + 10).contains(&x) && (26..36).contains(&y) {
p = [0.9, 0.1, 0.1, 1.0];
}
img.set(x, y, p);
}
}
img
}
#[test]
fn content_aware_fill_removes_an_object() {
let (mut s, cid, clip, _) = setup(96, 64, 0.48, |f| caf_frame(f, true));
// Cut the object out with a Subtract mask; fill the work area (12 frames).
s.execute("layer.addMask", json!({"rect": [36.0, 22.0, 20.0, 18.0], "mode": "subtract"})).unwrap();
let out = tmp("caf");
s.execute("contentFill.set", json!({"method": "object", "range": "workArea", "alphaExpansion": 1})).unwrap();
let r = s.execute("contentFill.generate", json!({"outputDir": out.to_string_lossy(), "wait": true})).unwrap();
let fill = LayerId(r["layer"].as_u64().unwrap());
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
assert_eq!(files.len(), 12);
// The Fill layer sits right above the source with the source's timing.
let comp = s.project.comp(cid).unwrap();
let i = comp.layers.iter().position(|l| l.id == fill).unwrap();
assert_eq!(comp.layers[i + 1].id, clip);
let fl = comp.layer(fill).unwrap();
assert_eq!((fl.in_point, fl.out_point.seconds()), (Tick::ZERO, 0.48));
let LayerSource::Footage { item } = fl.source else { panic!("footage") };
let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { panic!() };
assert_eq!((f.kind, f.sequence.len()), (FootageKind::Sequence, 12));
assert!(s.project.item(item).unwrap().name.starts_with("Fill 1"));
// The filled frames match the clean background inside the mask, and stay steady over time.
let region: Vec<bool> = (0..96 * 64).map(|i| (36..56).contains(&(i % 96)) && (22..40).contains(&(i / 96))).collect();
let mut prev: Option<Image> = None;
for (k, path) in files.iter().enumerate() {
let png = image::open(path).unwrap().to_rgba8();
let img = Image::from_rgba8(96, 64, png.as_raw());
let truth = caf_frame(k as u32, false);
let db = psnr(&img, &truth, Some(&region));
assert!(db > 25.0, "frame {k}: {db:.1} dB");
// Outside the hole the frame is the source itself.
let outside: Vec<bool> = region.iter().map(|r| !r).collect();
assert!(psnr(&img, &caf_frame(k as u32, true), Some(&outside)) > 40.0);
if let Some(p) = &prev {
// Temporal consistency: the change in the hole follows the 2 px/frame pan (compare
// against the previous fill shifted).
let mut shifted = p.clone();
for y in 0..64 {
for x in 0..94 {
shifted.data[y * 96 + x] = p.data[y * 96 + x + 2];
}
}
let inner: Vec<bool> = (0..96 * 64).map(|i| (38..54).contains(&(i % 96)) && (24..38).contains(&(i / 96))).collect();
assert!(psnr(&img, &shifted, Some(&inner)) > 24.0, "frame {k} jumps");
}
prev = Some(img);
}
// One undo step removes the fill layer.
assert!(s.undo());
assert!(s.project.comp(cid).unwrap().layer(fill).is_none());
std::fs::remove_dir_all(&out).unwrap();
// Without a hole there is nothing to fill.
let (mut s2, _, _, _) = setup(32, 24, 0.2, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
assert!(s2.execute("contentFill.generate", json!({"wait": true, "outputDir": tmp("caf2").to_string_lossy()})).is_err());
}
#[test]
fn content_aware_fill_runs_in_the_background() {
let (mut s, _, _, _) = setup(48, 32, 0.2, |f| {
let mut img = Image::new(48, 32);
for y in 0..32 {
for x in 0..48 {
img.set(x, y, bg(x as f32 + f as f32, y as f32));
}
}
img
});
s.execute("layer.addMask", json!({"rect": [20.0, 10.0, 8.0, 8.0], "mode": "subtract"})).unwrap();
let out = tmp("cafbg");
let r = s.execute("contentFill.generate", json!({"method": "edgeBlend", "outputDir": out.to_string_lossy()})).unwrap();
assert!(r["job"].as_str().unwrap().starts_with("task:"));
assert_eq!(s.jobs().len(), 1);
assert_eq!(s.jobs()[0].kind, "contentFill");
s.wait_jobs();
let rec = s.job_log.last().unwrap();
assert_eq!(rec.status, "done", "{}", rec.message);
assert_eq!(rec.result["frames"], json!(5));
std::fs::remove_dir_all(&out).unwrap();
}
// ---------------------------------------------------------------- Scene Edit Detection
fn shot_frame(f: u32) -> Image {
// Shots: frames 0–9, 10–17, 18–24.
let (kind, k) = if f < 10 {
(0, f)
} else if f < 18 {
(1, f - 10)
} else {
(2, f - 18)
};
let mut img = Image::new(80, 48);
for y in 0..48 {
for x in 0..80 {
let fx = x as f32 + 2.0 * k as f32;
let v = match kind {
0 => 0.5 + 0.4 * (fx * 0.15).sin() * (y as f32 * 0.11).cos(),
1 => {
if ((fx / 10.0).floor() as i32 + y as i32 / 10) % 2 == 0 {
0.85
} else {
0.15
}
}
_ => effectcraft_raster::hash_noise((fx.floor() as u32) / 4, y / 4, 5),
};
let c = match kind {
0 => [v, v * 0.6, 0.2],
1 => [0.1, v * 0.8, v],
_ => [v * 0.9, v * 0.9, 0.5 + v * 0.4],
};
img.set(x, y, [c[0], c[1], c[2], 1.0]);
}
}
img
}
#[test]
fn scene_edit_detection_finds_cuts() {
let (mut s, cid, clip, _) = setup(80, 48, 1.0, shot_frame);
// Markers.
let r = s.execute("layer.sceneEditDetection", json!({"mode": "markers", "wait": true})).unwrap();
assert_eq!(r["cuts"], json!([0.4, 0.72]));
let l = s.project.comp(cid).unwrap().layer(clip).unwrap();
assert_eq!(l.markers.iter().map(|m| m.time.seconds()).collect::<Vec<_>>(), vec![0.4, 0.72]);
s.undo();
// Split Layers.
let r = s.execute("layer.sceneEditDetection", json!({"mode": "split", "wait": true})).unwrap();
let ids: Vec<u64> = r["layers"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(ids.len(), 3);
let comp = s.project.comp(cid).unwrap();
let spans: Vec<(f64, f64)> = ids.iter().map(|i| comp.layer(LayerId(*i)).unwrap()).map(|l| (l.in_point.seconds(), l.out_point.seconds())).collect();
assert_eq!(spans, vec![(0.0, 0.4), (0.4, 0.72), (0.72, 1.0)]);
s.undo();
assert_eq!(s.project.comp(cid).unwrap().layers.len(), 1);
// Split and Precompose: three precomp layers, one undo step.
let r = s.execute("layer.sceneEditDetection", json!({"mode": "splitPrecompose", "wait": true})).unwrap();
let comp = s.project.comp(cid).unwrap();
assert_eq!(comp.layers.len(), 3);
for v in r["layers"].as_array().unwrap() {
let l = comp.layer(LayerId(v.as_u64().unwrap())).unwrap();
assert!(matches!(l.source, LayerSource::Comp { .. }));
}
assert_eq!(s.history.undo.last().unwrap().0, "Scene Edit Detection");
s.undo();
assert_eq!(s.project.comp(cid).unwrap().layers.len(), 1);
}
// ---------------------------------------------------------------- Auto-trace
fn disc_frame(f: u32) -> Image {
let mut img = Image::new(80, 60);
let (cx, cy) = (30.0 + 2.0 * f as f64, 30.0);
for y in 0..60 {
for x in 0..80 {
let d = ((x as f64 + 0.5 - cx).powi(2) + (y as f64 + 0.5 - cy).powi(2)).sqrt();
// Anti-aliased disc of radius 15 plus a 12 × 12 square, on transparency.
let a = (15.5 - d).clamp(0.0, 1.0) as f32;
let sq = (60..72).contains(&x) && (8..20).contains(&y);
let a = if sq { 1.0 } else { a };
img.set(x, y, [a, a, a, a]);
}
}
img
}
fn iou_with(path_set: &[effectcraft_keyframe::ShapePath], truth: &Image) -> f64 {
let paths: Vec<_> = path_set.iter().map(effectcraft_path::to_kurbo).collect();
let cov = effectcraft_path::fill_coverage(&paths, &effectcraft_geom::Mat3::IDENTITY, truth.width, truth.height, effectcraft_path::FillRule::EvenOdd);
let (mut i, mut u) = (0.0, 0.0);
for (c, p) in cov.data.iter().zip(&truth.data) {
let (a, b) = (*c >= 0.5, p[3] >= 0.5);
i += (a && b) as u8 as f64;
u += (a || b) as u8 as f64;
}
i / u
}
fn mask_paths(s: &Session, cid: ItemId, lid: LayerId, lt: Tick) -> Vec<effectcraft_keyframe::ShapePath> {
let l = s.project.comp(cid).unwrap().layer(lid).unwrap();
l.masks()
.unwrap()
.groups()
.filter(|g| g.get("opacity").unwrap().value_at(lt).as_f64() > 0.0)
.map(|g| match g.get("path").unwrap().value_at(lt) {
effectcraft_project::Value::Path(p) => p,
_ => panic!("path"),
})
.collect()
}
#[test]
fn auto_trace_matches_shapes() {
let (mut s, cid, clip, _) = setup(80, 60, 0.4, disc_frame);
let r = s.execute("layer.autoTrace", json!({"layer": clip.0, "channel": "alpha", "tolerance": 0.5, "cornerRoundness": 30})).unwrap();
assert_eq!(r["masks"], json!(2));
let iou = iou_with(&mask_paths(&s, cid, clip, Tick::ZERO), &disc_frame(0));
assert!(iou > 0.95, "IoU {iou}");
s.undo();
// Work area onto a new layer: one key per frame following the moving disc.
let r = s.execute("layer.autoTrace", json!({"layer": clip.0, "timeSpan": "workArea", "applyToNewLayer": true, "minimumArea": 20})).unwrap();
let nl = LayerId(r["layer"].as_u64().unwrap());
assert_ne!(nl, clip);
assert_eq!(r["frames"], json!(10));
for f in [0u32, 5, 9] {
let t = FrameRate::new(FPS as i64, 1).tick_of(f as i64);
let iou = iou_with(&mask_paths(&s, cid, nl, t), &disc_frame(f));
assert!(iou > 0.93, "frame {f}: IoU {iou}");
}
// Luminance with Invert traces the background instead (one outline with holes).
s.undo();
let r = s.execute("layer.autoTrace", json!({"layer": clip.0, "channel": "luminance", "invert": true, "threshold": 50})).unwrap();
assert!(r["masks"].as_u64().unwrap() >= 1);
}
// ---------------------------------------------------------------- Align Video to Data
#[test]
fn align_video_to_data() {
let (mut s, cid, clip, _) = setup(16, 16, 20.0, |_| Image::filled(16, 16, [0.2, 0.2, 0.2, 1.0]));
let add_data = |s: &mut Session, name: &str, ext: &str, text: &str| -> u64 {
s.edit("data", None, |p, _| {
let f =
Footage { path: format!("{name}.{ext}"), kind: FootageKind::Data, codec: ext.to_uppercase(), data: Some(text.into()), ..Default::default() };
Ok(p.add_item(name, Label::Sandstone, None, ItemKind::Footage(f)).0)
})
.unwrap()
};
let gps = add_data(&mut s, "gps", "csv", "lat,lon,timestamp\n1,2,2026-05-01T10:00:00Z\n1,2,2026-05-01T10:00:01Z\n");
// The video started 5 s after the first sample: it moves 5 s later.
let r = s.execute("layer.alignVideoToData", json!({"data": gps, "videoStart": "2026-05-01T10:00:05Z"})).unwrap();
assert_eq!(r["startTime"], json!(5.0));
let l = s.project.comp(cid).unwrap().layer(clip).unwrap();
assert_eq!((l.start_time.seconds(), l.in_point.seconds()), (5.0, 5.0));
s.undo();
// Data placed at 2 s, timestamps as times of day, video start as timecode.
let log = add_data(&mut s, "log", "json", r#"{"samples": [{"t": "09:59:58.0", "v": 1}, {"t": "09:59:59.0", "v": 2}]}"#);
let r = s.execute("layer.alignVideoToData", json!({"data": "log", "videoStart": "10:00:00:00", "dataStart": 2.0})).unwrap();
assert_eq!(r["key"], json!("t"));
assert_eq!(r["startTime"], json!(4.0));
// A video that started before the data moves earlier (negative start).
let r = s.execute("layer.alignVideoToData", json!({"data": log, "videoStart": "2026-05-01T09:59:57Z"})).unwrap();
assert_eq!(r["startTime"], json!(-1.0));
// Errors: unknown key, no data.
assert!(s.execute("layer.alignVideoToData", json!({"data": gps, "key": "nope", "videoStart": 0})).is_err());
let _: Value = Value::Null;
}
#[test]
fn panels_commands_are_wired() {
// The Window menu opens real panels now, and the former stubs are enabled.
let menus = crate::menus::entries();
for panel in ["lumetriScopes", "footage", "mediaBrowser", "metadata", "progress", "contentAwareFill"] {
assert!(menus.iter().any(|(_, e)| e.command == "window.panel" && e.params["panel"] == panel), "{panel}");
}
assert!(crate::find_command("window.unavailablePanel").is_none());
let (s, _, _, _) = setup(8, 8, 1.0, |_| Image::filled(8, 8, [0.0; 4]));
for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.alignVideoToData", "layer.newContentAwareFill", "contentFill.generate"] {
assert!(s.is_enabled(id), "{id}");
}
}
+1 -1
View File
@@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
}
// What remains a stub is still disabled.
for id in ["layer.autoTrace", "keys.audioToKeyframes"] {
for id in ["layer.autoTrace", "layer.sceneEditDetection"] {
assert!(!s.is_enabled(id), "{id}");
}
}
+265
View File
@@ -0,0 +1,265 @@
//! File ▸ Save a Copy As XML…: the project as an XML document (`.ecprojx`), the counterpart of
//! After Effects' `.aepx`. It is a lossless, element-per-value encoding of the `.ecproj` JSON, so
//! File ▸ Open reads it back (and saving to a `.ecprojx` path writes XML again):
//!
//! ```xml
//! <?xml version="1.0" encoding="UTF-8"?>
//! <EffectCraftProject encoding="json" version="1">
//! <obj>
//! <m n="version"><num>3</num></m>
//! <m n="items"><arr>…</arr></m>
//! </obj>
//! </EffectCraftProject>
//! ```
//!
//! `obj`/`m n="key"` (object members), `arr`, `str`, `num`, `bool`, `null`.
use serde_json::{Map, Value};
const ROOT: &str = "EffectCraftProject";
/// Whether `text` looks like an XML project.
pub fn is_xml(text: &str) -> bool {
let t = text.trim_start_matches('\u{feff}').trim_start();
t.starts_with("<?xml") || t.starts_with(&format!("<{ROOT}"))
}
/// Whether a path names an XML project copy.
pub fn is_xml_path(path: &str) -> bool {
let p = path.to_ascii_lowercase();
p.ends_with(".ecprojx") || p.ends_with(".xml")
}
fn esc(s: &str, out: &mut String) {
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\r' => out.push_str("&#13;"),
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => out.push_str(&format!("&#{};", c as u32)),
c => out.push(c),
}
}
}
fn write(v: &Value, depth: usize, out: &mut String) {
let pad = " ".repeat(depth);
out.push_str(&pad);
match v {
Value::Null => out.push_str("<null/>"),
Value::Bool(b) => out.push_str(&format!("<bool>{b}</bool>")),
Value::Number(n) => out.push_str(&format!("<num>{n}</num>")),
Value::String(s) => {
out.push_str("<str>");
esc(s, out);
out.push_str("</str>");
}
Value::Array(a) if a.is_empty() => out.push_str("<arr/>"),
Value::Array(a) => {
out.push_str("<arr>\n");
for x in a {
write(x, depth + 1, out);
}
out.push_str(&pad);
out.push_str("</arr>");
}
Value::Object(m) if m.is_empty() => out.push_str("<obj/>"),
Value::Object(m) => {
out.push_str("<obj>\n");
for (k, x) in m {
out.push_str(&" ".repeat(depth + 1));
out.push_str("<m n=\"");
esc(k, out);
out.push_str("\">\n");
write(x, depth + 2, out);
out.push_str(&" ".repeat(depth + 1));
out.push_str("</m>\n");
}
out.push_str(&pad);
out.push_str("</obj>");
}
}
out.push('\n');
}
/// The project JSON as an XML document.
pub fn to_xml(json: &str) -> Result<String, String> {
let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
let mut out = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<{ROOT} encoding=\"json\" version=\"1\">\n");
write(&v, 1, &mut out);
out.push_str(&format!("</{ROOT}>\n"));
Ok(out)
}
struct P<'a> {
s: &'a str,
i: usize,
}
impl<'a> P<'a> {
fn err<T>(&self, m: &str) -> Result<T, String> {
Err(format!("XML project: {m} at byte {}", self.i))
}
fn rest(&self) -> &'a str {
&self.s[self.i..]
}
/// Skip whitespace, comments and processing instructions.
fn skip(&mut self) {
loop {
let t = self.rest();
let trimmed = t.trim_start();
self.i += t.len() - trimmed.len();
if trimmed.starts_with("<?") {
self.i += trimmed.find("?>").map_or(trimmed.len(), |e| e + 2);
} else if trimmed.starts_with("<!--") {
self.i += trimmed.find("-->").map_or(trimmed.len(), |e| e + 3);
} else {
return;
}
}
}
/// An opening tag: (name, attribute `n`, self-closing).
fn open(&mut self) -> Result<(String, Option<String>, bool), String> {
self.skip();
if !self.rest().starts_with('<') || self.rest().starts_with("</") {
return self.err("expected an element");
}
let end = self.rest().find('>').ok_or("XML project: unterminated tag")?;
let inner = &self.rest()[1..end];
self.i += end + 1;
let (inner, closed) = match inner.strip_suffix('/') {
Some(x) => (x, true),
None => (inner, false),
};
let mut parts = inner.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or_default().to_string();
let attrs = parts.next().unwrap_or("");
let n = attrs.find("n=\"").map(|a| {
let v = &attrs[a + 3..];
unesc(&v[..v.find('"').unwrap_or(v.len())])
});
Ok((name, n, closed))
}
fn close(&mut self, name: &str) -> Result<(), String> {
self.skip();
let want = format!("</{name}>");
if self.rest().starts_with(&want) {
self.i += want.len();
Ok(())
} else {
self.err(&format!("expected {want}"))
}
}
fn text(&mut self) -> String {
let end = self.rest().find('<').unwrap_or(self.rest().len());
let t = unesc(&self.rest()[..end]);
self.i += end;
t
}
fn value(&mut self) -> Result<Value, String> {
let (name, _, closed) = self.open()?;
let v = match (name.as_str(), closed) {
("null", _) => Value::Null,
("arr", true) => Value::Array(vec![]),
("obj", true) => Value::Object(Map::new()),
("str", true) => Value::String(String::new()),
("bool", false) => Value::Bool(self.text().trim() == "true"),
("num", false) => serde_json::from_str(self.text().trim()).map_err(|e| format!("XML project: bad number: {e}"))?,
("str", false) => Value::String(self.text()),
("arr", false) => {
let mut a = vec![];
loop {
self.skip();
if self.rest().starts_with("</") {
break;
}
a.push(self.value()?);
}
Value::Array(a)
}
("obj", false) => {
let mut m = Map::new();
loop {
self.skip();
if self.rest().starts_with("</") {
break;
}
let (tag, key, _) = self.open()?;
if tag != "m" {
return self.err("expected <m n=\"…\">");
}
let v = self.value()?;
self.close("m")?;
m.insert(key.unwrap_or_default(), v);
}
Value::Object(m)
}
(other, _) => return self.err(&format!("unexpected <{other}>")),
};
if !closed && name != "null" {
self.close(&name)?;
}
Ok(v)
}
}
fn unesc(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(a) = rest.find('&') {
out.push_str(&rest[..a]);
rest = &rest[a..];
let Some(e) = rest.find(';') else { break };
let ent = &rest[1..e];
match ent {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
n if n.starts_with("#x") => out.extend(u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32)),
n if n.starts_with('#') => out.extend(n[1..].parse::<u32>().ok().and_then(char::from_u32)),
other => {
out.push('&');
out.push_str(other);
out.push(';');
}
}
rest = &rest[e + 1..];
}
out.push_str(rest);
out
}
/// The project JSON of an XML project document.
pub fn from_xml(text: &str) -> Result<String, String> {
let mut p = P { s: text.trim_start_matches('\u{feff}'), i: 0 };
let (root, _, _) = p.open()?;
if root != ROOT {
return Err(format!("not an EffectCraft XML project (root <{root}>)"));
}
let v = p.value()?;
p.close(ROOT)?;
serde_json::to_string(&v).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_round_trips_through_xml() {
let j = serde_json::json!({"a": [1, 2.5, -3e-7, true, null, "x<y & \"z\"\n\tq"], "b": {}, "c": [], "d": "", "é": {"n": "ünï"}});
let xml = to_xml(&j.to_string()).unwrap();
assert!(is_xml(&xml));
assert!(xml.contains("<m n=\"a\">"));
let back: Value = serde_json::from_str(&from_xml(&xml).unwrap()).unwrap();
assert_eq!(back, j);
assert!(from_xml("<Other/>").is_err());
}
}