Merge main into M13.2

This commit is contained in:
Brandon Thomas 2026-10-03 04:19:01 -04:00
commit 7ac5317ce9
86 changed files with 10306 additions and 325 deletions

View File

@ -47,7 +47,15 @@ fn device(name: &str) -> Option<cpal::Device> {
pub fn open(out: &AudioOutput) -> Option<Box<dyn AudioDevice>> {
let dev = device(&out.device)?;
let cfg = dev.default_output_config().ok()?;
Some(Box::new(CpalOut { out: out.clone(), rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
// Settings ▸ Audio ▸ Preview Sample Rate: open the device at that rate when it supports it
// (otherwise the preview is mixed at that rate and resampled to the device's).
let supports = |r: u32| {
dev.supported_output_configs()
.map(|mut c| c.any(|c| c.channels() == cfg.channels() && c.min_sample_rate().0 <= r && r <= c.max_sample_rate().0))
.unwrap_or(false)
};
let rate = if out.rate > 0 && supports(out.rate) { out.rate } else { cfg.sample_rate().0 };
Some(Box::new(CpalOut { out: out.clone(), rate, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
}
fn run<T: SizedSample + FromSample<f32>>(

View File

@ -24,6 +24,8 @@ fn app_icon() -> egui::IconData {
}
fn main() -> eframe::Result {
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
effectcraft_engine::logging::install();
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
@ -94,8 +96,9 @@ fn main() -> eframe::Result {
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_open_project =
Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_open_project = Some(Box::new(|| {
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.audio_device = Some(Box::new(audio_out::open));
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));

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());
}
}

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"]

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))),
]
}

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
)]
}

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,
},
]
}

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),

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
),

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)
),
]
}

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!(

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
),
]
}

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.

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() {

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 {

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))
}

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()))
}

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
),
]
}

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,

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())

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
)]
}

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
crates/engine/src/jobs.rs Normal file
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();
}
}
}
}

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;

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()
}

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");
}
}

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);
}
}

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
---

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);

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."),
],
},
]

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 |_| {});

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).

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());
}
}

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}");
}
}

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}");
}

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}");
}
}

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}");
}
}

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());
}
}

View File

@ -69,6 +69,8 @@ pub struct Job<'a> {
pub path: &'a str,
/// Hand finished files to this instead of writing them to disk (web downloads, tests).
pub sink: Option<&'a Sink>,
/// Settings ▸ General ▸ Switches Affect Nested Comps (see `RenderOpts::nested_switches`).
pub nested_switches: bool,
}
/// Progress after a batch of frames.
@ -182,6 +184,8 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
view: None,
backend: effectcraft_render::Backend::Auto,
roi: None,
nested_switches: job.nested_switches,
draft_shadows: true,
proxy: job.settings.proxy_use,
};
let mut r = Renderer::new(job.project, job.footage, opts);

View File

@ -55,7 +55,7 @@ fn settings() -> RenderSettings {
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
let s = settings();
let path = path.to_string_lossy().to_string();
let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None };
let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None, nested_switches: true };
let mut last = Progress::default();
let mut calls = 0;
let r = export(&job, &mut |pr| {
@ -167,7 +167,8 @@ fn h264_odd_size_is_cropped_even() {
let comp = p.comp(cid).expect("comp");
assert_eq!(s.output_size(comp).0 % 2, 1);
let path = d.join("odd.mp4").to_string_lossy().to_string();
let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None };
let job =
Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true };
let r = export(&job, &mut |_| true).expect("export");
assert_eq!((r.width % 2, r.height % 2), (0, 0));
}
@ -287,7 +288,8 @@ fn cancel_stops_early() {
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = settings();
let path = d.join("c_[#####].png").to_string_lossy().to_string();
let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None };
let job =
Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true };
let r = export(&job, &mut |_| false);
assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled)));
let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0);

View File

@ -40,6 +40,7 @@ impl Exporter for FileExporter {
output: &job.item.output,
path: job.path,
sink: self.sink.as_deref(),
nested_switches: job.nested_switches,
};
match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) {
Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }),

View File

@ -520,6 +520,7 @@ pub fn set_key(keys: &mut Vec<Keyframe>, key: Keyframe) -> usize {
k.spatial_in = old.spatial_in;
k.spatial_out = old.spatial_out;
k.spatial_auto = old.spatial_auto;
k.label = old.label;
keys[i] = k;
i
}

View File

@ -131,6 +131,12 @@ struct Inner {
hits: AtomicU64,
misses: AtomicU64,
prefetched: AtomicU64,
/// Settings ▸ Disk ▸ Conformed Audio Folder: decoded audio tracks written once per file and
/// rate, read back instead of decoding again (`None` = off).
conform: Mutex<Option<std::path::PathBuf>>,
/// Conformed files being written right now.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
conforming: Mutex<HashSet<std::path::PathBuf>>,
}
/// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus
@ -193,6 +199,8 @@ impl MediaPool {
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
prefetched: AtomicU64::new(0),
conform: Mutex::default(),
conforming: Mutex::default(),
}),
}
}
@ -257,14 +265,108 @@ impl MediaPool {
Inner::frame_at(&self.inner, footage, t, true)
}
/// Settings ▸ Disk ▸ Conformed Audio Folder: write each footage file's decoded audio there
/// once per sample rate (raw interleaved stereo `f32`) and read it back instead of decoding
/// again. `None` turns it off.
pub fn set_conform_folder(&self, folder: Option<std::path::PathBuf>) {
*lock(&self.inner.conform) = folder;
}
/// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and
/// modification time, so an edited file conforms again).
pub fn conformed_path(&self, path: &str, rate: u32) -> Option<std::path::PathBuf> {
use std::hash::{Hash, Hasher};
let folder = lock(&self.inner.conform).clone()?;
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(format!("{:016x}_{rate}.ecaf", h.finish())))
}
/// Samples from a conformed file: `Some` when it exists and covers the request.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn read_conformed(file: &std::path::Path, s0: usize, frames: usize) -> Option<Vec<f32>> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(file).ok()?;
let len = f.metadata().ok()?.len() as usize / 8;
let mut out = vec![0.0f32; frames * 2];
if s0 >= len {
return Some(out);
}
let n = frames.min(len - s0);
f.seek(SeekFrom::Start(s0 as u64 * 8)).ok()?;
let mut bytes = vec![0u8; n * 8];
f.read_exact(&mut bytes).ok()?;
for (o, c) in out.iter_mut().zip(bytes.chunks_exact(4)) {
*o = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
}
Some(out)
}
/// Conform `footage`'s audio at `rate` into `file` on a worker thread (once).
#[cfg(not(target_arch = "wasm32"))]
fn conform(&self, footage: &Footage, file: std::path::PathBuf, rate: u32) {
if !lock(&self.inner.conforming).insert(file.clone()) {
return;
}
let (pool, f) = (self.clone(), footage.clone());
let spawned = std::thread::Builder::new().name("ec-conform-audio".into()).spawn(move || {
let frames = f.duration.to_units_floor(rate as i64).max(0) as usize;
let mut bytes = Vec::with_capacity(frames * 8);
let mut at = 0usize;
while at < frames {
let n = (frames - at).min(1 << 16);
let t = Tick(at as i64 * TICKS_PER_SECOND / rate as i64);
for v in pool.decode_audio(&f, t, n, rate) {
bytes.extend_from_slice(&v.to_le_bytes());
}
at += n;
}
let ok = file.parent().is_some_and(|d| std::fs::create_dir_all(d).is_ok()) && {
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, &bytes).is_ok() && std::fs::rename(&tmp, &file).is_ok()
};
if !ok {
log::warn!("media: could not write conformed audio {}", file.display());
}
lock(&pool.inner.conforming).remove(&file);
});
if spawned.is_err() {
lock(&self.inner.conforming).clear();
}
}
/// `frames` stereo sample frames of `footage`'s audio starting at source time `start`, at
/// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; channels beyond
/// the first two are dropped. Silence where there is no audio (or before the media starts).
/// With a conformed audio folder, reads come from the conformed file once it is written.
pub fn audio_samples(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
let mut out = vec![0.0; frames * 2];
if !footage.has_audio || footage.missing || rate == 0 || frames == 0 {
return out;
return vec![0.0; frames * 2];
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(file) = self.conformed_path(&footage.path, rate) {
let s0 = start.to_units_floor(rate as i64);
if file.exists() && !lock(&self.inner.conforming).contains(&file) {
let skip = (-s0).max(0) as usize;
if skip >= frames {
return vec![0.0; frames * 2];
}
if let Some(v) = Self::read_conformed(&file, s0.max(0) as usize, frames - skip) {
let mut out = vec![0.0; skip * 2];
out.extend(v);
return out;
}
} else {
self.conform(footage, file, rate);
}
}
self.decode_audio(footage, start, frames, rate)
}
/// [`MediaPool::audio_samples`] straight from the decoder.
fn decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
let mut out = vec![0.0; frames * 2];
let path: Arc<str> = footage.path.as_str().into();
let Some(src) = self.inner.source(&path) else { return out };
let s0 = start.to_units_floor(rate as i64);
@ -487,6 +589,9 @@ impl Inner {
}
impl FootageSource for MediaPool {
fn set_conform_folder(&self, folder: Option<std::path::PathBuf>) {
MediaPool::set_conform_folder(self, folder);
}
fn model(&self, _item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
if footage.missing || footage.kind != FootageKind::Model {
return None;

View File

@ -11,6 +11,7 @@ mod fit;
pub mod interp;
pub mod offset;
pub mod ops;
pub mod trace;
pub mod varstroke;
use effectcraft_geom::Mat3;

417
crates/path/src/trace.rs Normal file
View File

@ -0,0 +1,417 @@
//! Auto-trace: outlines of a scalar field (a layer's alpha, a colour channel or luminance) as
//! Bezier paths (Layer ▸ Auto-trace).
//!
//! 1. **Marching squares** over the samples at pixel centres, with linear interpolation of the
//! threshold crossing on each cell edge (saddles resolved by the cell's mean), on a field padded
//! with "outside" so every contour closes.
//! 2. **Douglas–Peucker** simplification of each closed loop to the Tolerance (pixels).
//! 3. **Bezier fitting**: each simplified span is fitted with one cubic (least squares with fixed
//! end tangents, Newton reparameterisation; [`crate::fit`]). The tangent at each vertex is the
//! chord direction at Corner Roundness 0 (straight segments, sharp corners) and the smooth
//! (Catmull–Rom) direction at 100 %, blended between.
//!
//! Loops smaller than Minimum Area are dropped. Each loop gets its nesting depth (0 = outermost),
//! so callers can add outer outlines and subtract holes.
use std::collections::HashMap;
use effectcraft_keyframe::ShapePath;
use kurbo::{Point, Vec2};
use crate::fit::{fit_single, unit};
/// Auto-trace options.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TraceOpts {
/// Inside where the field is at least this (0…1).
pub threshold: f32,
/// Douglas–Peucker tolerance in pixels.
pub tolerance: f64,
/// Loops with a smaller area (square pixels) are dropped.
pub min_area: f64,
/// 0 = polygons, 1 = fully smooth.
pub corner_roundness: f64,
}
impl Default for TraceOpts {
fn default() -> Self {
TraceOpts { threshold: 0.5, tolerance: 1.0, min_area: 10.0, corner_roundness: 0.5 }
}
}
/// A traced outline.
#[derive(Clone, Debug, PartialEq)]
pub struct Traced {
pub path: ShapePath,
/// Number of other outlines that contain this one (even = filled, odd = hole).
pub depth: usize,
/// Signed area of the simplified polygon (pixels²).
pub area: f64,
}
/// Trace `field` (`w × h`, row-major, sample (x, y) at pixel centre (x + 0.5, y + 0.5)).
pub fn trace(field: &[f32], w: usize, h: usize, o: &TraceOpts) -> Vec<Traced> {
if w == 0 || h == 0 || field.len() < w * h {
return vec![];
}
let loops = contours(field, w, h, o.threshold);
let mut polys: Vec<(Vec<[f64; 2]>, Vec<[f64; 2]>, f64)> = loops
.into_iter()
.filter_map(|raw| {
let a = signed_area(&raw);
if a.abs() < o.min_area.max(0.0) || raw.len() < 3 {
return None;
}
let simp = simplify_closed(&raw, o.tolerance.max(0.01));
if simp.len() < 3 {
return None;
}
Some((raw, simp, a))
})
.collect();
// Depth: how many other loops contain a point of this loop.
let depths: Vec<usize> = (0..polys.len())
.map(|i| {
let p = polys[i].1[0];
(0..polys.len()).filter(|&j| j != i && polys[j].2.abs() > polys[i].2.abs() && point_in_poly(p, &polys[j].1)).count()
})
.collect();
let mut out: Vec<Traced> = polys
.drain(..)
.zip(depths)
.map(|((raw, simp, a), depth)| Traced { path: fit_loop(&raw, &simp, o.corner_roundness.clamp(0.0, 1.0)), depth, area: a })
.collect();
// Outer outlines first, then holes, islands…; larger first within a depth.
out.sort_by(|a, b| a.depth.cmp(&b.depth).then(b.area.abs().total_cmp(&a.area.abs())));
out
}
/// Marching squares: closed polylines (pixel coordinates).
fn contours(field: &[f32], w: usize, h: usize, thr: f32) -> Vec<Vec<[f64; 2]>> {
// Padded grid: sample (i, j) of the padded grid is pixel (i - 1, j - 1), outside = -inf.
let (gw, gh) = (w + 2, h + 2);
let val = |i: usize, j: usize| -> f32 { if i == 0 || j == 0 || i > w || j > h { f32::NEG_INFINITY } else { field[(j - 1) * w + (i - 1)] } };
let inside = |i: usize, j: usize| val(i, j) >= thr;
// Edge ids: horizontal edge (i, j)-(i+1, j) = 2 * (j * gw + i); vertical (i, j)-(i, j+1) = +1.
let pos = |i: usize, j: usize| [i as f64 - 0.5, j as f64 - 0.5];
let cross = |a: (usize, usize), b: (usize, usize)| -> [f64; 2] {
let (va, vb) = (val(a.0, a.1), val(b.0, b.1));
let t = if va.is_finite() && vb.is_finite() && (vb - va).abs() > 1e-12 {
((thr - va) / (vb - va)).clamp(0.0, 1.0) as f64
} else if va.is_finite() {
// b is padding: the crossing sits half a pixel out.
if va >= thr { 0.5 } else { 0.0 }
} else if vb >= thr {
0.5
} else {
1.0
};
let (pa, pb) = (pos(a.0, a.1), pos(b.0, b.1));
[pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t]
};
let hid = |i: usize, j: usize| 2 * (j * gw + i);
let vid = |i: usize, j: usize| 2 * (j * gw + i) + 1;
// Directed segments (from edge, to edge) keeping the inside on the left.
let mut next: HashMap<usize, usize> = HashMap::new();
let mut point: HashMap<usize, [f64; 2]> = HashMap::new();
for j in 0..gh - 1 {
for i in 0..gw - 1 {
// Corners: tl (i, j), tr (i+1, j), br (i+1, j+1), bl (i, j+1).
let (tl, tr, br, bl) = (inside(i, j), inside(i + 1, j), inside(i + 1, j + 1), inside(i, j + 1));
let code = (tl as u8) << 3 | (tr as u8) << 2 | (br as u8) << 1 | bl as u8;
if code == 0 || code == 15 {
continue;
}
let top = hid(i, j);
let bottom = hid(i, j + 1);
let left = vid(i, j);
let right = vid(i + 1, j);
let mut seg = |a: usize, b: usize| {
next.insert(a, b);
};
// Walk with the inside on the right in image coordinates (y down), which is
// counter-clockwise on screen for outer loops… orientation is fixed afterwards.
match code {
1 => seg(left, bottom),
2 => seg(bottom, right),
3 => seg(left, right),
4 => seg(right, top),
5 => {
// tr + bl inside (saddle).
let centre = (val(i, j) + val(i + 1, j) + val(i + 1, j + 1) + val(i, j + 1)) / 4.0;
if centre >= thr {
seg(left, top);
seg(right, bottom);
} else {
seg(left, bottom);
seg(right, top);
}
}
6 => seg(bottom, top),
7 => seg(left, top),
8 => seg(top, left),
9 => seg(top, bottom),
10 => {
// tl + br inside (saddle).
let centre = (val(i, j) + val(i + 1, j) + val(i + 1, j + 1) + val(i, j + 1)) / 4.0;
if centre >= thr {
seg(top, right);
seg(bottom, left);
} else {
seg(top, left);
seg(bottom, right);
}
}
11 => seg(top, right),
12 => seg(right, left),
13 => seg(right, bottom),
14 => seg(bottom, left),
_ => {}
}
for (e, a, b) in [(top, (i, j), (i + 1, j)), (bottom, (i, j + 1), (i + 1, j + 1)), (left, (i, j), (i, j + 1)), (right, (i + 1, j), (i + 1, j + 1))]
{
if inside(a.0, a.1) != inside(b.0, b.1) {
point.entry(e).or_insert_with(|| cross(a, b));
}
}
}
}
let mut out = vec![];
let mut keys: Vec<usize> = next.keys().copied().collect();
keys.sort_unstable();
for start in keys {
if !next.contains_key(&start) {
continue;
}
let mut poly = vec![];
let mut e = start;
while let Some(n) = next.remove(&e) {
if let Some(p) = point.get(&e) {
poly.push(*p);
}
e = n;
if e == start {
break;
}
}
if poly.len() >= 3 {
out.push(poly);
}
}
out
}
/// Shoelace area (positive = clockwise on screen with y down).
pub fn signed_area(p: &[[f64; 2]]) -> f64 {
let n = p.len();
let mut a = 0.0;
for i in 0..n {
let (q, r) = (p[i], p[(i + 1) % n]);
a += q[0] * r[1] - r[0] * q[1];
}
a * 0.5
}
fn point_in_poly(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
let mut inside = false;
let n = poly.len();
let mut j = n - 1;
for i in 0..n {
let (a, b) = (poly[i], poly[j]);
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
inside = !inside;
}
j = i;
}
inside
}
fn seg_dist(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
((p[0] - a[0] - t * dx).powi(2) + (p[1] - a[1] - t * dy).powi(2)).sqrt()
}
/// Douglas–Peucker on an open polyline: kept indices.
fn dp(p: &[[f64; 2]], lo: usize, hi: usize, tol: f64, keep: &mut Vec<usize>) {
if hi <= lo + 1 {
return;
}
let (mut best, mut bi) = (0.0, lo);
for (k, q) in p.iter().enumerate().take(hi).skip(lo + 1) {
let d = seg_dist(*q, p[lo], p[hi]);
if d > best {
best = d;
bi = k;
}
}
if best > tol {
dp(p, lo, bi, tol, keep);
keep.push(bi);
dp(p, bi, hi, tol, keep);
}
}
/// Douglas–Peucker on a closed loop: indices into `p` of the kept vertices, in order.
fn simplify_idx(p: &[[f64; 2]], tol: f64) -> Vec<usize> {
let n = p.len();
// Split at the two mutually farthest-ish points (first point and the point farthest from it).
let far = (0..n).max_by(|&a, &b| {
let d = |i: usize| (p[i][0] - p[0][0]).powi(2) + (p[i][1] - p[0][1]).powi(2);
d(a).total_cmp(&d(b))
});
let far = far.unwrap_or(0);
let mut ext: Vec<[f64; 2]> = p.to_vec();
ext.push(p[0]);
let mut keep = vec![0];
dp(&ext, 0, far, tol, &mut keep);
keep.push(far);
dp(&ext, far, n, tol, &mut keep);
keep.sort_unstable();
keep.dedup();
keep.retain(|&i| i < n);
keep
}
fn simplify_closed(p: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> {
simplify_idx(p, tol).into_iter().map(|i| p[i]).collect()
}
/// Fit cubics through the simplified vertices of a loop (`raw` holds the full contour).
fn fit_loop(raw: &[[f64; 2]], simp: &[[f64; 2]], round: f64) -> ShapePath {
let n = simp.len();
if round <= 1e-6 {
return ShapePath::polygon(simp, true);
}
// Raw index of each simplified vertex (they are raw points).
let idx: Vec<usize> = simp.iter().map(|s| raw.iter().position(|r| r == s).unwrap_or(0)).collect();
let pt = |a: [f64; 2]| Point::new(a[0], a[1]);
let v = |a: [f64; 2], b: [f64; 2]| Vec2::new(b[0] - a[0], b[1] - a[1]);
// Per vertex: (direction leaving, direction arriving), blended between chord and smooth.
let dirs: Vec<(Vec2, Vec2)> = (0..n)
.map(|i| {
let (prev, cur, nxt) = (simp[(i + n - 1) % n], simp[i], simp[(i + 1) % n]);
let smooth = unit(v(prev, nxt)).unwrap_or(Vec2::new(1.0, 0.0));
let out_chord = unit(v(cur, nxt)).unwrap_or(smooth);
let in_chord = unit(v(prev, cur)).unwrap_or(smooth);
let leave = unit(out_chord * (1.0 - round) + smooth * round).unwrap_or(out_chord);
let arrive = unit(in_chord * (1.0 - round) + smooth * round).unwrap_or(in_chord);
(leave, arrive)
})
.collect();
let mut path = ShapePath::polygon(simp, true);
for i in 0..n {
let j = (i + 1) % n;
let (a, b) = (idx[i], idx[j]);
let mut pts: Vec<Point> =
if b > a { raw[a..=b].iter().map(|q| pt(*q)).collect() } else { raw[a..].iter().chain(raw[..=b].iter()).map(|q| pt(*q)).collect() };
if pts.len() < 2 {
pts = vec![pt(simp[i]), pt(simp[j])];
}
let chord = pts[0].distance(pts[pts.len() - 1]);
let (t0, t1) = (dirs[i].0, dirs[j].1);
let (c, _, _) = if pts.len() >= 3 { fit_single(&pts, t0, t1) } else { (kurbo::CubicBez::new(pts[0], pts[0], pts[1], pts[1]), 0.0, 0) };
// Keep handles pointing along the tangents and within the chord.
let max = chord * 0.5 * round.max(0.34);
let l0 = (c.p1 - c.p0).dot(t0).clamp(0.0, max);
let l1 = (c.p3 - c.p2).dot(t1).clamp(0.0, max);
let (l0, l1) = if pts.len() < 3 { (chord / 3.0 * round, chord / 3.0 * round) } else { (l0, l1) };
path.out_tangents[i] = [t0.x * l0, t0.y * l0];
path.in_tangents[j] = [-t1.x * l1, -t1.y * l1];
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FillRule, fill_coverage, to_kurbo};
use effectcraft_geom::Mat3;
fn iou(a: &[bool], b: &[f32]) -> f64 {
let (mut i, mut u) = (0.0, 0.0);
for (x, y) in a.iter().zip(b) {
let y = *y >= 0.5;
if *x && y {
i += 1.0;
}
if *x || y {
u += 1.0;
}
}
i / u
}
/// Rasterise traced outlines with even-odd fill (as the Add / Subtract mask stack does).
fn raster(t: &[Traced], w: usize, h: usize) -> Vec<f32> {
let paths: Vec<_> = t.iter().map(|t| to_kurbo(&t.path)).collect();
fill_coverage(&paths, &Mat3::IDENTITY, w as u32, h as u32, FillRule::EvenOdd).data
}
#[test]
fn traces_a_disc_and_a_square_with_a_hole() {
let (w, h) = (120, 80);
let mut field = vec![0.0f32; w * h];
let mut truth = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let disc = (fx - 30.0).powi(2) + (fy - 40.0).powi(2) <= 20.0f64.powi(2);
let square = (60.0..110.0).contains(&fx) && (15.0..65.0).contains(&fy);
let hole = (75.0..95.0).contains(&fx) && (30.0..50.0).contains(&fy);
let on = disc || (square && !hole);
truth[y * w + x] = on;
field[y * w + x] = if on { 1.0 } else { 0.0 };
}
}
for round in [0.0, 0.5, 1.0] {
let t = trace(&field, w, h, &TraceOpts { corner_roundness: round, ..Default::default() });
assert_eq!(t.len(), 3, "disc, square, hole");
assert_eq!(t.iter().filter(|t| t.depth == 1).count(), 1, "one hole");
let r = raster(&t, w, h);
let score = iou(&truth, &r);
// Full roundness rounds the square's corners off as well.
let min = if round >= 1.0 { 0.88 } else { 0.96 };
assert!(score > min, "IoU {score} at roundness {round}");
}
// A disc stays a disc when smooth.
let disc: Vec<f32> =
(0..w * h).map(|i| if ((i % w) as f64 + 0.5 - 30.0).powi(2) + ((i / w) as f64 + 0.5 - 40.0).powi(2) <= 400.0 { 1.0 } else { 0.0 }).collect();
let t = trace(&disc, w, h, &TraceOpts { corner_roundness: 1.0, tolerance: 1.5, ..Default::default() });
let truth_disc: Vec<bool> = disc.iter().map(|v| *v > 0.5).collect();
let score = iou(&truth_disc, &raster(&t, w, h));
assert!(score > 0.97, "disc IoU {score}");
assert!(t[0].path.vertices.len() < 40, "simplified to {}", t[0].path.vertices.len());
// The square traces to 4 corners at zero roundness.
let t = trace(&field, w, h, &TraceOpts { corner_roundness: 0.0, tolerance: 1.0, ..Default::default() });
let sq = t.iter().find(|t| t.depth == 0 && t.path.vertices.iter().all(|v| v[0] > 55.0)).expect("square");
assert!(sq.path.vertices.len() <= 8, "{:?}", sq.path.vertices);
}
#[test]
fn min_area_and_threshold() {
let (w, h) = (40, 40);
let mut field = vec![0.0f32; w * h];
// A 2×2 speck and a 20×20 block at 0.6.
for y in 2..4 {
for x in 2..4 {
field[y * w + x] = 1.0;
}
}
for y in 10..30 {
for x in 10..30 {
field[y * w + x] = 0.6;
}
}
assert_eq!(trace(&field, w, h, &TraceOpts { min_area: 10.0, ..Default::default() }).len(), 1);
assert_eq!(trace(&field, w, h, &TraceOpts { min_area: 1.0, ..Default::default() }).len(), 2);
// Above 0.6 only the speck remains (and it is too small).
assert!(trace(&field, w, h, &TraceOpts { threshold: 0.7, ..Default::default() }).is_empty());
// Edge-touching shapes close along the border.
let full = vec![1.0f32; 100];
let t = trace(&full, 10, 10, &TraceOpts::default());
assert_eq!(t.len(), 1);
assert!((t[0].area.abs() - 100.0).abs() < 2.0, "{}", t[0].area);
}
}

View File

@ -174,6 +174,9 @@ pub struct ProjectSettings {
/// when a GPU adapter exists) or Mercury Software Only (`false`, the CPU compositor).
#[serde(default = "yes")]
pub gpu_acceleration: bool,
/// The project's comment (Metadata panel).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub comment: String,
/// Color Engine (Adobe-style built-in or OCIO with ACES working spaces).
#[serde(default)]
pub color_engine: ColorEngine,
@ -197,6 +200,7 @@ impl Default for ProjectSettings {
frame_start: 0,
audio_sample_rate: 48_000,
gpu_acceleration: true,
comment: String::new(),
color_engine: ColorEngine::Adobe,
hdr: HdrMode::Clip,
output_space: None,

206
crates/raster/src/cuts.rs Normal file
View File

@ -0,0 +1,206 @@
//! Scene edit (cut) detection (Layer ▸ Scene Edit Detection).
//!
//! Each frame is reduced to a [`Signature`]: a joint RGB colour histogram (4 × 4 × 4 bins,
//! normalised) and a small luma thumbnail. Two consecutive frames are compared with
//!
//! - the **histogram distance** (half the L1 distance, 0…1): large when the colour content
//! changes, insensitive to motion;
//! - the **motion-compensated difference**: block optical flow ([`crate::flow::block_flow`])
//! from the earlier thumbnail to the later one, the earlier one warped along it, and the mean
//! absolute luma error that remains. Inside a shot motion explains the change and the error
//! stays small; across a cut nothing matches.
//!
//! The dissimilarity is the geometric mean of the two (both must agree, which rejects flashes and
//! fast pans). A frame starts a new shot when its dissimilarity exceeds the absolute threshold
//! and is a clear local peak: several times the median of its neighbours (the adaptive threshold
//! of the classic shot-boundary literature), with a minimum shot length.
use rayon::prelude::*;
use crate::Image;
use crate::flow::block_flow;
const BINS: usize = 4;
const THUMB: u32 = 96;
/// The per-frame data compared by [`dissimilarity`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Signature {
pub hist: Vec<f32>,
pub thumb: Image,
}
/// Compute a frame's signature.
pub fn signature(img: &Image) -> Signature {
let mut hist = vec![0f32; BINS * BINS * BINS];
let mut n = 0.0;
for p in &img.data {
let a = p[3];
let c = if a > 1e-6 { [p[0] / a * a.min(1.0), p[1] / a * a.min(1.0), p[2] / a * a.min(1.0)] } else { [0.0; 3] };
let b = |v: f32| ((v.clamp(0.0, 1.0) * BINS as f32) as usize).min(BINS - 1);
hist[b(c[0]) * BINS * BINS + b(c[1]) * BINS + b(c[2])] += 1.0;
n += 1.0;
}
if n > 0.0 {
for h in &mut hist {
*h /= n;
}
}
let long = img.width.max(img.height).max(1);
let thumb = if long > THUMB {
let s = THUMB as f64 / long as f64;
crate::resample(img, ((img.width as f64 * s).round() as u32).max(8), ((img.height as f64 * s).round() as u32).max(8))
} else {
img.clone()
};
Signature { hist, thumb }
}
fn luma(p: [f32; 4]) -> f32 {
0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]
}
/// Dissimilarity of consecutive frames, 0 (same) … 1 (unrelated).
pub fn dissimilarity(a: &Signature, b: &Signature) -> f32 {
let hist: f32 = a.hist.iter().zip(&b.hist).map(|(x, y)| (x - y).abs()).sum::<f32>() * 0.5;
let (ta, tb) = (&a.thumb, &b.thumb);
let motion = if ta.width == tb.width && ta.height == tb.height && !ta.is_empty() {
let f = block_flow(tb, ta, 8, 4);
// For each pixel of B, where it came from in A.
let mut err = 0.0f32;
let mut n = 0.0f32;
for y in 0..tb.height {
for x in 0..tb.width {
let v = f.at(x as f64 + 0.5, y as f64 + 0.5);
let pa = ta.sample_bilinear_clamped(x as f64 + 0.5 + v[0] as f64, y as f64 + 0.5 + v[1] as f64);
err += (luma(pa) - luma(tb.data[tb.idx(x, y)])).abs();
n += 1.0;
}
}
// Mean absolute error of 0.25 (a quarter of full scale) counts as completely different.
(err / n.max(1.0) * 4.0).min(1.0)
} else {
1.0
};
(hist * motion).sqrt()
}
/// Cut detection options.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CutOpts {
/// Absolute dissimilarity a cut must exceed (0…1).
pub threshold: f32,
/// A cut must be this many times the median of its neighbours.
pub peak_ratio: f32,
/// Neighbours on each side used for the local median.
pub window: usize,
/// Minimum frames between cuts.
pub min_shot: usize,
}
impl Default for CutOpts {
fn default() -> Self {
CutOpts { threshold: 0.25, peak_ratio: 3.0, window: 4, min_shot: 2 }
}
}
/// Dissimilarities of every frame to the previous one (`scores[0]` = 0).
pub fn scores(frames: &[Image]) -> Vec<f32> {
let sigs: Vec<Signature> = frames.par_iter().map(signature).collect();
let mut out = vec![0.0];
out.extend((1..sigs.len()).into_par_iter().map(|i| dissimilarity(&sigs[i - 1], &sigs[i])).collect::<Vec<_>>());
out
}
/// Frames that start a new shot (indices into `scores`; never 0).
pub fn detect(scores: &[f32], o: &CutOpts) -> Vec<usize> {
let mut cuts: Vec<usize> = vec![];
for i in 1..scores.len() {
let s = scores[i];
if s < o.threshold {
continue;
}
let lo = i.saturating_sub(o.window).max(1);
let hi = (i + o.window).min(scores.len() - 1);
let mut nb: Vec<f32> = (lo..=hi).filter(|&j| j != i).map(|j| scores[j]).collect();
nb.sort_by(f32::total_cmp);
let med = nb.get(nb.len() / 2).copied().unwrap_or(0.0);
if s < o.peak_ratio * med.max(0.02) {
continue;
}
match cuts.last() {
Some(&last) if i - last < o.min_shot.max(1) => {
// Keep the stronger of two close candidates.
if s > scores[last] {
cuts.pop();
cuts.push(i);
}
}
_ => cuts.push(i),
}
}
cuts
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash_noise;
/// A textured "shot": `kind` picks the palette and texture, panned by `dx` pixels.
fn frame(kind: u32, dx: f32, w: u32, h: u32) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let fx = x as f32 + dx;
let v = match kind {
0 => 0.5 + 0.4 * (fx * 0.15).sin() * (y as f32 * 0.11).cos(),
1 => {
if ((fx / 12.0).floor() as i32 + (y / 12) as i32) % 2 == 0 {
0.85
} else {
0.15
}
}
_ => hash_noise((fx.floor() as i64 / 4) as u32, y / 4, 7),
};
let c = match kind {
0 => [v, v * 0.6, 0.2],
1 => [0.1, v * 0.8, v],
_ => [v * 0.9, v * 0.9, v * 0.4 + 0.5],
};
img.set(x, y, [c[0], c[1], c[2], 1.0]);
}
}
img
}
#[test]
fn finds_synthetic_cuts_exactly() {
// Three shots with camera pans (12, 9 and 11 frames) and a brightness flicker inside the
// second.
let mut frames = vec![];
for i in 0..12 {
frames.push(frame(0, i as f32 * 2.0, 128, 72));
}
for i in 0..9 {
let mut f = frame(1, i as f32 * 3.0, 128, 72);
if i == 4 {
f.map_straight(|c| [c[0] * 1.08, c[1] * 1.08, c[2] * 1.08]);
}
frames.push(f);
}
for i in 0..11 {
frames.push(frame(2, -(i as f32) * 4.0, 128, 72));
}
let s = scores(&frames);
let cuts = detect(&s, &CutOpts::default());
assert_eq!(cuts, vec![12, 21], "scores {s:?}");
}
#[test]
fn a_static_shot_has_no_cuts() {
let frames: Vec<Image> = (0..8).map(|i| frame(2, i as f32, 64, 48)).collect();
assert!(detect(&scores(&frames), &CutOpts::default()).is_empty());
}
}

View File

@ -0,0 +1,958 @@
//! Content-Aware Fill: filling the transparent area of a layer across a range of frames.
//!
//! Written from the published methods:
//!
//! - **Membrane (harmonic) fill**: the hole takes the smoothest values that match its boundary
//! (Laplace's equation with Dirichlet boundary values), solved coarse to fine with
//! known-weighted pyramids and Jacobi relaxation. Edge Blend uses it directly; it also
//! initialises the other methods and completes flow fields.
//! - **PatchMatch** (Barnes, Shechtman, Finkelstein, Goldman 2009) nearest-neighbour fields
//! inside the **space-time completion** EM loop of Wexler, Shechtman and Irani (2007), applied
//! per frame: every patch overlapping the hole is matched to a fully known patch (random
//! initialisation, propagation, random search); hole pixels become the weighted vote of the
//! overlapping matches; repeated over a multi-scale pyramid, coarse to fine.
//! - **Flow-guided propagation** (the propagation stage of flow-guided video inpainting, e.g. Xu
//! et al. 2019 / Huang et al. 2016): optical flow between neighbouring frames is computed on
//! membrane-filled frames, *completed* inside the hole from the surrounding motion, and each
//! missing pixel follows the flow forwards and backwards until it lands on a frame where that
//! point of the scene is visible; the two candidates are blended by temporal distance.
//! **Lighting Correction** adds the membrane interpolation of the mismatch along the hole's
//! border (Poisson-style seamless blending), scaled by the chosen strength.
//!
//! Methods (After Effects' Content-Aware Fill panel):
//! - **Object**: propagation, then PatchMatch synthesis for what no frame shows; each frame's
//! synthesis starts from the previous frame's result warped along the flow, so the fill stays
//! temporally stable.
//! - **Surface**: propagation, then one synthesised fill carried through the range by the flow
//! (flat, rigid surfaces).
//! - **Edge Blend**: the membrane fill of each frame from its own border (fast; textureless
//! surfaces).
//!
//! Reference frames are complete images of given frames (for example a clean plate): they count as
//! frames where every pixel is visible, so propagation pulls their content into the hole.
use rayon::prelude::*;
use crate::Image;
use crate::flow::{Flow, block_flow};
/// Fill method.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FillMethod {
#[default]
Object,
Surface,
EdgeBlend,
}
impl FillMethod {
pub fn from_name(s: &str) -> Option<FillMethod> {
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"object" => Some(FillMethod::Object),
"surface" => Some(FillMethod::Surface),
"edgeblend" | "edge" => Some(FillMethod::EdgeBlend),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
FillMethod::Object => "object",
FillMethod::Surface => "surface",
FillMethod::EdgeBlend => "edgeBlend",
}
}
}
/// Fill options.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FillOpts {
pub method: FillMethod,
/// Lighting Correction strength 0 (None) … 1 (Strong).
pub lighting: f32,
/// Patch size (odd).
pub patch: usize,
/// Seed of the PatchMatch random search.
pub seed: u32,
}
impl Default for FillOpts {
fn default() -> Self {
FillOpts { method: FillMethod::Object, lighting: 0.0, patch: 7, seed: 1 }
}
}
// ------------------------------------------------------------------ helpers
#[inline]
fn rnd(seed: u32, a: u32, b: u32) -> u32 {
let mut h = seed.wrapping_mul(0x9E37_79B9) ^ a.wrapping_mul(0x85EB_CA6B) ^ b.wrapping_mul(0xC2B2_AE35);
h ^= h >> 16;
h = h.wrapping_mul(0x7FEB_352D);
h ^= h >> 15;
h = h.wrapping_mul(0x846C_A68B);
h ^ (h >> 16)
}
/// Grow a hole mask by `r` pixels (square structuring element, separable).
pub fn dilate(hole: &[bool], w: usize, h: usize, r: usize) -> Vec<bool> {
if r == 0 {
return hole.to_vec();
}
let mut tmp = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let lo = x.saturating_sub(r);
let hi = (x + r).min(w - 1);
tmp[y * w + x] = (lo..=hi).any(|k| hole[y * w + k]);
}
}
let mut out = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let lo = y.saturating_sub(r);
let hi = (y + r).min(h - 1);
out[y * w + x] = (lo..=hi).any(|k| tmp[k * w + x]);
}
}
out
}
/// Peak signal-to-noise ratio (dB) of the RGB of `a` against `b` over `region` (all pixels when
/// `None`), for values in 0…1.
pub fn psnr(a: &Image, b: &Image, region: Option<&[bool]>) -> f64 {
let (mut se, mut n) = (0.0f64, 0.0f64);
for i in 0..a.data.len().min(b.data.len()) {
if region.is_some_and(|r| !r[i]) {
continue;
}
for c in 0..3 {
let d = (a.data[i][c] - b.data[i][c]) as f64;
se += d * d;
}
n += 3.0;
}
if se <= 0.0 || n == 0.0 {
return 99.0;
}
10.0 * (1.0 / (se / n)).log10()
}
// ------------------------------------------------------------------ membrane
/// Harmonic fill of `data` (`w × h`, 4 channels) where `known` is false.
fn membrane_core(data: &mut [[f32; 4]], known: &[bool], w: usize, h: usize) {
if !known.iter().any(|k| !*k) {
return;
}
if !known.iter().any(|k| *k) {
return;
}
// Coarse level: known-weighted 2×2 averages.
if w > 8 && h > 8 {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let mut cd = vec![[0.0f32; 4]; cw * ch];
let mut ck = vec![false; cw * ch];
for y in 0..ch {
for x in 0..cw {
let mut s = [0.0f32; 4];
let mut n = 0.0;
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let (fx, fy) = (2 * x + dx, 2 * y + dy);
if fx < w && fy < h && known[fy * w + fx] {
let p = data[fy * w + fx];
for c in 0..4 {
s[c] += p[c];
}
n += 1.0;
}
}
if n > 0.0 {
cd[y * cw + x] = s.map(|v| v / n);
ck[y * cw + x] = true;
}
}
}
membrane_core(&mut cd, &ck, cw, ch);
for y in 0..h {
for x in 0..w {
if !known[y * w + x] {
data[y * w + x] = cd[(y / 2).min(ch - 1) * cw + (x / 2).min(cw - 1)];
}
}
}
}
// Jacobi relaxation of the unknown pixels (in place, Gauss–Seidel order).
let iters = if w * h <= 4096 { 200 } else { 40 };
let unknown: Vec<usize> = (0..w * h).filter(|&i| !known[i]).collect();
for _ in 0..iters {
let mut delta = 0.0f32;
for &i in &unknown {
let (x, y) = (i % w, i / w);
let mut s = [0.0f32; 4];
let mut n = 0.0;
for (nx, ny) in [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)] {
if nx < w && ny < h {
let p = data[ny * w + nx];
for c in 0..4 {
s[c] += p[c];
}
n += 1.0;
}
}
let v = s.map(|v| v / n);
delta = delta.max((v[0] - data[i][0]).abs());
data[i] = v;
}
if delta < 1e-5 {
break;
}
}
}
/// Membrane fill: `img` with the `hole` pixels replaced by the harmonic interpolation of their
/// border.
pub fn membrane_fill(img: &Image, hole: &[bool]) -> Image {
let (w, h) = (img.width as usize, img.height as usize);
let mut out = img.clone();
let known: Vec<bool> = hole.iter().map(|h| !*h).collect();
membrane_core(&mut out.data, &known, w, h);
out
}
// ------------------------------------------------------------------ PatchMatch
struct Level {
w: usize,
h: usize,
img: Vec<[f32; 4]>,
hole: Vec<bool>,
}
fn downsample(l: &Level) -> Level {
let (w, h) = (l.w.div_ceil(2), l.h.div_ceil(2));
let mut img = vec![[0.0f32; 4]; w * h];
let mut hole = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let mut s = [0.0f32; 4];
let mut n = 0.0;
let mut any_hole = false;
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let (fx, fy) = ((2 * x + dx).min(l.w - 1), (2 * y + dy).min(l.h - 1));
let i = fy * l.w + fx;
if l.hole[i] {
any_hole = true;
} else {
for c in 0..4 {
s[c] += l.img[i][c];
}
n += 1.0;
}
}
hole[y * w + x] = any_hole;
if n > 0.0 {
img[y * w + x] = s.map(|v| v / n);
}
}
}
Level { w, h, img, hole }
}
/// Summed-area table of hole pixels, for "is this patch fully known" queries.
struct HoleSat {
w: usize,
s: Vec<u32>,
}
impl HoleSat {
fn new(hole: &[bool], w: usize, h: usize) -> HoleSat {
let sw = w + 1;
let mut s = vec![0u32; sw * (h + 1)];
for y in 0..h {
for x in 0..w {
s[(y + 1) * sw + x + 1] = hole[y * w + x] as u32 + s[y * sw + x + 1] + s[(y + 1) * sw + x] - s[y * sw + x];
}
}
HoleSat { w: sw, s }
}
/// Hole pixels in [x0, x1) × [y0, y1).
fn count(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> u32 {
self.s[y1 * self.w + x1] + self.s[y0 * self.w + x0] - self.s[y0 * self.w + x1] - self.s[y1 * self.w + x0]
}
}
struct Pm<'a> {
w: usize,
h: usize,
r: usize,
img: &'a [[f32; 4]],
sat: HoleSat,
}
impl Pm<'_> {
fn valid_source(&self, x: i64, y: i64) -> bool {
let r = self.r as i64;
x >= r
&& y >= r
&& x + r < self.w as i64
&& y + r < self.h as i64
&& self.sat.count((x - r) as usize, (y - r) as usize, (x + r + 1) as usize, (y + r + 1) as usize) == 0
}
/// SSD between the patch at target (tx, ty) (clipped to the image) and source (sx, sy).
fn dist(&self, tx: i64, ty: i64, sx: i64, sy: i64, cap: f32) -> f32 {
let r = self.r as i64;
let mut d = 0.0f32;
let mut n = 0.0f32;
for dy in -r..=r {
let (ty2, sy2) = (ty + dy, sy + dy);
if ty2 < 0 || ty2 >= self.h as i64 {
continue;
}
for dx in -r..=r {
let tx2 = tx + dx;
if tx2 < 0 || tx2 >= self.w as i64 {
continue;
}
let a = self.img[ty2 as usize * self.w + tx2 as usize];
let b = self.img[sy2 as usize * self.w + (sx + dx) as usize];
for c in 0..4 {
let e = a[c] - b[c];
d += e * e;
}
n += 1.0;
}
if d > cap * n.max(1.0) {
return f32::INFINITY;
}
}
d / n.max(1.0)
}
}
/// One Wexler/PatchMatch level: refine `img` (hole pixels) with `em` EM rounds, starting from the
/// nearest-neighbour field `nnf` (per pixel, source centre; `None` = random).
fn pm_level(l: &mut Level, nnf: &mut Vec<Option<[i32; 2]>>, em: usize, pm_iters: usize, patch: usize, seed: u32) {
let (w, h) = (l.w, l.h);
let r = (patch / 2).min((w.min(h).saturating_sub(1)) / 2).max(1);
// Target centres: pixels whose patch touches the hole.
let near = dilate(&l.hole, w, h, r);
let targets: Vec<usize> = (0..w * h).filter(|&i| near[i]).collect();
if targets.is_empty() {
return;
}
let sat = HoleSat::new(&l.hole, w, h);
// Sources: centres of fully known patches.
let sources: Vec<[i32; 2]> = {
let pm = Pm { w, h, r, img: &l.img, sat: HoleSat::new(&l.hole, w, h) };
(0..w * h).filter(|&i| pm.valid_source((i % w) as i64, (i / w) as i64)).map(|i| [(i % w) as i32, (i / w) as i32]).collect()
};
if sources.is_empty() {
return;
}
nnf.resize(w * h, None);
for e in 0..em {
let pm = Pm { w, h, r, img: &l.img, sat: HoleSat { w: sat.w, s: sat.s.clone() } };
// Initialise / validate.
let mut field: Vec<([i32; 2], f32)> = vec![([0, 0], f32::INFINITY); w * h];
targets
.par_iter()
.map(|&i| {
let (x, y) = ((i % w) as i64, (i / w) as i64);
let s = nnf[i]
.filter(|s| pm.valid_source(s[0] as i64, s[1] as i64))
.unwrap_or_else(|| sources[rnd(seed ^ e as u32, i as u32, 0) as usize % sources.len()]);
(i, (s, pm.dist(x, y, s[0] as i64, s[1] as i64, f32::INFINITY)))
})
.collect::<Vec<_>>()
.into_iter()
.for_each(|(i, v)| field[i] = v);
let is_target = &near;
for it in 0..pm_iters {
let fwd = it % 2 == 0;
let snap = field.clone();
let rows: Vec<Vec<(usize, ([i32; 2], f32))>> = (0..h)
.into_par_iter()
.map(|y| {
let mut row: Vec<([i32; 2], f32)> = snap[y * w..(y + 1) * w].to_vec();
let mut out = vec![];
let xs: Box<dyn Iterator<Item = usize>> = if fwd { Box::new(0..w) } else { Box::new((0..w).rev()) };
for x in xs {
let i = y * w + x;
if !is_target[i] {
continue;
}
let (mut best, mut bd) = row[x];
let (xi, yi) = (x as i64, y as i64);
let try_c = |c: [i32; 2], best: &mut [i32; 2], bd: &mut f32| {
if c != *best && pm.valid_source(c[0] as i64, c[1] as i64) {
let d = pm.dist(xi, yi, c[0] as i64, c[1] as i64, *bd);
if d < *bd {
*bd = d;
*best = c;
}
}
};
// Propagation from the scan-order neighbours.
let step: i64 = if fwd { -1 } else { 1 };
let nx = xi + step;
if nx >= 0 && nx < w as i64 && is_target[y * w + nx as usize] {
let s = row[nx as usize].0;
try_c([s[0] - step as i32, s[1]], &mut best, &mut bd);
}
let ny = yi + step;
if ny >= 0 && ny < h as i64 && is_target[ny as usize * w + x] {
let s = snap[ny as usize * w + x].0;
try_c([s[0], s[1] - step as i32], &mut best, &mut bd);
}
// Random search around the best match, halving the radius.
let mut rad = w.max(h) as i64;
let mut k = 0u32;
while rad >= 1 {
let a = rnd(seed.wrapping_add(it as u32 * 977 + e as u32 * 31), i as u32, k);
let ox = (a % (2 * rad as u32 + 1)) as i64 - rad;
let oy = ((a >> 16) % (2 * rad as u32 + 1)) as i64 - rad;
try_c([(best[0] as i64 + ox) as i32, (best[1] as i64 + oy) as i32], &mut best, &mut bd);
rad /= 2;
k += 1;
}
// And a random source anywhere (helps escape bad starts).
let any = sources[rnd(seed ^ 0xABCD, i as u32, it as u32 + e as u32 * 64) as usize % sources.len()];
try_c(any, &mut best, &mut bd);
row[x] = (best, bd);
out.push((i, (best, bd)));
}
out
})
.collect();
for row in rows {
for (i, v) in row {
field[i] = v;
}
}
}
// Vote.
let mut ds: Vec<f32> = targets.iter().map(|&i| field[i].1).filter(|d| d.is_finite()).collect();
ds.sort_by(f32::total_cmp);
let sigma2 = ds.get(ds.len() * 3 / 4).copied().unwrap_or(1.0).max(1e-5);
let ri = r as i64;
let new: Vec<(usize, [f32; 4])> = (0..w * h)
.into_par_iter()
.filter(|&i| l.hole[i])
.map(|i| {
let (x, y) = ((i % w) as i64, (i / w) as i64);
let mut acc = [0.0f32; 4];
let mut wsum = 0.0f32;
for dy in -ri..=ri {
for dx in -ri..=ri {
let (px, py) = (x + dx, y + dy);
if px < 0 || py < 0 || px >= w as i64 || py >= h as i64 {
continue;
}
let (s, d) = field[py as usize * w + px as usize];
if !d.is_finite() {
continue;
}
let (sx, sy) = (s[0] as i64 - dx, s[1] as i64 - dy);
let wt = (-d / (2.0 * sigma2)).exp();
let v = l.img[sy as usize * w + sx as usize];
for c in 0..4 {
acc[c] += v[c] * wt;
}
wsum += wt;
}
}
(i, if wsum > 0.0 { acc.map(|v| v / wsum) } else { l.img[i] })
})
.collect();
for (i, v) in new {
l.img[i] = v;
}
for &i in &targets {
nnf[i] = Some(field[i].0);
}
}
}
/// Fill the `hole` of `img` with PatchMatch synthesis (multi-scale Wexler EM). `init`: a starting
/// guess for the hole (e.g. the previous frame's fill warped here); with it only the finest
/// scale is refined.
pub fn patch_fill(img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Image {
let (w, h) = (img.width as usize, img.height as usize);
if !hole.iter().any(|b| *b) || w == 0 || h == 0 {
return img.clone();
}
let patch = patch.max(3) | 1;
let fine = Level { w, h, img: img.data.clone(), hole: hole.to_vec() };
if let Some(init) = init {
let mut l = fine;
for (i, v) in l.img.iter_mut().enumerate() {
if l.hole[i] {
*v = init.data[i];
}
}
let mut nnf = vec![];
pm_level(&mut l, &mut nnf, 2, 4, patch, seed);
return Image { width: img.width, height: img.height, data: l.img };
}
// Pyramid: stop while the image is still a few patches across.
let mut levels = vec![fine];
while levels.len() < 6 {
let last = levels.last().expect("level");
if last.w.min(last.h) / 2 < patch * 4 || !last.hole.iter().any(|b| *b) {
break;
}
let d = downsample(last);
// Keep at least one fully known patch.
if !d.hole.iter().any(|b| !*b) {
break;
}
levels.push(d);
}
// Coarsest: membrane start.
let top = levels.len() - 1;
{
let l = &mut levels[top];
let known: Vec<bool> = l.hole.iter().map(|b| !*b).collect();
membrane_core(&mut l.img, &known, l.w, l.h);
}
let mut nnf: Vec<Option<[i32; 2]>> = vec![];
for k in (0..=top).rev() {
if k < top {
// Upsample the coarser result into this level's hole and the NNF (×2).
let (coarse_img, cw, ch, coarse_nnf) = {
let c = &levels[k + 1];
(c.img.clone(), c.w, c.h, nnf.clone())
};
let l = &mut levels[k];
for y in 0..l.h {
for x in 0..l.w {
let i = y * l.w + x;
if l.hole[i] {
// Bilinear sample of the coarse image.
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32);
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32);
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (x1, y1) = ((x0 + 1).min(cw - 1), (y0 + 1).min(ch - 1));
let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
let g = |xx: usize, yy: usize| coarse_img[yy * cw + xx];
let mut v = [0.0f32; 4];
for c in 0..4 {
let top = g(x0, y0)[c] + (g(x1, y0)[c] - g(x0, y0)[c]) * tx;
let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx;
v[c] = top + (bot - top) * ty;
}
l.img[i] = v;
}
}
}
nnf = (0..l.w * l.h)
.map(|i| {
let (x, y) = (i % l.w, i / l.w);
let ci = (y / 2).min(ch - 1) * cw + (x / 2).min(cw - 1);
coarse_nnf.get(ci).copied().flatten().map(|s| [s[0] * 2 + (x % 2) as i32, s[1] * 2 + (y % 2) as i32])
})
.collect();
}
let em = if k == top { 8 } else { 4 };
pm_level(&mut levels[k], &mut nnf, em, 5, patch, seed.wrapping_add(k as u32 * 101));
}
let l = levels.swap_remove(0);
Image { width: img.width, height: img.height, data: l.img }
}
// ------------------------------------------------------------------ flow-guided propagation
/// Block flow from `a` to `b` with the tiles that touch either hole replaced by the membrane
/// interpolation of the surrounding motion (flow completion).
fn completed_flow(a: &Image, b: &Image, hole_a: &[bool], hole_b: &[bool]) -> Flow {
let mut f = block_flow(a, b, 8, 6);
if f.v.is_empty() {
return f;
}
let (w, bs) = (a.width as usize, f.block as usize);
let mut known = vec![true; f.v.len()];
for (i, k) in known.iter_mut().enumerate() {
let (c, r) = (i % f.cols, i / f.cols);
// A tile is unreliable when any pixel within it (and a block's margin) is a hole.
let (x0, y0) = ((c * bs).saturating_sub(bs / 2), (r * bs).saturating_sub(bs / 2));
let (x1, y1) = (((c + 1) * bs + bs / 2).min(w), ((r + 1) * bs + bs / 2).min(a.height as usize));
'tile: for y in y0..y1 {
for x in x0..x1 {
if hole_a[y * w + x] || hole_b[y * w + x] {
*k = false;
break 'tile;
}
}
}
}
if known.iter().all(|k| !*k) {
f.v.iter_mut().for_each(|v| *v = [0.0; 2]);
return f;
}
let mut d: Vec<[f32; 4]> = f.v.iter().map(|v| [v[0], v[1], 0.0, 0.0]).collect();
membrane_core(&mut d, &known, f.cols, f.rows);
for (v, x) in f.v.iter_mut().zip(d) {
*v = [x[0], x[1]];
}
f
}
/// The input of a sequence fill.
pub struct FillInput {
/// Frames (premultiplied RGBA, all the same size).
pub frames: Vec<Image>,
/// Per frame, `true` where the pixel is missing.
pub holes: Vec<Vec<bool>>,
/// Complete images for some frames: (frame index, image).
pub references: Vec<(usize, Image)>,
}
/// Trace pixel (x, y) of frame `t` through `flows` (`step` = +1 forward with `fwd`, −1 backward
/// with `bwd`) to the nearest frame where it is visible: (sampled colour, frames travelled).
fn trace(
t: usize,
x: f64,
y: f64,
step: i64,
fwd: &[Flow],
bwd: &[Flow],
known: &dyn Fn(usize, i64, i64) -> bool,
frames: &[&Image],
max: usize,
) -> Option<([f32; 4], usize)> {
let n = frames.len();
let (w, h) = (frames[0].width as f64, frames[0].height as f64);
let (mut px, mut py) = (x, y);
let mut k = t as i64;
for d in 1..=max {
let v = if step > 0 {
if k as usize + 1 >= n {
return None;
}
fwd[k as usize].at(px, py)
} else {
if k == 0 {
return None;
}
bwd[k as usize - 1].at(px, py)
};
px += v[0] as f64;
py += v[1] as f64;
k += step;
if px < 0.0 || py < 0.0 || px >= w || py >= h {
return None;
}
// Visible when the bilinear support is known.
let (x0, y0) = ((px - 0.5).floor() as i64, (py - 0.5).floor() as i64);
let ok = [(x0, y0), (x0 + 1, y0), (x0, y0 + 1), (x0 + 1, y0 + 1)]
.iter()
.all(|&(xx, yy)| known(k as usize, xx.clamp(0, w as i64 - 1), yy.clamp(0, h as i64 - 1)));
if ok {
return Some((frames[k as usize].sample_bilinear_clamped(px, py), d));
}
}
None
}
/// Fill a range of frames. `progress(done, total)` returns `false` to cancel (→ `None`).
pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(usize, usize) -> bool) -> Option<Vec<Image>> {
let n = input.frames.len();
if n == 0 {
return Some(vec![]);
}
let (w, h) = (input.frames[0].width as usize, input.frames[0].height as usize);
// Steps: flows (1) + propagation (1) + per-frame synthesis (n).
let total = n + 2;
if o.method == FillMethod::EdgeBlend {
let mut out = Vec::with_capacity(n);
for (i, (f, hl)) in input.frames.iter().zip(&input.holes).enumerate() {
out.push(opaque_fill(membrane_fill(f, hl), hl));
if !progress(i + 1, n) {
return None;
}
}
return Some(out);
}
// Reference frames make their frame fully known.
let mut frames: Vec<Image> = input.frames.clone();
let mut holes: Vec<Vec<bool>> = input.holes.clone();
let mut is_ref = vec![false; n];
for (k, img) in &input.references {
if *k < n && img.width as usize == w && img.height as usize == h {
frames[*k] = img.clone();
holes[*k] = vec![false; w * h];
is_ref[*k] = true;
}
}
// 1. Flows between membrane-filled neighbours, completed inside the holes.
let pre: Vec<Image> = frames.par_iter().zip(&holes).map(|(f, hl)| membrane_fill(f, hl)).collect();
let pairs: Vec<(Flow, Flow)> = (0..n.saturating_sub(1))
.into_par_iter()
.map(|i| (completed_flow(&pre[i], &pre[i + 1], &holes[i], &holes[i + 1]), completed_flow(&pre[i + 1], &pre[i], &holes[i + 1], &holes[i])))
.collect();
let (fwd, bwd): (Vec<Flow>, Vec<Flow>) = pairs.into_iter().unzip();
if !progress(1, total) {
return None;
}
// 2. Propagation (and the lighting-correction border ring).
let refs: Vec<&Image> = frames.iter().collect();
let known = |k: usize, x: i64, y: i64| !holes[k][y as usize * w + x as usize];
let ring_r = 2usize;
let propagated: Vec<(Image, Vec<bool>)> = (0..n)
.into_par_iter()
.map(|t| {
let mut img = frames[t].clone();
let mut left = holes[t].clone();
if is_ref[t] || !holes[t].iter().any(|b| *b) {
return (img, left);
}
let sample = |i: usize| -> Option<[f32; 4]> {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let f = trace(t, x, y, 1, &fwd, &bwd, &known, &refs, n);
let b = trace(t, x, y, -1, &fwd, &bwd, &known, &refs, n);
match (f, b) {
(Some((a, da)), Some((c, dc))) => {
let (wa, wc) = (1.0 / da as f32, 1.0 / dc as f32);
Some(std::array::from_fn(|k| (a[k] * wa + c[k] * wc) / (wa + wc)))
}
(Some((a, _)), None) | (None, Some((a, _))) => Some(a),
_ => None,
}
};
let filled: Vec<(usize, [f32; 4])> = (0..w * h).filter(|&i| holes[t][i]).filter_map(|i| sample(i).map(|v| (i, v))).collect();
for (i, v) in &filled {
img.data[*i] = *v;
left[*i] = false;
}
// Lighting correction: membrane of the border mismatch, added to propagated pixels.
if o.lighting > 0.0 && !filled.is_empty() {
let grown = dilate(&holes[t], w, h, ring_r);
let mut diff = vec![[0.0f32; 4]; w * h];
let mut dk = vec![true; w * h];
for i in 0..w * h {
if holes[t][i] {
dk[i] = false;
} else if grown[i]
&& let Some(v) = sample(i)
{
let a = frames[t].data[i];
diff[i] = std::array::from_fn(|c| if c < 3 { a[c] - v[c] } else { 0.0 });
}
}
membrane_core(&mut diff, &dk, w, h);
for (i, _) in &filled {
for c in 0..3 {
img.data[*i][c] += o.lighting * diff[*i][c];
}
}
}
(img, left)
})
.collect();
if !progress(2, total) {
return None;
}
// 3. Synthesis of what no frame shows.
let mut out: Vec<Option<Image>> = vec![None; n];
let first = (0..n).find(|&t| propagated[t].1.iter().any(|b| *b));
let Some(t0) = first else {
return Some(propagated.into_iter().zip(&holes).map(|((img, _), hl)| opaque_fill(img, hl)).collect());
};
// Frames before t0 are complete already.
for (t, slot) in out.iter_mut().enumerate().take(t0) {
*slot = Some(propagated[t].0.clone());
}
let mut done = 2;
out[t0] = Some(patch_fill(&propagated[t0].0, &propagated[t0].1, None, o.patch, o.seed));
done += 1;
if !progress(done, total) {
return None;
}
for t in t0 + 1..n {
let (img, left) = &propagated[t];
if !left.iter().any(|b| *b) {
out[t] = Some(img.clone());
} else {
// Warp the previous result here along the backward flow (t → t − 1).
let prev = out[t - 1].as_ref().expect("previous frame");
let mut warped = img.clone();
for i in 0..w * h {
if left[i] {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let v = bwd[t - 1].at(x, y);
warped.data[i] = prev.sample_bilinear_clamped(x + v[0] as f64, y + v[1] as f64);
}
}
out[t] = Some(match o.method {
FillMethod::Surface => warped,
_ => patch_fill(img, left, Some(&warped), o.patch, o.seed),
});
}
done += 1;
if !progress(done, total) {
return None;
}
}
Some(out.into_iter().zip(&holes).map(|(img, hl)| opaque_fill(img.unwrap_or_default(), hl)).collect())
}
/// Filled pixels are opaque.
fn opaque_fill(mut img: Image, hole: &[bool]) -> Image {
for (p, hl) in img.data.iter_mut().zip(hole) {
if *hl && p[3] > 1e-6 {
let a = p[3];
*p = [p[0] / a, p[1] / a, p[2] / a, 1.0];
}
}
img
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash_noise;
/// A textured background (periodic stripes + blocky noise), shifted by `dx`.
fn background(w: u32, h: u32, dx: f32) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let fx = x as f32 + dx;
let s = 0.5 + 0.3 * (fx * std::f32::consts::TAU / 10.0).sin();
let n = hash_noise(((fx.floor() as i64).rem_euclid(20) / 4) as u32, (y % 20) / 4, 3);
img.set(x, y, [s, 0.3 + 0.4 * n, 0.6 - 0.3 * s * n, 1.0]);
}
}
img
}
fn cut(img: &Image, hole: &[bool]) -> Image {
let mut o = img.clone();
for (p, h) in o.data.iter_mut().zip(hole) {
if *h {
*p = [0.0; 4];
}
}
o
}
fn rect_hole(w: u32, h: u32, x0: u32, y0: u32, s: u32) -> Vec<bool> {
(0..w * h).map(|i| (x0..x0 + s).contains(&(i % w)) && (y0..y0 + s).contains(&(i / w))).collect()
}
#[test]
fn membrane_matches_a_linear_ramp() {
let (w, h) = (40u32, 30u32);
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let v = x as f32 / w as f32;
img.set(x, y, [v, v, v, 1.0]);
}
}
let hole = rect_hole(w, h, 10, 8, 14);
let f = membrane_fill(&cut(&img, &hole), &hole);
assert!(psnr(&f, &img, Some(&hole)) > 40.0, "{}", psnr(&f, &img, Some(&hole)));
}
#[test]
fn patchmatch_beats_the_membrane_on_texture() {
let (w, h) = (96u32, 72u32);
let gt = background(w, h, 0.0);
let hole = rect_hole(w, h, 40, 28, 16);
let src = cut(&gt, &hole);
let mem = membrane_fill(&src, &hole);
let pm = patch_fill(&src, &hole, None, 7, 1);
let (pm_db, mem_db) = (psnr(&pm, &gt, Some(&hole)), psnr(&mem, &gt, Some(&hole)));
assert!(pm_db > mem_db + 3.0 && pm_db > 20.0, "PatchMatch {pm_db:.1} dB vs membrane {mem_db:.1} dB");
// Known pixels are untouched.
for i in 0..hole.len() {
if !hole[i] {
assert_eq!(pm.data[i], gt.data[i]);
}
}
}
/// A moving object removed from a (static or panning) textured background.
fn sequence(n: usize, pan: f32) -> (FillInput, Vec<Image>) {
let (w, h) = (96u32, 64u32);
let mut frames = vec![];
let mut holes = vec![];
let mut gts = vec![];
for t in 0..n {
let gt = background(w, h, t as f32 * pan);
let hole = rect_hole(w, h, 10 + 8 * t as u32, 22, 18);
frames.push(cut(&gt, &hole));
holes.push(hole);
gts.push(gt);
}
(FillInput { frames, holes, references: vec![] }, gts)
}
#[test]
fn object_fill_recovers_background_and_is_stable() {
for pan in [0.0, 1.0] {
let (input, gts) = sequence(8, pan);
let out = fill_sequence(&input, &FillOpts::default(), &mut |_, _| true).expect("fill");
for t in 0..out.len() {
let db = psnr(&out[t], &gts[t], Some(&input.holes[t]));
assert!(db > 26.0, "frame {t} pan {pan}: {db:.1} dB");
assert_eq!(out[t].data[0][3], 1.0);
}
if pan == 0.0 {
// Temporal consistency: on a static background the filled pixels agree between
// frames.
for t in 1..out.len() {
let both: Vec<bool> = input.holes[t].iter().zip(&input.holes[t - 1]).map(|(a, b)| *a || *b).collect();
let db = psnr(&out[t], &out[t - 1], Some(&both));
assert!(db > 30.0, "frames {}–{t} differ: {db:.1} dB", t - 1);
}
}
}
}
#[test]
fn surface_and_edge_blend_and_references() {
// A hole that never moves: nothing to propagate, so synthesis must carry it.
let (w, h) = (80u32, 60u32);
let gt = background(w, h, 0.0);
let hole = rect_hole(w, h, 30, 20, 14);
let input = FillInput { frames: vec![cut(&gt, &hole); 4], holes: vec![hole.clone(); 4], references: vec![] };
let surf = fill_sequence(&input, &FillOpts { method: FillMethod::Surface, ..Default::default() }, &mut |_, _| true).expect("fill");
for t in 1..4 {
// Surface carries one fill through: identical frames.
assert!(psnr(&surf[t], &surf[0], Some(&hole)) > 40.0);
}
let edge = fill_sequence(&input, &FillOpts { method: FillMethod::EdgeBlend, ..Default::default() }, &mut |_, _| true).expect("fill");
assert!(psnr(&edge[0], &membrane_fill(&input.frames[0], &hole), Some(&hole)) > 60.0);
// A clean reference frame recovers the background exactly.
let mut with_ref = FillInput { frames: input.frames.clone(), holes: input.holes.clone(), references: vec![(2, gt.clone())] };
let out = fill_sequence(&with_ref, &FillOpts::default(), &mut |_, _| true).expect("fill");
for (t, f) in out.iter().enumerate() {
assert!(psnr(f, &gt, Some(&hole)) > 35.0, "frame {t}: {:.1}", psnr(f, &gt, Some(&hole)));
}
// Cancel.
with_ref.references.clear();
assert!(fill_sequence(&with_ref, &FillOpts::default(), &mut |d, _| d < 2).is_none());
}
#[test]
fn lighting_correction_absorbs_a_brightness_change() {
// The background brightens over time; propagated pixels come from darker frames.
let (mut input, mut gts) = sequence(6, 0.0);
for t in 0..6 {
let k = 1.0 + 0.06 * t as f32;
for img in [&mut input.frames[t], &mut gts[t]] {
for p in &mut img.data {
for c in 0..3 {
p[c] *= k;
}
}
}
}
let plain = fill_sequence(&input, &FillOpts::default(), &mut |_, _| true).expect("fill");
let lit = fill_sequence(&input, &FillOpts { lighting: 1.0, ..Default::default() }, &mut |_, _| true).expect("fill");
let score = |o: &[Image]| (0..6).map(|t| psnr(&o[t], &gts[t], Some(&input.holes[t]))).sum::<f64>();
assert!(score(&lit) > score(&plain), "lit {} vs plain {}", score(&lit), score(&plain));
}
}

View File

@ -5,7 +5,10 @@
pub mod blur;
pub mod channels3d;
pub mod cuts;
pub mod flow;
pub mod inpaint;
pub mod scopes;
pub mod warp;
use effectcraft_color::{BlendMode, blend_pixel};

555
crates/raster/src/scopes.rs Normal file
View File

@ -0,0 +1,555 @@
//! Video scopes (the Lumetri Scopes panel): waveform (RGB, Luma, YC), vectorscope (YUV, HLS),
//! histogram and parade (RGB, YUV), computed from a frame as density plots.
//!
//! The maths is the textbook broadcast definition: luma `Y = Kr·R + Kg·G + Kb·B` with the
//! ITU-R BT.601 / BT.709 / BT.2020 coefficients, colour differences `Cb = (B − Y) / (2(1 − Kb))`
//! and `Cr = (R − Y) / (2(1 − Kr))` (each in −0.5…0.5). Values are read straight (un-premultiplied)
//! from the frame. Every scope is a [`Scope`]: a `width × height` grid of three density planes
//! (one per trace colour) on a logarithmic scale where the busiest cell is 1, ready to be tinted
//! and drawn.
//!
//! Scales: **8-bit** maps 0…1 to the plot (0…255 graticule); **float** maps −0.25…1.25 so
//! super-white and sub-black values stay visible. **Clamp** limits the signal to 0…1 first.
use rayon::prelude::*;
use crate::Image;
/// Luma / colour-difference coefficients.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColorStandard {
Rec601,
#[default]
Rec709,
Rec2020,
}
impl ColorStandard {
pub const ALL: [ColorStandard; 3] = [ColorStandard::Rec601, ColorStandard::Rec709, ColorStandard::Rec2020];
/// (Kr, Kb).
pub fn coefficients(self) -> (f32, f32) {
match self {
ColorStandard::Rec601 => (0.299, 0.114),
ColorStandard::Rec709 => (0.2126, 0.0722),
ColorStandard::Rec2020 => (0.2627, 0.0593),
}
}
pub fn name(self) -> &'static str {
match self {
ColorStandard::Rec601 => "rec601",
ColorStandard::Rec709 => "rec709",
ColorStandard::Rec2020 => "rec2020",
}
}
pub fn from_name(s: &str) -> Option<ColorStandard> {
match s.to_ascii_lowercase().replace([' ', '.', '-', '_'], "").as_str() {
"rec601" | "601" | "bt601" => Some(ColorStandard::Rec601),
"rec709" | "709" | "bt709" => Some(ColorStandard::Rec709),
"rec2020" | "2020" | "bt2020" => Some(ColorStandard::Rec2020),
_ => None,
}
}
/// (Y, Cb, Cr) of a straight RGB colour.
pub fn ycbcr(self, rgb: [f32; 3]) -> [f32; 3] {
let (kr, kb) = self.coefficients();
let y = kr * rgb[0] + (1.0 - kr - kb) * rgb[1] + kb * rgb[2];
[y, (rgb[2] - y) / (2.0 * (1.0 - kb)), (rgb[0] - y) / (2.0 * (1.0 - kr))]
}
}
/// Which scope.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScopeKind {
WaveformRgb,
WaveformLuma,
/// Luma (plane 0) and chroma amplitude `√(Cb² + Cr²)` (plane 2).
WaveformYc,
/// Cb across, Cr up.
VectorscopeYuv,
/// Hue around the circle, saturation outwards (HLS).
VectorscopeHls,
/// R, G, B histograms (bins across, counts up).
Histogram,
ParadeRgb,
ParadeYuv,
}
impl ScopeKind {
pub const ALL: [ScopeKind; 8] = [
ScopeKind::WaveformRgb,
ScopeKind::WaveformLuma,
ScopeKind::WaveformYc,
ScopeKind::VectorscopeYuv,
ScopeKind::VectorscopeHls,
ScopeKind::Histogram,
ScopeKind::ParadeRgb,
ScopeKind::ParadeYuv,
];
pub fn name(self) -> &'static str {
match self {
ScopeKind::WaveformRgb => "waveformRgb",
ScopeKind::WaveformLuma => "waveformLuma",
ScopeKind::WaveformYc => "waveformYc",
ScopeKind::VectorscopeYuv => "vectorscopeYuv",
ScopeKind::VectorscopeHls => "vectorscopeHls",
ScopeKind::Histogram => "histogram",
ScopeKind::ParadeRgb => "paradeRgb",
ScopeKind::ParadeYuv => "paradeYuv",
}
}
pub fn label(self) -> &'static str {
match self {
ScopeKind::WaveformRgb => "Waveform (RGB)",
ScopeKind::WaveformLuma => "Waveform (Luma)",
ScopeKind::WaveformYc => "Waveform (YC)",
ScopeKind::VectorscopeYuv => "Vectorscope YUV",
ScopeKind::VectorscopeHls => "Vectorscope HLS",
ScopeKind::Histogram => "Histogram",
ScopeKind::ParadeRgb => "Parade (RGB)",
ScopeKind::ParadeYuv => "Parade (YUV)",
}
}
pub fn from_name(s: &str) -> Option<ScopeKind> {
let n = s.to_ascii_lowercase().replace([' ', '(', ')', '_', '-'], "");
Self::ALL.iter().copied().find(|k| k.name().to_ascii_lowercase() == n || k.label().to_ascii_lowercase().replace([' ', '(', ')'], "") == n)
}
/// Vectorscopes are square.
pub fn is_vectorscope(self) -> bool {
matches!(self, ScopeKind::VectorscopeYuv | ScopeKind::VectorscopeHls)
}
}
/// Scope options.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScopeOpts {
pub standard: ColorStandard,
/// Float scale (−0.25…1.25) instead of 8-bit (0…1).
pub float: bool,
/// Clamp the signal to 0…1 before plotting.
pub clamp: bool,
/// Plot size.
pub width: u32,
pub height: u32,
}
impl Default for ScopeOpts {
fn default() -> Self {
ScopeOpts { standard: ColorStandard::Rec709, float: false, clamp: true, width: 256, height: 256 }
}
}
impl ScopeOpts {
/// Signal range shown on the value axis.
pub fn range(&self) -> (f32, f32) {
if self.float { (-0.25, 1.25) } else { (0.0, 1.0) }
}
/// Plot row (0 = top) of a value, or `None` when off-scale.
fn row(&self, v: f32) -> Option<usize> {
let (lo, hi) = self.range();
let f = (v - lo) / (hi - lo);
if !(0.0..=1.0).contains(&f) {
return None;
}
let r = ((1.0 - f) * (self.height - 1) as f32).round() as usize;
Some(r.min(self.height as usize - 1))
}
}
/// A computed scope: three density planes (`planes[c][y * width + x]`, 0…1).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Scope {
pub width: u32,
pub height: u32,
pub planes: [Vec<f32>; 3],
/// Raw counts before normalisation (same layout), for statistics and tests.
pub counts: [Vec<u32>; 3],
}
impl Scope {
fn new(w: u32, h: u32) -> Scope {
let n = w as usize * h as usize;
Scope { width: w, height: h, planes: [vec![0.0; n], vec![0.0; n], vec![0.0; n]], counts: [vec![0; n], vec![0; n], vec![0; n]] }
}
fn merge(mut self, o: Scope) -> Scope {
for c in 0..3 {
for (a, b) in self.counts[c].iter_mut().zip(&o.counts[c]) {
*a += *b;
}
}
self
}
fn normalise(&mut self) {
// Logarithmic density (like a phosphor trace) so faint traces stay visible next to
// dense ones.
let max = self.counts.iter().flat_map(|p| p.iter()).copied().max().unwrap_or(0).max(1) as f32;
let k = 1.0 / (1.0 + max).ln();
for c in 0..3 {
self.planes[c] = self.counts[c].iter().map(|&n| if n == 0 { 0.0 } else { (0.25 + 0.75 * (1.0 + n as f32).ln() * k).min(1.0) }).collect();
}
}
/// Total count of plane `c`.
pub fn total(&self, c: usize) -> u64 {
self.counts[c].iter().map(|&n| n as u64).sum()
}
/// The cell of plane `c` with the most samples, as (x, y).
pub fn peak(&self, c: usize) -> (u32, u32) {
let (i, _) = self.counts[c].iter().enumerate().max_by_key(|(_, n)| **n).unwrap_or((0, &0));
(i as u32 % self.width.max(1), i as u32 / self.width.max(1))
}
/// Straight RGBA8 picture: plane 0 red, 1 green, 2 blue (`mono`: all planes white).
pub fn to_rgba8(&self, mono: bool) -> Vec<u8> {
let n = self.width as usize * self.height as usize;
let mut out = vec![0u8; n * 4];
for i in 0..n {
let (r, g, b) = (self.planes[0][i], self.planes[1][i], self.planes[2][i]);
let (r, g, b) = if mono {
let m = r.max(g).max(b);
(m, m, m)
} else {
(r, g, b)
};
let a = r.max(g).max(b);
out[i * 4] = (r.min(1.0) * 255.0) as u8;
out[i * 4 + 1] = (g.min(1.0) * 255.0) as u8;
out[i * 4 + 2] = (b.min(1.0) * 255.0) as u8;
out[i * 4 + 3] = (a.min(1.0) * 255.0) as u8;
}
out
}
}
/// Straight RGB of pixel `i` (transparent pixels read as black), clamped when asked.
#[inline]
fn rgb(img: &Image, i: usize, clamp: bool) -> [f32; 3] {
let p = img.data[i];
let mut c = if p[3] > 1e-6 { [p[0] / p[3], p[1] / p[3], p[2] / p[3]] } else { [0.0; 3] };
// Composite over black, as a monitor shows the frame.
let a = p[3].clamp(0.0, 1.0);
for v in &mut c {
*v *= a;
if clamp {
*v = v.clamp(0.0, 1.0);
}
}
c
}
/// Compute a scope of `img`.
pub fn compute(img: &Image, kind: ScopeKind, o: &ScopeOpts) -> Scope {
let (w, h) = (o.width.max(8), o.height.max(8));
let o = ScopeOpts { width: w, height: h, ..*o };
if img.is_empty() {
return Scope::new(w, h);
}
let iw = img.width as usize;
let rows: Vec<usize> = (0..img.height as usize).collect();
let mut s = rows
.par_chunks(16)
.map(|chunk| {
let mut s = Scope::new(w, h);
for &y in chunk {
for x in 0..iw {
plot(&mut s, kind, &o, rgb(img, y * iw + x, o.clamp), x as f32 / iw as f32);
}
}
s
})
.reduce(|| Scope::new(w, h), Scope::merge);
s.normalise();
s
}
#[inline]
fn bump(s: &mut Scope, c: usize, x: usize, y: usize) {
let i = y * s.width as usize + x.min(s.width as usize - 1);
s.counts[c][i] += 1;
}
/// Add one pixel (straight RGB, at horizontal position `fx` 0…1 in the frame).
fn plot(s: &mut Scope, kind: ScopeKind, o: &ScopeOpts, c: [f32; 3], fx: f32) {
let w = s.width as usize;
let col = |fx: f32, x0: usize, span: usize| x0 + ((fx * span as f32) as usize).min(span - 1);
match kind {
ScopeKind::WaveformRgb => {
let x = col(fx, 0, w);
for (ch, v) in c.iter().enumerate() {
if let Some(y) = o.row(*v) {
bump(s, ch, x, y);
}
}
}
ScopeKind::WaveformLuma => {
let x = col(fx, 0, w);
let yv = o.standard.ycbcr(c)[0];
if let Some(y) = o.row(yv) {
for ch in 0..3 {
bump(s, ch, x, y);
}
}
}
ScopeKind::WaveformYc => {
let x = col(fx, 0, w);
let [yv, cb, cr] = o.standard.ycbcr(c);
if let Some(y) = o.row(yv) {
bump(s, 0, x, y);
bump(s, 1, x, y);
}
if let Some(y) = o.row((cb * cb + cr * cr).sqrt()) {
bump(s, 2, x, y);
}
}
ScopeKind::ParadeRgb | ScopeKind::ParadeYuv => {
let third = (w / 3).max(1);
let vals = if kind == ScopeKind::ParadeRgb {
c
} else {
let [y, cb, cr] = o.standard.ycbcr(c);
// Colour differences centred on mid-scale.
[y, cb + 0.5, cr + 0.5]
};
for (ch, v) in vals.iter().enumerate() {
let x = col(fx, ch * third, third);
if let Some(y) = o.row(*v) {
// RGB parade: tinted per channel; YUV: Y white, Cb blue, Cr red.
match (kind, ch) {
(ScopeKind::ParadeYuv, 0) => {
for p in 0..3 {
bump(s, p, x, y);
}
}
(ScopeKind::ParadeYuv, 1) => bump(s, 2, x, y),
(ScopeKind::ParadeYuv, _) => bump(s, 0, x, y),
_ => bump(s, ch, x, y),
}
}
}
}
ScopeKind::VectorscopeYuv => {
let [_, cb, cr] = o.standard.ycbcr(c);
if let Some((x, y)) = vec_cell(s, cb, cr) {
for ch in 0..3 {
bump(s, ch, x, y);
}
}
}
ScopeKind::VectorscopeHls => {
let (hue, _, sat) = hls(c);
let a = hue.to_radians();
if let Some((x, y)) = vec_cell(s, sat * 0.5 * a.cos(), sat * 0.5 * a.sin()) {
for ch in 0..3 {
bump(s, ch, x, y);
}
}
}
ScopeKind::Histogram => {
let (lo, hi) = o.range();
for (ch, v) in c.iter().enumerate() {
let f = (v - lo) / (hi - lo);
if (0.0..=1.0).contains(&f) {
let x = ((f * (w - 1) as f32).round() as usize).min(w - 1);
// Histograms keep their counts in row 0; the bars are drawn by `histogram_bars`.
bump(s, ch, x, 0);
}
}
}
}
}
/// Vectorscope cell of (u, v) in −0.5…0.5 (the disc fills the plot; +v up).
fn vec_cell(s: &Scope, u: f32, v: f32) -> Option<(usize, usize)> {
let (w, h) = (s.width as f32, s.height as f32);
let x = ((u + 0.5) * (w - 1.0)).round();
let y = ((0.5 - v) * (h - 1.0)).round();
if x < 0.0 || y < 0.0 || x >= w || y >= h {
return None;
}
Some((x as usize, y as usize))
}
/// (hue degrees, lightness, saturation) of straight RGB (HLS).
pub fn hls(c: [f32; 3]) -> (f32, f32, f32) {
let (r, g, b) = (c[0], c[1], c[2]);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) * 0.5;
let d = max - min;
if d < 1e-6 {
return (0.0, l, 0.0);
}
let s = if l > 0.5 { d / (2.0 - max - min).max(1e-6) } else { d / (max + min).max(1e-6) };
let h = if max == r {
((g - b) / d).rem_euclid(6.0)
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
(h * 60.0, l, s.min(1.0))
}
/// Histogram bars: per channel, `bins` counts across the value range (counts in a
/// [`ScopeKind::Histogram`] scope's first row).
pub fn histogram_bars(s: &Scope) -> [Vec<u32>; 3] {
let w = s.width as usize;
[s.counts[0][..w].to_vec(), s.counts[1][..w].to_vec(), s.counts[2][..w].to_vec()]
}
/// Where a vectorscope target (75 % colour bars' primaries and secondaries) sits, as (u, v) in
/// −0.5…0.5, for the graticule.
pub fn vector_targets(standard: ColorStandard, hls_mode: bool) -> Vec<(&'static str, [f32; 2])> {
let cols: [(&str, [f32; 3]); 6] = [
("R", [0.75, 0.0, 0.0]),
("Mg", [0.75, 0.0, 0.75]),
("B", [0.0, 0.0, 0.75]),
("Cy", [0.0, 0.75, 0.75]),
("G", [0.0, 0.75, 0.0]),
("Yl", [0.75, 0.75, 0.0]),
];
cols.iter()
.map(|(n, c)| {
if hls_mode {
let (h, _, s) = hls(*c);
let a = h.to_radians();
(*n, [s * 0.5 * a.cos(), s * 0.5 * a.sin()])
} else {
let [_, cb, cr] = standard.ycbcr(*c);
(*n, [cb, cr])
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(w: u32, h: u32, c: [f32; 3]) -> Image {
Image::filled(w, h, [c[0], c[1], c[2], 1.0])
}
#[test]
fn waveform_of_flat_grey_is_one_line() {
let img = solid(64, 32, [0.5, 0.5, 0.5]);
let o = ScopeOpts { width: 64, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::WaveformLuma, &o);
// 0.5 sits on row 50 of 0..=100; every sample lands there.
assert_eq!(s.total(0), 64 * 32);
for x in 0..64 {
assert_eq!(s.counts[0][50 * 64 + x], 32);
}
}
#[test]
fn waveform_rgb_ramps_follow_columns() {
// Horizontal ramp: column x has value x / (w - 1).
let w = 101;
let mut img = Image::new(w, 4);
for y in 0..4 {
for x in 0..w {
let v = x as f32 / (w - 1) as f32;
img.set(x, y, [v, 1.0 - v, 0.0, 1.0]);
}
}
let o = ScopeOpts { width: w, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::WaveformRgb, &o);
for x in 0..w as usize {
// Red: row 100 - x; green: row x.
assert_eq!(s.counts[0][(100 - x) * w as usize + x], 4, "red column {x}");
assert_eq!(s.counts[1][x * w as usize + x], 4, "green column {x}");
}
}
#[test]
fn luma_coefficients_per_standard() {
let red = [1.0, 0.0, 0.0];
assert!((ColorStandard::Rec601.ycbcr(red)[0] - 0.299).abs() < 1e-6);
assert!((ColorStandard::Rec709.ycbcr(red)[0] - 0.2126).abs() < 1e-6);
assert!((ColorStandard::Rec2020.ycbcr(red)[0] - 0.2627).abs() < 1e-6);
// Pure red has Cr = +0.5 in every standard, Cb negative.
for st in ColorStandard::ALL {
let [_, cb, cr] = st.ycbcr(red);
assert!((cr - 0.5).abs() < 1e-6);
assert!(cb < 0.0);
// White and grey have no chroma.
let [_, cb, cr] = st.ycbcr([0.6, 0.6, 0.6]);
assert!(cb.abs() < 1e-6 && cr.abs() < 1e-6);
}
}
#[test]
fn vectorscope_places_red_on_its_target() {
let img = solid(16, 16, [1.0, 0.0, 0.0]);
let o = ScopeOpts { width: 101, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::VectorscopeYuv, &o);
let [_, cb, cr] = ColorStandard::Rec709.ycbcr([1.0, 0.0, 0.0]);
let (x, y) = s.peak(0);
assert_eq!(x, ((cb + 0.5) * 100.0).round() as u32);
assert_eq!(y, ((0.5 - cr) * 100.0).round() as u32);
// Grey sits in the centre.
let g = compute(&solid(8, 8, [0.3, 0.3, 0.3]), ScopeKind::VectorscopeYuv, &o);
assert_eq!(g.peak(0), (50, 50));
// HLS: fully saturated green at 120° on the rim.
let h = compute(&solid(8, 8, [0.0, 1.0, 0.0]), ScopeKind::VectorscopeHls, &o);
let a = 120f32.to_radians();
assert_eq!(h.peak(0), (((0.5 * a.cos() + 0.5) * 100.0).round() as u32, ((0.5 - 0.5 * a.sin()) * 100.0).round() as u32));
}
#[test]
fn histogram_counts_and_clamp() {
// Half black, half white.
let mut img = Image::new(10, 10);
for y in 0..10 {
for x in 0..10 {
let v = if x < 5 { 0.0 } else { 1.0 };
img.set(x, y, [v, v, v, 1.0]);
}
}
let o = ScopeOpts { width: 256, height: 64, ..Default::default() };
let s = compute(&img, ScopeKind::Histogram, &o);
let bars = histogram_bars(&s);
for b in &bars {
assert_eq!(b[0], 50);
assert_eq!(b[255], 50);
assert_eq!(b.iter().sum::<u32>(), 100);
}
// Super-white: off scale in 8-bit unclamped, on the float scale, pinned to 1 clamped.
let hot = solid(4, 4, [1.2, 1.2, 1.2]);
let unclamped = ScopeOpts { clamp: false, ..o };
assert_eq!(compute(&hot, ScopeKind::Histogram, &unclamped).total(0), 0);
let fl = ScopeOpts { clamp: false, float: true, ..o };
let f = histogram_bars(&compute(&hot, ScopeKind::Histogram, &fl));
let expect = (((1.2 + 0.25) / 1.5) * 255.0f32).round() as usize;
assert_eq!(f[0][expect], 16);
let c = histogram_bars(&compute(&hot, ScopeKind::Histogram, &o));
assert_eq!(c[0][255], 16);
}
#[test]
fn parade_splits_channels_into_thirds() {
let img = solid(30, 3, [1.0, 0.5, 0.0]);
let o = ScopeOpts { width: 90, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::ParadeRgb, &o);
// Red trace in the first third at the top, green mid-height in the second, blue at the
// bottom of the third.
let (rx, ry) = s.peak(0);
let (gx, gy) = s.peak(1);
let (bx, by) = s.peak(2);
assert!(rx < 30 && ry == 0);
assert!((30..60).contains(&gx) && gy == 50);
assert!((60..90).contains(&bx) && by == 100);
let yuv = compute(&solid(30, 3, [0.5, 0.5, 0.5]), ScopeKind::ParadeYuv, &o);
// Grey: Y = 0.5 and both colour differences at mid-scale.
assert_eq!(yuv.peak(1).1, 50);
assert_eq!(yuv.peak(2).1, 50);
}
#[test]
fn yc_waveform_has_chroma_trace() {
let img = solid(8, 8, [1.0, 0.0, 0.0]);
let o = ScopeOpts { width: 8, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::WaveformYc, &o);
let [y, cb, cr] = ColorStandard::Rec709.ycbcr([1.0, 0.0, 0.0]);
assert_eq!(s.peak(0).1, ((1.0 - y) * 100.0).round() as u32);
assert_eq!(s.peak(2).1, ((1.0 - (cb * cb + cr * cr).sqrt()) * 100.0).round() as u32);
}
}

View File

@ -53,6 +53,9 @@ pub trait FootageSource: Send + Sync {
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
/// a cache ignore it.
fn set_cache_budget(&self, _bytes: usize) {}
/// Settings ▸ Disk ▸ Conformed Audio Folder: where decoded audio is kept between reads
/// (`None` = off); sources that don't decode audio ignore it.
fn set_conform_folder(&self, _folder: Option<std::path::PathBuf>) {}
/// The decoded-frame cache budget in bytes, if the source has a cache.
fn cache_budget(&self) -> Option<usize> {
None
@ -303,6 +306,19 @@ pub struct RenderOpts {
pub roi: Option<[f64; 4]>,
/// Which proxies stand in for footage and compositions (Render Settings ▸ Proxy Use).
pub proxy: effectcraft_project::render_queue::ProxyUse,
/// Switches Affect Nested Comps (Settings ▸ General): a precomp layer's Quality and Motion
/// Blur switches also limit the layers of the nested comp (Draft/Wireframe or motion blur off
/// propagate down; they never raise a nested layer's own setting).
pub nested_switches: bool,
/// Realtime Shadows in Draft (Settings ▸ 3D): draft renders (`draft`) still cast shadows.
pub draft_shadows: bool,
}
impl RenderOpts {
/// Whether 3D layers cast shadows in this render.
pub fn shadows(&self) -> bool {
!self.draft || self.draft_shadows
}
}
impl Default for RenderOpts {
@ -315,6 +331,8 @@ impl Default for RenderOpts {
view: None,
roi: None,
backend: Backend::Cpu,
nested_switches: true,
draft_shadows: true,
proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings,
}
}
@ -342,6 +360,9 @@ pub struct Renderer<'a> {
opacity_mul: f32,
/// Collapsed precomp: nested 3D layers join the parent's 3D space.
pub(crate) collapse3d: Option<Collapse3d<'a>>,
/// Switches inherited from the precomp layers this comp is rendered through (see
/// [`RenderOpts::nested_switches`]): the worst quality and whether motion blur is allowed.
pub(crate) inherited: Option<(Quality, bool)>,
}
/// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`).
@ -385,9 +406,39 @@ impl<'a> Renderer<'a> {
outer: None,
opacity_mul: 1.0,
collapse3d: None,
inherited: None,
}
}
/// A layer's Quality switch, limited by the precomp layers above it when
/// [`RenderOpts::nested_switches`] is on.
pub fn quality(&self, layer: &Layer) -> Quality {
let rank = |q: Quality| match q {
Quality::Best => 0,
Quality::Draft => 1,
Quality::Wireframe => 2,
};
match self.inherited {
Some((q, _)) if rank(q) > rank(layer.switches.quality) => q,
_ => layer.switches.quality,
}
}
/// A layer's Motion Blur switch, limited by the precomp layers above it.
pub(crate) fn layer_motion_blur(&self, layer: &Layer) -> bool {
layer.switches.motion_blur && self.inherited.is_none_or(|(_, mb)| mb)
}
/// The renderer for the nested comp of precomp layer `layer`: its switches pass down when
/// [`RenderOpts::nested_switches`] is on.
fn nested_through(&self, layer: &Layer) -> Renderer<'a> {
let mut sub = self.nested();
if self.opts.nested_switches {
sub.inherited = Some((self.quality(layer), self.layer_motion_blur(layer)));
}
sub
}
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
fn nested(&self) -> Renderer<'a> {
Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, ..*self }
@ -769,7 +820,7 @@ impl<'a> Renderer<'a> {
// values.
let ov = essential_overrides(ctx, layer);
let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) };
let base = self.nested();
let base = self.nested_through(layer);
let sub = match &tmp {
Some(p) => Renderer { project: p, ..base },
None => base,
@ -1058,7 +1109,7 @@ impl<'a> Renderer<'a> {
}
fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling {
if layer.switches.quality == Quality::Draft {
if self.quality(layer) == Quality::Draft {
// Draft quality: no interpolation (nearest neighbour), as After Effects' Draft.
effectcraft_raster::Sampling::Nearest
} else if self.opts.draft {
@ -1103,7 +1154,7 @@ impl<'a> Renderer<'a> {
/// Whether a layer is motion blurred.
fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur
self.opts.motion_blur && ctx.comp.enable_motion_blur && self.layer_motion_blur(layer)
}
/// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D
@ -1227,7 +1278,7 @@ impl<'a> Renderer<'a> {
/// Draw a 2D layer into `canvas`; returns the region it may have changed.
fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, blank: bool) -> Region {
let opacity = ctx.opacity(layer) as f32 * self.opacity_mul;
if layer.switches.quality == Quality::Wireframe && !layer.switches.adjustment {
if self.quality(layer) == Quality::Wireframe && !layer.switches.adjustment {
self.draw_wireframe(ctx, layer, canvas);
return Region::Full;
}

View File

@ -855,7 +855,14 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run:
{
add_environment(&mut b, w, h, d, k, rot);
}
super::raster::shadow_maps(&mut b.s, r.opts.draft);
if r.opts.shadows() {
super::raster::shadow_maps(&mut b.s, r.opts.draft);
} else {
// Settings ▸ 3D ▸ Realtime Shadows in Draft off: draft renders skip shadows.
for l in &mut b.s.lights {
l.shadow = -1;
}
}
}
// Camera matrices.
let s = r.opts.scale;

View File

@ -170,7 +170,7 @@ pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) {
/// Number of motion-blur sub-samples for a layer.
fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
if r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur {
if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) {
if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
} else {
1
@ -373,9 +373,9 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
}
let out = (canvas.width, canvas.height);
let g = gather(r, ctx, run, out);
let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect();
let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| r.opts.shadows() && i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect();
composite(canvas, &g.items, &g.lights, &casters, run[0].id.0 as u32);
for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) {
for l in run.iter().filter(|l| r.quality(l) == effectcraft_project::Quality::Wireframe) {
r.draw_layer(ctx, l, canvas, false);
}
}
@ -394,7 +394,7 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32,
let mut items: Vec<Item> = run
.par_iter()
.enumerate()
.filter(|(_, l)| ctx.opacity(l) > 0.0 && l.switches.quality != effectcraft_project::Quality::Wireframe)
.filter(|(_, l)| ctx.opacity(l) > 0.0 && r.quality(l) != effectcraft_project::Quality::Wireframe)
.flat_map_iter(|(i, l)| {
// Stack order with room for a collapsed precomp's layers in between.
let i = i * 1024;
@ -689,8 +689,7 @@ pub struct Plane3d {
/// Prepare a run of 3D layers for an accelerator. `None` when the run must be drawn on the CPU
/// (adjustment, wireframe or environment background layers, Advanced 3D).
pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Option<Run3d> {
if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || l.switches.quality == effectcraft_project::Quality::Wireframe)
{
if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || r.quality(l) == effectcraft_project::Quality::Wireframe) {
return None;
}
if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() {
@ -718,7 +717,7 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3
order: it.order,
})
.collect();
let casters = planes.iter().enumerate().filter(|(_, p)| p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect();
let casters = planes.iter().enumerate().filter(|(_, p)| r.opts.shadows() && p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect();
Some(Run3d { planes, casters, lights: g.lights, seed: run[0].id.0 as u32 })
}

View File

@ -204,7 +204,7 @@ File.prototype = {
return true;
},
rename: function () { throw __err("File.rename is not supported"); },
execute: function () { throw __err("scripts can't execute files"); },
execute: function () { return JSON.parse(__file("execute", this.__path)); },
openDlg: function () { return null; },
saveDlg: function () { return null; },
};

View File

@ -294,6 +294,11 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
let path = arg_string(args, 1, ctx)?;
let data = if args.len() > 2 { arg_string(args, 2, ctx)? } else { String::new() };
let r: Result<J, String> = with_active(|a| {
// File.execute(): opened with its default application through `file.executeFile`
// (Allow Scripts to Write Files gate, Warn User When Executing Files confirmation).
if op == "execute" {
return exec(&mut a.session, "file.executeFile", json!({"path": path})).map(|v| json!(v.get("opened").is_some()));
}
let s = &a.session;
match op.as_str() {
"access" => Ok(json!(check_access(s, &path, false)?.exists())),

View File

@ -48,6 +48,8 @@ pub struct FaceInfo {
pub data: FaceData,
/// "bundled", "system", …
pub origin: &'static str,
/// The family name in the font's own (non-English) language, when it has one.
pub native_family: Option<String>,
}
/// Something that can list fonts (system folders, a web font picker, a project's font folder…).
@ -121,7 +123,7 @@ impl FontSource for DirectorySource {
}
fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin }
FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin, native_family: n.native_family }
}
/// A loaded (or loadable) face.
@ -315,6 +317,12 @@ pub fn all_faces() -> Vec<Arc<Face>> {
db().read().unwrap_or_else(|e| e.into_inner()).faces.clone()
}
/// Family names in the fonts' own languages: English family → native family (only families
/// that have one).
pub fn native_families() -> std::collections::BTreeMap<String, String> {
all_faces().into_iter().filter_map(|f| Some((f.info.family.clone(), f.info.native_family.clone()?))).collect()
}
/// Families with their style names, sorted by family; styles in weight order.
pub fn families() -> Vec<(String, Vec<String>)> {
let mut m: std::collections::BTreeMap<String, Vec<(bool, u16, String)>> = Default::default();

View File

@ -13,6 +13,9 @@ pub struct FaceNames {
pub full_name: String,
pub weight: u16,
pub italic: bool,
/// The family name in another language than English (CJK, Arabic… fonts), when the font
/// has one (Settings ▸ Type ▸ Show Font Names in English off shows it).
pub native_family: Option<String>,
}
fn be16(b: &[u8], o: usize) -> Option<u16> {
@ -75,6 +78,7 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
let family = get(16).or_else(|| get(1))?;
let style = get(17).or_else(|| get(2)).unwrap_or_else(|| "Regular".into());
let full_name = get(4).unwrap_or_else(|| format!("{family} {style}"));
let native_family = native_name(&nb, 16).or_else(|| native_name(&nb, 1)).filter(|n| *n != family);
let (mut weight, mut italic) = (400u16, false);
if let Some((oo, ol)) = os2
&& let Some(ob) = read_at(r, oo, ol.min(78))
@ -84,7 +88,28 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
}
let sl = style.to_ascii_lowercase();
italic |= sl.contains("italic") || sl.contains("oblique");
Some(FaceNames { index, family, style, full_name, weight, italic })
Some(FaceNames { index, family, style, full_name, weight, italic, native_family })
}
/// A Windows Unicode name-table string by id in a language other than US English.
fn native_name(b: &[u8], id: u16) -> Option<String> {
let count = be16(b, 2)? as usize;
let storage = be16(b, 4)? as usize;
for i in 0..count {
let r = 6 + i * 12;
let (plat, enc, lang, nid, len, off) =
(be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize);
if nid != id || plat != 3 || !(enc == 1 || enc == 10) || lang == 0x409 {
continue;
}
let raw = b.get(storage + off..storage + off + len)?;
let u: Vec<u16> = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let s = String::from_utf16_lossy(&u);
if !s.trim().is_empty() {
return Some(s);
}
}
None
}
/// A name-table string by id, preferring Windows Unicode English, then any Unicode, then Mac Roman.

View File

@ -51,11 +51,13 @@ pub struct AudioOutput {
pub device: String,
pub left: u32,
pub right: u32,
/// Preview Sample Rate: devices that support it are opened at this rate.
pub rate: u32,
}
impl AudioOutput {
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> AudioOutput {
AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right }
AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right, rate: p.audio.preview_sample_rate }
}
}
@ -165,7 +167,7 @@ pub struct AudioPlayback {
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
/// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`].
feeder: Option<(RenderSource, ItemId, i64)>,
feeder: Option<(RenderSource, ItemId, i64, u32)>,
pub rate: u32,
pub span: PlaySpan,
}
@ -178,6 +180,9 @@ fn queue_target(rate: u32) -> usize {
impl AudioPlayback {
/// Start mixing `comp` over `span` into `device`.
/// `mix_rate`: Settings ▸ Audio ▸ Preview Sample Rate, the rate the comp's audio is mixed
/// at (resampled to the device's rate when they differ; 0 = the device's rate).
#[allow(clippy::too_many_arguments)]
pub fn start(
mut device: Box<dyn AudioDevice>,
src: RenderSource,
@ -186,8 +191,10 @@ impl AudioPlayback {
wa: Tick,
wb: Tick,
looping: bool,
mix_rate: u32,
) -> Result<AudioPlayback, String> {
let rate = device.sample_rate().max(8000);
let mix = if mix_rate == 0 { rate } else { mix_rate.clamp(8000, 192_000) };
let span = PlaySpan::new(start, wa, wb, looping, rate);
let feed = Arc::new(AudioFeed::default());
let stop = Arc::new(AtomicBool::new(false));
@ -195,10 +202,10 @@ impl AudioPlayback {
let mut cursor = span.start;
let prime = (rate as usize / 20).div_ceil(BLOCK);
for _ in 0..prime {
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix);
}
if cfg!(target_arch = "wasm32") {
let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, cursor)), rate, span };
let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, cursor, mix)), rate, span };
p.device.start(feed)?;
p.pump();
return Ok(p);
@ -211,7 +218,7 @@ impl AudioPlayback {
let target = queue_target(rate);
while !stop.load(Ordering::Relaxed) {
if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) {
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix);
} else {
std::thread::sleep(std::time::Duration::from_millis(4));
}
@ -226,10 +233,10 @@ impl AudioPlayback {
/// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread
/// does elsewhere), then let the device take what it needs.
pub fn pump(&mut self) {
if let Some((src, comp, cursor)) = &mut self.feeder {
if let Some((src, comp, cursor, mix)) = &mut self.feeder {
let target = queue_target(self.rate);
while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) {
*cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate);
*cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate, *mix);
}
}
self.device.pump();
@ -261,7 +268,7 @@ impl Drop for AudioPlayback {
/// Mix one block at `cursor` into the feed; returns the next cursor (wrapping or finishing at
/// the work-area end).
fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32) -> i64 {
fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32, mix: u32) -> i64 {
let mut cursor = cursor;
if cursor >= span.wb {
if !span.looping {
@ -271,11 +278,39 @@ fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpa
cursor = span.wa;
}
let n = ((span.wb - cursor) as usize).min(BLOCK);
let buf = effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, sample_time(cursor, rate), n, rate);
let t = sample_time(cursor, rate);
let buf = if mix == rate {
effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, n, rate)
} else {
let m = (n as u64 * mix as u64).div_ceil(rate as u64) as usize + 1;
let b = effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, m, mix);
resample_stereo(&b, mix, rate, n)
};
feed.push(&buf);
cursor + n as i64
}
/// Linear resampling of interleaved stereo from `from` Hz to `n` frames at `to` Hz.
pub fn resample_stereo(src: &[f32], from: u32, to: u32, n: usize) -> Vec<f32> {
let frames = src.len() / 2;
let mut out = Vec::with_capacity(n * 2);
if frames == 0 {
out.resize(n * 2, 0.0);
return out;
}
let step = from as f64 / to as f64;
for i in 0..n {
let x = i as f64 * step;
let a = (x.floor() as usize).min(frames - 1);
let b = (a + 1).min(frames - 1);
let f = (x - a as f64) as f32;
for c in 0..2 {
out.push(src[a * 2 + c] * (1.0 - f) + src[b * 2 + c] * f);
}
}
out
}
/// VU meter state for the Audio panel: smoothed level with a falling decay and peak hold, in
/// dBFS per channel.
#[derive(Clone, Copy, Debug)]

View File

@ -36,10 +36,16 @@ pub enum PanelKind {
MaskInterpolation,
ScriptConsole,
EssentialGraphics,
LumetriScopes,
Footage,
MediaBrowser,
Metadata,
Progress,
ContentAwareFill,
}
impl PanelKind {
pub const ALL: [PanelKind; 26] = [
pub const ALL: [PanelKind; 32] = [
PanelKind::Project,
PanelKind::EffectControls,
PanelKind::Composition,
@ -66,6 +72,12 @@ impl PanelKind {
PanelKind::MaskInterpolation,
PanelKind::ScriptConsole,
PanelKind::EssentialGraphics,
PanelKind::LumetriScopes,
PanelKind::Footage,
PanelKind::MediaBrowser,
PanelKind::Metadata,
PanelKind::Progress,
PanelKind::ContentAwareFill,
];
pub fn title(self) -> &'static str {
match self {
@ -95,6 +107,12 @@ impl PanelKind {
PanelKind::MaskInterpolation => "Mask Interpolation",
PanelKind::ScriptConsole => "Script Console",
PanelKind::EssentialGraphics => "Essential Graphics",
PanelKind::LumetriScopes => "Lumetri Scopes",
PanelKind::Footage => "Footage",
PanelKind::MediaBrowser => "Media Browser",
PanelKind::Metadata => "Metadata",
PanelKind::Progress => "Progress",
PanelKind::ContentAwareFill => "Content-Aware Fill",
}
}
pub fn id(self) -> String {
@ -102,7 +120,7 @@ impl PanelKind {
}
pub fn from_name(s: &str) -> Option<PanelKind> {
let n = s.to_ascii_lowercase().replace([' ', '_', '-', '&'], "");
Self::ALL.iter().copied().find(|p| format!("{p:?}").to_ascii_lowercase() == n || p.title().to_ascii_lowercase().replace([' ', '&'], "") == n)
Self::ALL.iter().copied().find(|p| format!("{p:?}").to_ascii_lowercase() == n || p.title().to_ascii_lowercase().replace([' ', '&', '-'], "") == n)
}
/// Window-menu shortcut.
pub fn window_shortcut(self) -> Option<&'static str> {
@ -295,7 +313,7 @@ pub fn workspace(name: &str) -> DockNode {
hsplit(
FixedA(360.0),
tabs(&[EffectControls, Project], 0),
hsplit(FixedB(300.0), tabs(&[Composition, Layer], 0), right(&[Info, Preview], &[EffectsPresets])),
hsplit(FixedB(340.0), tabs(&[Composition, Layer], 0), right(&[LumetriScopes, Info, Preview], &[EffectsPresets])),
),
tabs(&[Timeline], 0),
),
@ -315,17 +333,17 @@ pub fn workspace(name: &str) -> DockNode {
Ratio(0.55),
hsplit(
FixedA(300.0),
tabs(&[Project, EffectControls, Flowchart, History], 0),
tabs(&[Project, EffectControls, Flowchart, History, MediaBrowser, Metadata], 0),
hsplit(
FixedB(300.0),
tabs(&[Composition, Layer], 0),
tabs(&[Composition, Layer, Footage], 0),
right(
&[Info, Preview, Audio, Align, Character, Paragraph],
&[EffectsPresets, Properties, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, Markers],
&[Info, Preview, Audio, Align, Character, Paragraph, LumetriScopes],
&[EffectsPresets, Properties, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, Markers, ContentAwareFill],
),
),
),
tabs(&[Timeline, RenderQueue], 0),
tabs(&[Timeline, RenderQueue, Progress], 0),
),
"Standard" => vsplit(
Ratio(0.56),
@ -779,6 +797,10 @@ pub fn draw_group_chrome(
}
} else {
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
if t.gradients {
// Settings ▸ Appearance ▸ Use Gradients.
crate::theme::gradient_rect(&painter, strip.shrink2(vec2(t.radius, 0.0)), t.grad_top(t.panel_bg), t.panel_bg);
}
let mut x = strip.min.x + 12.0;
let text_y = strip.min.y + 16.0;
for (i, p) in g.panels.iter().enumerate() {

View File

@ -29,6 +29,30 @@ impl EffectcraftApp {
out
}
/// Settings ▸ Appearance ▸ Use Label Color for Related Tabs: the Composition and Timeline
/// tabs carry their comp's label colour, Effect Controls and Properties the layer's.
fn label_tab_marks(&self, ui: &egui::Ui) {
if !self.session.prefs.appearance.use_label_color_for_tabs {
return;
}
let Some(cid) = self.session.active_comp_id() else { return };
let comp_label = self.session.project.item(cid).map(|i| i.label);
let layer_label = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|l| c.layer(*l))).map(|l| l.label);
for (p, label) in [
(PanelKind::Composition, comp_label),
(PanelKind::Timeline, comp_label),
(PanelKind::EffectControls, layer_label),
(PanelKind::Properties, layer_label),
] {
let (Some(label), Some(e)) = (label, self.auto.find(&format!("panel.tab.{}", p.id()))) else { continue };
if label == effectcraft_engine::color::Label::None {
continue;
}
let r = Rect::from_min_size(pos2(e.rect[0] + 2.0, e.rect[1] + 9.0), vec2(4.0, (e.rect[3] - 16.0).max(6.0)));
ui.painter().rect_filled(r, 1.0, self.tokens.label(label));
}
}
/// Edit the full layout (docked tree + floating groups) with a [`Layout`] operation.
pub fn edit_layout(&mut self, f: impl FnOnce(&mut Layout) -> bool) -> bool {
let mut l = Layout { root: self.ui.dock.clone(), floating: self.ui.floating.clone() };
@ -98,6 +122,7 @@ impl EffectcraftApp {
for g in &groups {
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title));
}
self.label_tab_marks(ui);
if maximized.is_none() {
self.ui.dock = dock;
}

View File

@ -16,6 +16,7 @@ pub mod icons;
pub mod menus;
pub mod native_menu;
pub mod panels;
pub mod prefs_live;
pub mod state;
pub mod theme;
pub mod widgets;
@ -255,7 +256,7 @@ impl EffectcraftApp {
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
self.ui.cache_when_idle = p.previews.cache_frames_when_idle;
self.ui.viewer.fast_preview = p.previews.fast_previews;
self.frames.set_budget(p.preview_cache_bytes());
self.frames.set_budget(p.cache_budgets(self.session.sys_memory).preview);
}
/// Change settings from the UI (applied next frame and saved).
@ -475,6 +476,8 @@ impl EffectcraftApp {
view: self.session.view_camera(comp),
roi,
backend: effectcraft_engine::render::Backend::Auto,
nested_switches: self.session.prefs.general.switches_affect_nested_comps,
draft_shadows: self.session.prefs.three_d.realtime_shadows,
proxy: Default::default(),
}
}
@ -528,7 +531,8 @@ impl EffectcraftApp {
}
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop) {
let mix = self.session.prefs.audio.preview_sample_rate;
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop, mix) {
Ok(a) => self.audio = Some(a),
Err(e) => log::warn!("audio preview: {e}"),
}
@ -787,6 +791,11 @@ impl EffectcraftApp {
self.session.poll_mask_track();
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
// Background tasks (Content-Aware Fill, Scene Edit Detection).
if !self.session.tasks.is_empty() {
self.session.poll_jobs();
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// Analyses running in a worker (the web app).
if !self.session.offloaded.is_empty() {
self.session.poll_offload();
@ -806,6 +815,7 @@ impl EffectcraftApp {
self.apply_prefs(&ctx);
self.handle_events(&ctx);
self.tick_autosave(&ctx);
prefs_live::frame(self, &ctx);
if let Some(rx) = self.command_inbox.take() {
while let Ok(id) = rx.try_recv() {
if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) {

View File

@ -754,6 +754,37 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.show_panel(PanelKind::Layer);
Value::Null
}
"app.confirmExecute" => {
// Settings ▸ Scripting ▸ Warn User When Executing Files.
let path = p.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
crate::panels::forms::form(
app,
"A script wants to open a file",
"file.executeFile",
json!({"path": path, "confirmed": true}),
vec![crate::panels::forms::Field::text("path", "Open", &path)],
);
Value::Null
}
"app.showReport" => {
let title = p.get("title").and_then(Value::as_str).unwrap_or("Report").to_string();
let text = p.get("text").and_then(Value::as_str).unwrap_or_default().to_string();
crate::panels::dialogs::info(app, &title, &text);
Value::Null
}
"window.assignWorkspaceShortcut" => {
let mut q = p.clone();
q["workspace"] = json!(app.ui.workspace);
return run_engine(app, ctx, "window.assignWorkspaceShortcut", q);
}
"file.interpretFootage" => {
// Import ▸ Interpret Unlabeled Alpha As: Ask User.
if let Some(i) = p.get("item").and_then(Value::as_u64) {
app.session.state.project_selection = vec![effectcraft_engine::project::ItemId(i)];
}
crate::panels::dialogs::open_form(app, "file.interpretFootage", &json!({}));
Value::Null
}
"effect.manage" | "anim.browsePresets" => {
app.show_panel(PanelKind::EffectsPresets);
Value::Null
@ -794,6 +825,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"file.runScript" => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
"file.replaceFootage" => ("path", Ask::Import),
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
"file.saveCopyAsXml" => ("path", Ask::Save("Untitled Project.ecprojx")),
"keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])),
"essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")),
"essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])),
"file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])),
@ -873,6 +906,15 @@ pub(crate) fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
match e.command.as_str() {
"edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()),
"edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()),
// Window ▸ Layer: the layer open in the Layer panel.
"window.panel" if e.params.get("panel").and_then(Value::as_str) == Some("layer") => {
let name = app
.ui
.layer_panel
.and_then(|id| app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(id))))
.map(|l| l.name.clone());
format!("Layer: {}", name.unwrap_or_else(|| "(none)".into()))
}
_ => effectcraft_engine::menus::entry_label(&app.session, e),
}
}
@ -1137,29 +1179,38 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
MenuNode::Separator => {
ui.separator();
}
MenuNode::Submenu { label, children } if label == "Open Recent" => {
// File ▸ Open Recent: the recent projects (Settings), then the static entries.
ui.menu_button((gutter(false), label.as_str()), |ui| {
ui.set_min_width(320.0);
let recent = app.session.prefs.recent_projects.clone();
if recent.is_empty() {
ui.add_enabled(false, egui::Button::new((gutter(false), "No Recent Projects")));
MenuNode::Dynamic { name } => {
// Recent projects / footage / presets, undo history, shortcut slots, viewers.
let ws = app.ui.workspace.clone();
let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws));
if entries.is_empty()
&& let Some(e) = empty
{
ui.add_enabled(false, egui::Button::new((gutter(false), e)));
}
for (i, e) in entries.iter().enumerate() {
let tip = match name.as_str() {
"recentProjects" => app.session.prefs.recent_projects.get(i).cloned(),
"recentFootage" => app.session.prefs.recent_footage.get(i).cloned(),
"recentPresets" => app.session.prefs.recent_presets.get(i).cloned(),
_ => None,
};
let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(false), e.label.as_str())));
let r = match &tip {
Some(t) => r.on_hover_text(t),
None => r,
};
app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label);
if r.clicked() {
*clicked = Some((e.command.clone(), e.params.clone()));
ui.close();
}
for (i, path) in recent.iter().enumerate() {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
let r = ui.add(egui::Button::new((gutter(false), name))).on_hover_text(path);
app.auto.add(&format!("menu.openRecent.{i}"), r.rect, path);
if r.clicked() {
*clicked = Some(("file.openRecent".into(), json!({"index": i})));
ui.close();
}
}
ui.separator();
menu_nodes(app, ui, children, clicked);
});
}
}
MenuNode::Submenu { label, children } => {
ui.menu_button((gutter(false), label.as_str()), |ui| {
let ws = app.ui.workspace.clone();
let shown = effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws));
ui.menu_button((gutter(false), shown.as_str()), |ui| {
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
// Long submenus (Blending Mode, effect categories) scroll instead of running
// off the screen.
@ -1177,6 +1228,11 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
}
}
/// Frontend state for dynamic menus (the current workspace).
pub(crate) fn dyn_ctx(workspace: &str) -> effectcraft_engine::menus::DynCtx<'_> {
effectcraft_engine::menus::DynCtx { workspace: Some(workspace) }
}
/// The check-mark column every menu row reserves (like macOS / After Effects menus).
fn gutter(checked: bool) -> egui::Atom<'static> {
use egui::AtomExt;

View File

@ -134,28 +134,41 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
let id = format!("{path}.{i}");
match n {
MenuNode::Separator => out.push(NativeNode::Separator),
MenuNode::Dynamic { name } => {
// Recent projects / footage / presets, undo history, shortcut slots, viewers.
let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace));
if entries.is_empty()
&& let Some(e) = empty
{
out.push(NativeNode::Item(NativeItem {
id: format!("{id}.none"),
label: e.to_string(),
command: String::new(),
params: Value::Null,
shortcut: None,
accelerator: None,
enabled: false,
checked: None,
}));
}
for (k, e) in entries.iter().enumerate() {
out.push(NativeNode::Item(NativeItem {
id: format!("{id}.d{k}"),
label: e.label.clone(),
command: e.command.clone(),
params: e.params.clone(),
shortcut: None,
accelerator: None,
enabled: crate::menus::entry_enabled(app, e),
checked: None,
}));
}
}
MenuNode::Submenu { label, children } => {
let mut kids = vec![];
if label == "Open Recent" {
for (k, p) in app.session.prefs.recent_projects.iter().enumerate() {
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.clone());
kids.push(NativeNode::Item(NativeItem {
id: format!("{id}.r{k}"),
label: name,
command: "file.openRecent".into(),
params: json!({"index": k}),
shortcut: None,
accelerator: None,
enabled: true,
checked: None,
}));
}
if !kids.is_empty() {
kids.push(NativeNode::Separator);
}
}
rec(app, children, &id, &mut kids);
out.push(NativeNode::Submenu { label: label.clone(), children: kids });
let shown = effectcraft_engine::menus::submenu_label(&app.session, label, &crate::menus::dyn_ctx(&app.ui.workspace));
out.push(NativeNode::Submenu { label: shown, children: kids });
}
MenuNode::Item(e) => {
let role = match e.command.as_str() {
@ -272,6 +285,11 @@ pub fn state_key(app: &EffectcraftApp) -> u64 {
s.journal.len().hash(&mut h);
s.journal.last().map(|j| &j.0).hash(&mut h);
s.prefs.recent_projects.hash(&mut h);
(&s.prefs.recent_footage, &s.prefs.recent_presets).hash(&mut h);
s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h));
s.state.open_comps.hash(&mut h);
s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h);
s.shortcuts().keys.len().hash(&mut h);
s.keymaps.active.hash(&mut h);
let v = &app.ui.viewer;
(v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h);
@ -340,7 +358,10 @@ mod tests {
fn commands(n: &[NativeNode], out: &mut Vec<(String, String)>) {
for x in n {
match x {
NativeNode::Item(i) if i.command != "file.openRecent" => out.push((i.command.clone(), i.params.to_string())),
// Dynamic entries (recent files, History, viewers…) are not tree entries.
NativeNode::Item(i) if !i.id.rsplit('.').next().is_some_and(|l| l.starts_with('d') || l == "none") => {
out.push((i.command.clone(), i.params.to_string()))
}
NativeNode::Predefined { command, .. } if !command.is_empty() => out.push((command.clone(), "null".into())),
NativeNode::Submenu { children, .. } => commands(children, out),
_ => {}
@ -431,7 +452,9 @@ mod tests {
app.session.execute("layer.setSwitch", json!({"switch": "lock"})).unwrap();
assert_ne!(state_key(&app), k0);
let spec1 = build(&app);
assert_eq!(spec0.structure(), spec1.structure(), "state changes don't rebuild the menu");
// The new undo step joins Edit ▸ History (a dynamic submenu); nothing else is rebuilt.
let history = |s: &NativeMenu| s.items().into_iter().filter(|i| i.command == "edit.history").count();
assert_eq!(history(&spec1), history(&spec0) + 1);
let lock = spec1.items().into_iter().find(|i| i.command == "layer.setSwitch" && i.params["switch"] == "lock").unwrap().checked;
assert_eq!(lock, Some(true));
assert_ne!(undo(&spec0), undo(&spec1));

View File

@ -0,0 +1,138 @@
//! Content-Aware Fill panel (Window ▸ Content-Aware Fill): Fill Target, Alpha Expansion, Fill
//! Method (Object / Surface / Edge Blend), Lighting Correction, Range (Work Area / Entire
//! Duration), a reference frame from a layer of the comp, and Generate Fill Layer. The settings
//! live in `EditorState::content_fill` (`contentFill.set`); the button runs
//! `contentFill.generate` as a background job (Window ▸ Progress).
use egui::{Align2, Rect, pos2, vec2};
use serde_json::json;
use super::panel_kit as kit;
use crate::EffectcraftApp;
use crate::theme::Tokens;
const ROW: f32 = 28.0;
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.panel_bg);
let x0 = rect.min.x + 12.0;
let xv = x0 + 130.0;
let w = (rect.max.x - xv - 12.0).clamp(90.0, 220.0);
let mut y = rect.min.y + 10.0;
let st = app.session.state.content_fill.clone();
let comp = app.session.active_comp().cloned();
let layer = comp.as_ref().and_then(|c| app.session.state.selected_layers.first().and_then(|l| c.layer(*l)).cloned());
// Fill Target.
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, "Fill Target", Tokens::semibold(12.0), t.text);
y += ROW - 6.0;
let target = match &layer {
Some(l) => {
let masks = l
.masks()
.map(|m| {
m.groups()
.filter(|g| {
matches!(
g.kind,
effectcraft_engine::project::GroupKind::Mask { mode: effectcraft_engine::project::MaskMode::Subtract, .. }
| effectcraft_engine::project::GroupKind::Mask { inverted: true, .. }
)
})
.count()
})
.unwrap_or(0);
format!("{} — {masks} subtracting mask(s)", l.name)
}
None => "Select a layer with a Subtract mask".into(),
};
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, &target, Tokens::ui(11.5), if layer.is_some() { t.text_dim } else { t.text_faint });
app.auto.add("contentFill.target", Rect::from_min_size(pos2(x0, y), vec2(rect.width() - 24.0, 18.0)), &target);
y += ROW;
let mut set = serde_json::Map::new();
// Alpha Expansion.
kit::label(ui, pos2(x0, y), "Alpha Expansion:", &t);
let a = kit::number(app, ui, pos2(xv, y), "contentFill.alphaExpansion", st.alpha_expansion, 0.2, (0.0, 100.0), 0, " px");
if (a - st.alpha_expansion).abs() > 1e-9 {
set.insert("alphaExpansion".into(), json!(a.round()));
}
y += ROW;
// Fill Method.
kit::label(ui, pos2(x0, y), "Fill Method:", &t);
let methods = ["object", "surface", "edgeBlend"];
let cur = methods.iter().position(|m| *m == st.method).unwrap_or(0);
if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "contentFill.method", &["Object", "Surface", "Edge Blend"], cur) {
set.insert("method".into(), json!(methods[i]));
}
y += ROW;
// Lighting Correction (Object / Surface).
kit::label(ui, pos2(x0, y), "Lighting Correction:", &t);
let lights = ["none", "subtle", "moderate", "strong"];
let cur = lights.iter().position(|m| *m == st.lighting_correction).unwrap_or(0);
if st.method != "edgeBlend"
&& let Some(i) =
kit::dropdown(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "contentFill.lighting", &["None", "Subtle", "Moderate", "Strong"], cur)
{
set.insert("lightingCorrection".into(), json!(lights[i]));
}
y += ROW;
// Range.
kit::label(ui, pos2(x0, y), "Range:", &t);
let cur = usize::from(st.range == "entire");
if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "contentFill.range", &["Work Area", "Entire Duration"], cur) {
set.insert("range".into(), json!(if i == 1 { "entire" } else { "workArea" }));
}
y += ROW;
// Reference frame: a layer of the comp at a time.
kit::label(ui, pos2(x0, y), "Reference Frame:", &t);
if let Some(c) = &comp {
let mut names = vec!["None".to_string()];
names.extend(c.layers.iter().map(|l| l.name.clone()));
let cur = st.reference_layer.and_then(|r| c.layers.iter().position(|l| l.id == r)).map(|i| i + 1).unwrap_or(0);
let opts: Vec<&str> = names.iter().map(String::as_str).collect();
if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "contentFill.referenceLayer", &opts, cur) {
set.insert("referenceLayer".into(), if i == 0 { json!(null) } else { json!(c.layers[i - 1].id.0) });
}
y += ROW;
if st.reference_layer.is_some() {
let at = st.reference_time.unwrap_or(0.0);
p.text(pos2(xv, y + 9.0), Align2::LEFT_CENTER, format!("at {}", kit::seconds(at)), Tokens::ui(11.5), t.text_dim);
if kit::button(app, ui, Rect::from_min_size(pos2(xv + 80.0, y), vec2(120.0, 20.0)), "contentFill.referenceHere", "Use Current Time", false) {
set.insert("referenceTime".into(), json!(app.session.time().seconds()));
}
y += ROW;
}
} else {
y += ROW;
}
if !set.is_empty() {
kit::exec(app, "contentFill.set", serde_json::Value::Object(set));
}
// Generate, or the running job.
y += 6.0;
let running = app.session.jobs().into_iter().find(|j| j.kind == "contentFill");
match running {
Some(j) => {
let frac = j.fraction.unwrap_or(0.0) as f32;
let bar = Rect::from_min_size(pos2(x0, y + 7.0), vec2((rect.width() - 110.0).max(60.0), 6.0));
p.rect_filled(bar, 3.0, t.field_bg);
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * frac, 6.0)), 3.0, t.accent);
p.text(pos2(x0, y + 24.0), Align2::LEFT_CENTER, format!("{} {:.0} %", j.message, frac * 100.0), Tokens::ui(11.0), t.text_dim);
if kit::button(app, ui, Rect::from_min_size(pos2(bar.max.x + 10.0, y), vec2(70.0, 20.0)), "contentFill.cancel", "Cancel", false) {
kit::exec(app, "jobs.cancel", json!({"job": j.id}));
}
ui.ctx().request_repaint_after(std::time::Duration::from_millis(150));
}
None => {
if kit::button(app, ui, Rect::from_min_size(pos2(x0, y), vec2(160.0, 24.0)), "contentFill.generate", "Generate Fill Layer", true) {
match &layer {
Some(l) => {
kit::exec(app, "contentFill.generate", json!({"layer": l.id.0}));
}
None => app.ui.status = "Select the layer to fill first".into(),
}
}
}
}
}

View File

@ -0,0 +1,394 @@
//! The expression editor of the Timeline, following Settings ▸ Scripting & Expressions:
//! Font Size, Syntax Highlighting, Line Numbers, Auto-complete (Tab accepts), Bracket Matching
//! and Word Wrap; and the Composition panel's expression error banner (Show Expression Error
//! Banner).
use effectcraft_engine::prefs::Scripting;
use egui::text::{LayoutJob, TextFormat};
use egui::{Color32, FontId, Rect, Stroke, pos2, vec2};
use crate::theme::Tokens;
/// Syntax colours.
const KEYWORD: Color32 = Color32::from_rgb(0xc6, 0x8a, 0xe6);
const NUMBER: Color32 = Color32::from_rgb(0xe0, 0xb4, 0x6c);
const STRING: Color32 = Color32::from_rgb(0x9c, 0xd0, 0x7a);
const COMMENT: Color32 = Color32::from_rgb(0x80, 0x88, 0x90);
const API: Color32 = Color32::from_rgb(0x6c, 0xb8, 0xf0);
const BRACKET_BG: Color32 = Color32::from_rgb(0x4a, 0x55, 0x6a);
const KEYWORDS: &[&str] = &[
"var",
"let",
"const",
"function",
"return",
"if",
"else",
"for",
"while",
"do",
"break",
"continue",
"new",
"true",
"false",
"null",
"undefined",
"this",
"typeof",
"in",
"of",
"switch",
"case",
"default",
"try",
"catch",
"throw",
];
/// Expression language names offered by auto-complete (public Expression Language Reference
/// vocabulary).
pub const COMPLETIONS: &[&str] = &[
"thisComp",
"thisLayer",
"thisProperty",
"time",
"value",
"index",
"layer",
"effect",
"mask",
"content",
"transform",
"position",
"anchorPoint",
"scale",
"rotation",
"opacity",
"orientation",
"xRotation",
"yRotation",
"zRotation",
"wiggle",
"loopOut",
"loopIn",
"loopOutDuration",
"loopInDuration",
"valueAtTime",
"velocityAtTime",
"speedAtTime",
"linear",
"ease",
"easeIn",
"easeOut",
"clamp",
"random",
"gaussRandom",
"seedRandom",
"noise",
"degreesToRadians",
"radiansToDegrees",
"timeToFrames",
"framesToTime",
"timeToTimecode",
"length",
"lookAt",
"toComp",
"fromComp",
"toWorld",
"fromWorld",
"sourceRectAtTime",
"sourceText",
"numKeys",
"key",
"nearestKey",
"inPoint",
"outPoint",
"startTime",
"width",
"height",
"frameDuration",
"marker",
"Math",
"comp",
"footage",
"posterizeTime",
"add",
"sub",
"mul",
"div",
"normalize",
"cross",
"dot",
"hslToRgb",
"rgbToHsl",
"createPath",
"points",
"inTangents",
"outTangents",
"isClosed",
];
/// The bracket matching the one at or just before char index `cursor`: (open, close) char
/// indices.
pub fn matching_bracket(text: &str, cursor: usize) -> Option<(usize, usize)> {
let chars: Vec<char> = text.chars().collect();
let pairs = [('(', ')'), ('[', ']'), ('{', '}')];
for at in [cursor, cursor.wrapping_sub(1)] {
let Some(&c) = chars.get(at) else { continue };
if let Some(&(o, cl)) = pairs.iter().find(|(o, _)| *o == c) {
let mut depth = 0;
for (i, &x) in chars.iter().enumerate().skip(at) {
if x == o {
depth += 1;
} else if x == cl {
depth -= 1;
if depth == 0 {
return Some((at, i));
}
}
}
}
if let Some(&(o, cl)) = pairs.iter().find(|(_, cl)| *cl == c) {
let mut depth = 0;
for i in (0..=at).rev() {
if chars[i] == cl {
depth += 1;
} else if chars[i] == o {
depth -= 1;
if depth == 0 {
return Some((i, at));
}
}
}
}
}
None
}
/// The identifier being typed before char index `cursor`, and auto-complete candidates for it.
pub fn completions(text: &str, cursor: usize) -> (String, Vec<&'static str>) {
let chars: Vec<char> = text.chars().collect();
let end = cursor.min(chars.len());
let mut start = end;
while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') {
start -= 1;
}
let word: String = chars[start..end].iter().collect();
if word.chars().count() < 2 || word.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return (word, vec![]);
}
let c: Vec<&str> = COMPLETIONS.iter().copied().filter(|w| w.starts_with(word.as_str()) && *w != word).take(6).collect();
(word, c)
}
/// Colour runs for JavaScript: (byte start, byte end, colour).
pub fn highlight(text: &str, base: Color32) -> Vec<(usize, usize, Color32)> {
let b = text.as_bytes();
let mut out = vec![];
let mut i = 0;
while i < b.len() {
let c = b[i];
let start = i;
let col = if c == b'/' && b.get(i + 1) == Some(&b'/') {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
COMMENT
} else if c == b'/' && b.get(i + 1) == Some(&b'*') {
i += 2;
while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) {
i += 1;
}
i = (i + 2).min(b.len());
COMMENT
} else if c == b'"' || c == b'\'' || c == b'`' {
i += 1;
while i < b.len() && b[i] != c {
i += if b[i] == b'\\' { 2 } else { 1 };
}
i = (i + 1).min(b.len());
STRING
} else if c.is_ascii_digit() {
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'.') {
i += 1;
}
NUMBER
} else if c.is_ascii_alphabetic() || c == b'_' || c == b'$' {
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'$') {
i += 1;
}
let w = &text[start..i];
if KEYWORDS.contains(&w) {
KEYWORD
} else if COMPLETIONS.contains(&w) {
API
} else {
base
}
} else {
// Advance one whole character.
i += text[i..].chars().next().map_or(1, char::len_utf8);
base
};
match out.last_mut() {
Some((_, e, c)) if *c == col && *e == start => *e = i,
_ => out.push((start, i, col)),
}
}
out
}
/// Byte index of char index `ci`.
fn byte_of(text: &str, ci: usize) -> usize {
text.char_indices().nth(ci).map(|(b, _)| b).unwrap_or(text.len())
}
/// The layout job of the editor text: syntax colours, the matched brackets' background, wrap.
fn layout(text: &str, font: FontId, base: Color32, p: &Scripting, brackets: Option<(usize, usize)>, wrap: f32) -> LayoutJob {
let mut job = LayoutJob::default();
let runs = if p.syntax_highlighting { highlight(text, base) } else { vec![(0, text.len(), base)] };
let marks: Vec<usize> = brackets.filter(|_| p.bracket_matching).map(|(a, b)| vec![byte_of(text, a), byte_of(text, b)]).unwrap_or_default();
for (s, e, col) in runs {
// Split runs at the marked brackets.
let mut cuts = vec![s];
for m in &marks {
if *m >= s && *m < e {
cuts.push(*m);
cuts.push((*m + 1).min(e));
}
}
cuts.push(e);
cuts.sort_unstable();
cuts.dedup();
for w in cuts.windows(2) {
let bg = if marks.contains(&w[0]) { BRACKET_BG } else { Color32::TRANSPARENT };
job.append(&text[w[0]..w[1]], 0.0, TextFormat { font_id: font.clone(), color: col, background: bg, ..Default::default() });
}
}
job.wrap.max_width = if p.word_wrap { wrap } else { f32::INFINITY };
job
}
/// The editor: returns the response; `buf` is edited in place.
pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: &Scripting, color: Color32, rows: usize, t: &Tokens) -> egui::Response {
let size = (p.editor_font_size.clamp(8, 40) as f32) * 0.9;
let font = Tokens::mono(size);
// Line numbers in a gutter on the left.
let gutter = if p.line_numbers { (size * 0.62) * (buf.lines().count().max(1).to_string().len() as f32 + 1.0) + 6.0 } else { 0.0 };
let er = Rect::from_min_max(pos2(rect.min.x + gutter, rect.min.y), rect.max);
let cursor = egui::TextEdit::load_state(ui.ctx(), id).and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0);
let brackets = cursor.and_then(|c| matching_bracket(buf, c));
let pc = p.clone();
let mut layouter = move |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| {
let job = layout(text.as_str(), font.clone(), color, &pc, brackets, wrap);
ui.fonts_mut(|f| f.layout_job(job))
};
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er));
// Auto-complete: Tab accepts the first candidate (consumed before the editor sees it).
let mut accepted = None;
if p.auto_complete
&& child.memory(|m| m.has_focus(id))
&& let Some(c) = cursor
{
let (word, cands) = completions(buf, c);
if let Some(first) = cands.first()
&& child.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab))
{
let at = byte_of(buf, c);
buf.insert_str(at, &first[word.len()..]);
accepted = Some(c + first.chars().count() - word.chars().count());
}
}
let out = egui::TextEdit::multiline(buf)
.id(id)
.font(Tokens::mono(size))
.desired_width(er.width())
.desired_rows(rows)
.frame(egui::Frame::NONE)
.lock_focus(true)
.layouter(&mut layouter)
.show(&mut child);
if let Some(c) = accepted {
let mut st = out.state.clone();
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(c))));
st.store(ui.ctx(), id);
}
let painter = ui.painter();
if p.line_numbers {
let g = out.galley.clone();
let mut line = 1;
let mut new_line = true;
for row in &g.rows {
if new_line {
let y = out.galley_pos.y + row.rect().center().y;
painter.text(pos2(rect.min.x + gutter - 6.0, y), egui::Align2::RIGHT_CENTER, line.to_string(), Tokens::mono(size * 0.85), t.text_faint);
line += 1;
}
new_line = row.ends_with_newline;
}
painter.line_segment([pos2(rect.min.x + gutter - 3.0, rect.min.y), pos2(rect.min.x + gutter - 3.0, rect.max.y)], Stroke::new(1.0, t.separator));
}
// Candidate list under the editor.
if p.auto_complete
&& out.response.has_focus()
&& let Some(c) = out.cursor_range.map(|r| r.primary.index.0)
{
let (_, cands) = completions(buf, c);
if !cands.is_empty() {
let pos = out.galley_pos + out.galley.pos_from_cursor(egui::text::CCursor::new(c)).left_bottom().to_vec2() + vec2(0.0, 2.0);
egui::Area::new(id.with("complete")).order(egui::Order::Tooltip).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
for (i, w) in cands.iter().enumerate() {
let txt = if i == 0 { format!("{w} Tab") } else { w.to_string() };
ui.label(egui::RichText::new(txt).font(Tokens::mono(size * 0.9)).color(if i == 0 { t.text } else { t.text_dim }));
}
});
});
}
}
out.response.response
}
/// Settings ▸ Scripting & Expressions ▸ Show Expression Error Banner: the first failing
/// expression of the comp at the current time, in a banner along the bottom of the viewer
/// (click it to reveal the property).
pub fn error_banner(app: &mut crate::EffectcraftApp, ui: &egui::Ui, area: Rect) {
if !app.session.prefs.scripting.error_banner {
return;
}
let Some(cid) = app.session.active_comp_id() else { return };
let key = (app.session.revision, cid.0, app.session.time().0);
let id = egui::Id::new("expr-error-banner");
let cached: Option<((u64, u64, i64), Vec<serde_json::Value>)> = ui.ctx().data(|d| d.get_temp(id));
let errors = match cached {
Some((k, e)) if k == key => e,
_ => {
let e = app.session.expression_errors(cid);
ui.ctx().data_mut(|d| d.insert_temp(id, (key, e.clone())));
e
}
};
let Some(first) = errors.first() else { return };
let layer = first["layer"].as_u64().unwrap_or(0);
let lname = app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(layer))).map(|l| l.name.clone()).unwrap_or_default();
let msg = format!(
"⚠ Expression error{}: {} ▸ {}: {}",
if errors.len() > 1 { format!(" (1 of {})", errors.len()) } else { String::new() },
lname,
first["name"].as_str().unwrap_or(""),
first["error"].as_str().unwrap_or("").lines().next().unwrap_or("")
);
let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 22.0), area.max);
let p = ui.painter();
p.rect_filled(r, 0.0, Color32::from_rgb(0xf0, 0xc0, 0x30));
p.text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, msg, Tokens::ui(11.5), Color32::BLACK);
app.auto.add("viewer.expressionErrorBanner", r, "Expression error banner");
if ui.interact(r, id.with("click"), egui::Sense::click()).clicked() {
let p = serde_json::json!({"props": [{"layer": layer, "prop": first["prop"]}]});
if let Err(e) = crate::menus::invoke(app, ui.ctx(), "timeline.revealProps", p) {
app.ui.status = e;
}
}
}

View File

@ -0,0 +1,150 @@
//! The Footage panel (double-click footage in the Project panel): the source on its own time
//! ruler, play / pause, In and Out marks, and the Edit Target buttons — Overlay Edit and Ripple
//! Insert Edit — that cut the marked range into the active composition at its current time.
//! Everything runs `footage.*` commands (state in `EditorState::footage_panel`).
use effectcraft_engine::project::{FootageKind, ItemKind};
use effectcraft_engine::time::Tick;
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use super::panel_kit as kit;
use crate::EffectcraftApp;
use crate::theme::Tokens;
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.pasteboard);
let Some(view) = app.session.state.footage_panel.clone() else {
p.text(rect.center(), Align2::CENTER_CENTER, "Double-click footage in the Project panel to open it here", Tokens::ui(12.0), t.text_faint);
app.auto.add("footage.empty", rect, "Footage panel (empty)");
return;
};
let Some(item) = app.session.project.item(view.item).cloned() else {
app.session.state.footage_panel = None;
return;
};
let footage = match &item.kind {
ItemKind::Footage(f) => Some(f.clone()),
_ => None,
};
let (dur, fr) = match &footage {
Some(f) if f.kind != FootageKind::Still && f.duration > Tick::ZERO => (f.duration, f.frame_rate),
Some(f) => (Tick::from_seconds_f64(10.0), f.frame_rate),
None => (Tick::from_seconds_f64(10.0), effectcraft_engine::time::FrameRate::FPS_30),
};
// Title strip.
let top = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
p.rect_filled(top, 0.0, t.panel_bg);
p.text(pos2(top.min.x + 10.0, top.center().y), Align2::LEFT_CENTER, format!("Footage: {}", item.name), Tokens::semibold(12.0), t.text);
app.auto.add("footage.title", top, &item.name);
// Bottom: ruler and controls.
// Rows, bottom up: Edit Target buttons, transport, ruler.
let edit_row = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 30.0), rect.max);
let bar = Rect::from_min_max(pos2(rect.min.x, edit_row.min.y - 28.0), pos2(rect.max.x, edit_row.min.y));
let ruler = Rect::from_min_max(pos2(rect.min.x + 10.0, bar.min.y - 26.0), pos2(rect.max.x - 10.0, bar.min.y - 4.0));
p.rect_filled(Rect::from_min_max(pos2(rect.min.x, ruler.min.y - 4.0), rect.max), 0.0, t.panel_bg);
// Canvas.
let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, ruler.min.y - 4.0));
if let Some(f) = &footage
&& f.has_video
{
let key = (view.item.0, view.time.0, app.session.revision);
let tid = egui::Id::new("footage-panel-tex");
let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid));
let tex = match cached {
Some((k, tex, size)) if k == key => Some((tex, size)),
_ => app.session.footage.frame(view.item, f, view.time).map(|img| {
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8());
let tex = ctx.load_texture("footage-panel", ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height])));
(tex, [img.width, img.height])
}),
};
if let Some((tex, [w, h])) = tex {
let s = ((area.width() - 20.0) / w as f32).min((area.height() - 20.0) / h as f32).max(0.01);
let r = Rect::from_center_size(area.center(), vec2(w as f32 * s, h as f32 * s));
super::viewer::checker(&p.with_clip_rect(area), r);
p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
} else if let ItemKind::Solid(so) = &item.kind {
let c = so.color;
p.rect_filled(area.shrink(20.0), 0.0, Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8));
} else {
p.text(area.center(), Align2::CENTER_CENTER, "No video", Tokens::ui(12.0), t.text_faint);
}
// Ruler: ticks, In/Out span, CTI; click or drag to scrub.
let x_of = |tt: Tick| ruler.min.x + (tt.seconds() / dur.seconds().max(1e-9)) as f32 * ruler.width();
p.rect_filled(ruler, 2.0, t.tl_ruler_bg);
let secs = dur.seconds().max(0.001);
let step = [0.04, 0.2, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0].into_iter().find(|s| (secs / s) as f32 * 50.0 < ruler.width()).unwrap_or(600.0);
let mut k = 0.0;
while k <= secs {
let x = ruler.min.x + (k / secs) as f32 * ruler.width();
p.line_segment([pos2(x, ruler.max.y - 6.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick));
p.text(pos2(x + 2.0, ruler.min.y + 1.0), Align2::LEFT_TOP, kit::seconds(k), Tokens::ui(9.0), t.tl_ruler_text);
k += step;
}
let (a, b) = (view.in_point.unwrap_or(Tick::ZERO), view.out_point.unwrap_or(dur));
p.rect_filled(Rect::from_min_max(pos2(x_of(a), ruler.max.y - 4.0), pos2(x_of(b), ruler.max.y)), 0.0, t.work_area);
let cx = x_of(view.time);
p.line_segment([pos2(cx, ruler.min.y), pos2(cx, ruler.max.y)], Stroke::new(1.5, t.cti));
let resp = ui.interact(ruler, egui::Id::new("footage-ruler"), Sense::click_and_drag());
app.auto.add("footage.ruler", ruler, "Time ruler");
if (resp.clicked() || resp.dragged())
&& let Some(pt) = resp.interact_pointer_pos()
{
let s = ((pt.x - ruler.min.x) / ruler.width()).clamp(0.0, 1.0) as f64 * dur.seconds();
kit::exec(app, "footage.setTime", json!({"time": s}));
}
// Playback.
let play_id = egui::Id::new("footage-playing");
let playing: bool = ctx.data(|d| d.get_temp(play_id).unwrap_or(false));
if playing {
let dt = ctx.input(|i| i.stable_dt).min(0.1) as f64;
let next = view.time.seconds() + dt;
let next = if next >= dur.seconds() - fr.frame_duration().seconds() { 0.0 } else { next };
let _ = app.session.execute("footage.setTime", json!({"time": next}));
ctx.request_repaint();
}
// Controls.
let y = bar.min.y + 5.0;
let mut x = bar.min.x + 10.0;
p.text(
pos2(x, bar.center().y),
Align2::LEFT_CENTER,
effectcraft_engine::time::format_timecode_ae(fr.frame_at(view.time), fr, false),
Tokens::mono(12.0),
t.timecode,
);
x += 92.0;
if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(54.0, 20.0)), "footage.play", if playing { "Pause" } else { "Play" }, false) {
ctx.data_mut(|d| d.insert_temp(play_id, !playing));
}
x += 60.0;
if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(28.0, 20.0)), "footage.setIn", "{", false) {
kit::exec(app, "footage.setIn", json!({}));
}
x += 32.0;
if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(28.0, 20.0)), "footage.setOut", "}", false) {
kit::exec(app, "footage.setOut", json!({}));
}
x += 36.0;
let span = format!("Δ {}", kit::seconds((b - a).seconds()));
p.text(pos2(x, bar.center().y), Align2::LEFT_CENTER, span, Tokens::ui(11.0), t.text_dim);
let comp_ok = app.session.active_comp_id().is_some();
let target = app.session.active_comp_id().and_then(|c| app.session.project.item(c)).map(|i| i.name.clone()).unwrap_or_default();
let ey = edit_row.min.y + 5.0;
let bx = (edit_row.max.x - 238.0).max(edit_row.min.x + 10.0);
if !target.is_empty() && bx > edit_row.min.x + 140.0 {
p.text(pos2(bx - 8.0, edit_row.center().y), Align2::RIGHT_CENTER, format!("Edit Target: {target}"), Tokens::ui(11.0), t.text_dim);
}
if kit::button(app, ui, Rect::from_min_size(pos2(bx, ey), vec2(110.0, 20.0)), "footage.rippleInsertEdit", "Ripple Insert Edit", false) && comp_ok {
kit::exec(app, "footage.rippleInsertEdit", json!({}));
}
if kit::button(app, ui, Rect::from_min_size(pos2(bx + 116.0, ey), vec2(110.0, 20.0)), "footage.overlayEdit", "Overlay Edit", true) && comp_ok {
kit::exec(app, "footage.overlayEdit", json!({}));
}
}

View File

@ -397,6 +397,60 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
],
)
}
"layer.autoTrace" if !has(p, &["channel", "timeSpan", "threshold"]) => (
"Auto-trace".into(),
vec![
Field::choice("timeSpan", "Time Span", &[("Current Frame", json!("currentFrame")), ("Work Area", json!("workArea"))], 0),
Field::choice(
"channel",
"Channel",
&[("Alpha", json!("alpha")), ("Red", json!("red")), ("Green", json!("green")), ("Blue", json!("blue")), ("Luminance", json!("luminance"))],
0,
),
Field::bool("invert", "Invert", false),
Field::num("blur", "Blur (pixels before auto-trace)", 1.0),
Field::num("tolerance", "Tolerance (pixels)", 1.0),
Field::num("threshold", "Threshold (%)", 50.0),
Field::num("minimumArea", "Minimum Area (pixels)", 10.0),
Field::num("cornerRoundness", "Corner Roundness (%)", 50.0),
Field::bool("applyToNewLayer", "Apply To New Layer", false),
],
),
"layer.sceneEditDetection" if !has(p, &["mode"]) => (
"Scene Edit Detection".into(),
vec![
Field::choice(
"mode",
"Action",
&[("Create Markers", json!("markers")), ("Split Layers", json!("split")), ("Split and Precompose", json!("splitPrecompose"))],
0,
),
Field::num("threshold", "Sensitivity threshold (0–1, lower finds more)", 0.25),
],
),
"layer.alignVideoToData" if !has(p, &["data", "videoStart"]) => {
let data: Vec<(String, Value)> = s
.project
.items
.values()
.filter(|i| matches!(&i.kind, effectcraft_engine::project::ItemKind::Footage(f) if f.kind == effectcraft_engine::project::FootageKind::Data))
.map(|i| (i.name.clone(), json!(i.id.0)))
.collect();
if data.is_empty() {
info(app, "Align Video to Data", "Import a data file (JSON, CSV or TSV with a time column) first: File ▸ Import.");
return true;
}
let opts: Vec<(&str, Value)> = data.iter().map(|(n, v)| (n.as_str(), v.clone())).collect();
(
"Align Video to Data".into(),
vec![
Field::choice("data", "Data", &opts, 0),
Field::text("key", "Time field (empty: automatic)", ""),
Field::text("videoStart", "Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", ""),
Field::num("dataStart", "First sample at (comp seconds)", 0.0),
],
)
}
"file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
"New Placeholder".into(),
vec![

View File

@ -0,0 +1,368 @@
//! Media Browser, Metadata and Progress panels (Window menu).
//!
//! - **Media Browser**: favourites and the folder tree on the left, the folder's files on the
//! right with thumbnails (images and video, decoded through the media layer's probe), double-
//! click to open a folder or import a file, drag files into the Project panel or the timeline.
//! Desktop only (the web build has no file system to browse).
//! - **Metadata**: what we read from the selected item's file (codec, size, rate, duration,
//! colour profile, dates) and the project's, with editable item and project comments.
//! - **Progress**: every background job (render queue, track / mask track / Warp Stabilizer /
//! 3D Camera Tracker / Roto Brush analyses, Content-Aware Fill, Scene Edit Detection) with a
//! progress bar and a cancel button, and the recently finished ones.
use effectcraft_engine::media_browser as mb;
use effectcraft_engine::project::ItemId;
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::{Value, json};
use super::DragPayload;
use super::panel_kit as kit;
use crate::EffectcraftApp;
use crate::theme::Tokens;
// ---------------------------------------------------------------- Media Browser
type Thumb = Option<(egui::TextureHandle, [u32; 2])>;
/// Thumbnail of a file (cached per path; `None` while not decoded or not decodable).
fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb {
let id = egui::Id::new(("mb-thumb", path));
if let Some(t) = ctx.data(|d| d.get_temp::<Thumb>(id)) {
return t;
}
if *budget == 0 {
return None;
}
*budget -= 1;
let thumb = (|| {
let imp = app.session.importer.clone()?;
let f = imp.probe(path).ok()?;
if !f.has_video {
return None;
}
// A synthetic item id keyed by the path (the frame cache keys on the path).
let hid = path.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3)) | (1 << 62);
let img = app.session.footage.frame(ItemId(hid), &f, effectcraft_engine::time::Tick::ZERO)?;
let step = (img.width.max(img.height) as f32 / 128.0).ceil().max(1.0) as u32;
let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step));
let full = img.to_rgba8();
let mut px = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let i = (((y * step) * img.width + x * step) * 4) as usize;
px.extend_from_slice(&full[i..i + 4]);
}
}
let tex = ctx.load_texture(format!("mb-{path}"), egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &px), egui::TextureOptions::LINEAR);
Some((tex, [w, h]))
})();
ctx.data_mut(|d| d.insert_temp(id, thumb.clone()));
thumb
}
pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.panel_bg);
if !mb::available() {
p.text(rect.center(), Align2::CENTER_CENTER, "The Media Browser needs the desktop app (use File ▸ Import here)", Tokens::ui(12.0), t.text_faint);
app.auto.add("mediaBrowser.unavailable", rect, "Media Browser unavailable");
return;
}
let st = app.session.state.media_browser.clone();
let dir = st.folder.clone().unwrap_or_else(mb::home);
// Listing cached per folder for a second.
let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only));
let now = ctx.input(|i| i.time);
let listing: Option<(f64, Result<Vec<mb::Entry>, String>)> = ctx.data(|d| d.get_temp(lid));
let entries = match listing {
Some((at, l)) if now - at < 1.0 => l,
_ => {
let l = mb::list(&dir, st.importable_only);
ctx.data_mut(|d| d.insert_temp(lid, (now, l.clone())));
l
}
};
// Toolbar: up, home, path, favourite, importable only.
let y = rect.min.y + 6.0;
let mut x = rect.min.x + 8.0;
if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(30.0, 20.0)), "mediaBrowser.up", "↑", false) {
kit::exec(app, "mediaBrowser.go", json!({"path": ".."}));
}
x += 34.0;
if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(48.0, 20.0)), "mediaBrowser.home", "Home", false) {
kit::exec(app, "mediaBrowser.go", json!({}));
}
x += 54.0;
let fav = st.favorites.contains(&dir);
if kit::button(app, ui, Rect::from_min_size(pos2(x, y), vec2(24.0, 20.0)), "mediaBrowser.favorite", if fav { "★" } else { "☆" }, false) {
kit::exec(app, if fav { "mediaBrowser.removeFavorite" } else { "mediaBrowser.addFavorite" }, json!({"path": dir}));
}
x += 30.0;
let only = kit::checkbox(app, ui, pos2(x, y + 2.0), "mediaBrowser.importableOnly", "Media only", st.importable_only);
if only != st.importable_only {
kit::exec(app, "mediaBrowser.go", json!({"path": dir, "importableOnly": only}));
}
// The path on its own row.
let _ = x;
let py = y + 24.0;
p.text(pos2(rect.min.x + 10.0, py + 9.0), Align2::LEFT_CENTER, &dir, Tokens::ui(11.5), t.text_dim);
app.auto.add("mediaBrowser.path", Rect::from_min_max(pos2(rect.min.x + 8.0, py), pos2(rect.max.x - 8.0, py + 18.0)), &dir);
// Left column: favourites.
let side = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 54.0), pos2(rect.min.x + 160.0_f32.min(rect.width() * 0.3), rect.max.y));
p.line_segment([side.right_top(), side.right_bottom()], Stroke::new(1.0, t.separator));
p.text(pos2(side.min.x + 10.0, side.min.y + 10.0), Align2::LEFT_CENTER, "Favorites", Tokens::semibold(11.5), t.text_dim);
let mut fy = side.min.y + 22.0;
let mut places: Vec<(String, String)> = vec![("Home".into(), mb::home())];
places.extend(
st.favorites.iter().map(|f| (std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()), f.clone())),
);
for (i, (name, path)) in places.iter().enumerate() {
let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
let resp = ui.interact(r, egui::Id::new(("mb-fav", i)), Sense::click());
if *path == dir {
p.rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
p.rect_filled(r, 0.0, t.hover);
}
p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
app.auto.add(&format!("mediaBrowser.place.{i}"), r, name);
if resp.clicked() {
kit::exec(app, "mediaBrowser.go", json!({"path": path}));
}
fy += 20.0;
}
// Files.
let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max);
let entries = match entries {
Ok(e) => e,
Err(e) => {
p.text(list.center(), Align2::CENTER_CENTER, e, Tokens::ui(12.0), t.danger);
return;
}
};
let tile = vec2(140.0, 112.0);
let cols = ((list.width() - 8.0) / tile.x).floor().max(1.0) as usize;
let mut budget = 2u32;
let scroll_id = egui::Id::new("mb-scroll");
let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
if ui.rect_contains_pointer(list) {
scroll -= ctx.input(|i| i.smooth_scroll_delta.y);
}
let rows = entries.len().div_ceil(cols);
scroll = scroll.clamp(0.0, (rows as f32 * tile.y - list.height() + 8.0).max(0.0));
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
let lp = p.with_clip_rect(list);
let mut actions: Vec<(&str, Value)> = vec![];
for (i, e) in entries.iter().enumerate() {
let r = Rect::from_min_size(
pos2(list.min.x + 8.0 + (i % cols) as f32 * tile.x, list.min.y + 8.0 + (i / cols) as f32 * tile.y - scroll),
tile - vec2(8.0, 8.0),
);
if !r.intersects(list) {
continue;
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("mb-entry", &e.path)), Sense::click_and_drag());
app.auto.add(&format!("mediaBrowser.entry.{}", e.name), r, &e.name);
lp.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.row });
let thumb = Rect::from_min_max(r.min + vec2(6.0, 6.0), pos2(r.max.x - 6.0, r.max.y - 26.0));
lp.rect_filled(thumb, 3.0, Color32::from_rgb(0x18, 0x18, 0x18));
let kind = if e.is_dir { "Folder" } else { e.kind.unwrap_or("File") };
match (e.is_dir, e.kind) {
(false, Some("image" | "video")) => match thumbnail(app, &ctx, &e.path, &mut budget) {
Some((tex, [w, h])) => {
let s = (thumb.width() / w as f32).min(thumb.height() / h as f32);
lp.image(
tex.id(),
Rect::from_center_size(thumb.center(), vec2(w as f32 * s, h as f32 * s)),
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
Color32::WHITE,
);
}
None => {
lp.text(thumb.center(), Align2::CENTER_CENTER, kind, Tokens::ui(11.0), t.text_faint);
}
},
_ => {
lp.text(thumb.center(), Align2::CENTER_CENTER, if e.is_dir { "▣" } else { kind }, Tokens::ui(if e.is_dir { 28.0 } else { 11.0 }), t.text_faint);
}
}
let name = if e.name.chars().count() > 20 { format!("{}…", e.name.chars().take(19).collect::<String>()) } else { e.name.clone() };
lp.text(pos2(r.min.x + 8.0, r.max.y - 12.0), Align2::LEFT_CENTER, name, Tokens::ui(11.5), t.text);
if resp.double_clicked() {
if e.is_dir {
actions.push(("mediaBrowser.go", json!({"path": e.path})));
} else if e.kind.is_some() {
actions.push(("mediaBrowser.import", json!({"paths": [e.path]})));
}
}
if resp.drag_started() && !e.is_dir && e.kind.is_some() {
egui::DragAndDrop::set_payload(&ctx, DragPayload::Files(vec![e.path.clone()]));
}
resp.context_menu(|ui| {
if !e.is_dir && ui.button("Import").clicked() {
actions.push(("mediaBrowser.import", json!({"paths": [e.path]})));
ui.close();
}
if !e.is_dir && ui.button("Import and Add to Composition").clicked() {
actions.push(("mediaBrowser.import", json!({"paths": [e.path], "addToComp": true})));
ui.close();
}
if e.is_dir && ui.button("Add to Favorites").clicked() {
actions.push(("mediaBrowser.addFavorite", json!({"path": e.path})));
ui.close();
}
});
}
if entries.is_empty() {
lp.text(list.center(), Align2::CENTER_CENTER, "Empty folder", Tokens::ui(12.0), t.text_faint);
}
if budget == 0 {
ctx.request_repaint();
}
for (id, p) in actions {
kit::exec(app, id, p);
}
}
// ---------------------------------------------------------------- Metadata
fn rows_of(v: &Value) -> Vec<(String, String)> {
let Some(o) = v.as_object() else { return vec![] };
o.iter()
.filter(|(k, v)| !v.is_null() && !matches!(k.as_str(), "comment" | "id"))
.map(|(k, v)| {
let s = match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.as_f64().map(|f| if f.fract() == 0.0 { format!("{f:.0}") } else { format!("{f:.3}") }).unwrap_or_default(),
other => other.to_string(),
};
(k.clone(), s)
})
.collect()
}
/// An editable comment field; returns the edited text when it changes.
fn comment_field(ui: &mut egui::Ui, r: Rect, id: &str, text: &str) -> Option<String> {
let key = egui::Id::new(("meta-comment", id));
let mut buf: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| text.to_string());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r));
let resp = child.add(egui::TextEdit::multiline(&mut buf).desired_width(r.width()).desired_rows(2).hint_text("Add a comment"));
if resp.has_focus() || resp.changed() {
ui.data_mut(|d| d.insert_temp(key, buf.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(key));
}
(resp.lost_focus() && buf != text).then_some(buf)
}
pub fn metadata(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.panel_bg);
let Ok(v) = app.session.execute("item.metadata", json!({})) else { return };
let mut y = rect.min.y + 8.0;
let x0 = rect.min.x + 12.0;
let xv = x0 + 120.0;
let section = |p: &egui::Painter, y: &mut f32, title: &str| {
p.text(pos2(x0, *y + 8.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text);
*y += 22.0;
};
if let Some(item) = v.get("item").filter(|i| !i.is_null()) {
let name = item["name"].as_str().unwrap_or_default().to_string();
let id = item["id"].as_u64().unwrap_or(0);
section(&p, &mut y, &format!("{name} ({})", item["type"].as_str().unwrap_or("")));
kit::label(ui, pos2(x0, y), "Comment", &t);
let r = Rect::from_min_size(pos2(xv, y), vec2((rect.max.x - xv - 12.0).max(80.0), 40.0));
app.auto.add("metadata.itemComment", r, item["comment"].as_str().unwrap_or(""));
if let Some(c) = comment_field(ui, r, &format!("item{id}"), item["comment"].as_str().unwrap_or("")) {
kit::exec(app, "project.setComment", json!({"items": [id], "comment": c}));
}
y += 48.0;
for (k, val) in rows_of(item) {
if matches!(k.as_str(), "name" | "type") {
continue;
}
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, &k, Tokens::ui(11.5), t.text_dim);
p.text(pos2(xv, y + 8.0), Align2::LEFT_CENTER, &val, Tokens::ui(11.5), t.text);
app.auto.add(&format!("metadata.item.{k}"), Rect::from_min_size(pos2(xv, y), vec2(200.0, 16.0)), &val);
y += 18.0;
}
y += 10.0;
} else {
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Select an item in the Project panel", Tokens::ui(12.0), t.text_faint);
y += 28.0;
}
let proj = v["project"].clone();
section(&p, &mut y, "Project");
kit::label(ui, pos2(x0, y), "Comment", &t);
let r = Rect::from_min_size(pos2(xv, y), vec2((rect.max.x - xv - 12.0).max(80.0), 40.0));
app.auto.add("metadata.projectComment", r, proj["comment"].as_str().unwrap_or(""));
if let Some(c) = comment_field(ui, r, "project", proj["comment"].as_str().unwrap_or("")) {
kit::exec(app, "project.setProjectComment", json!({"comment": c}));
}
y += 48.0;
for (k, val) in rows_of(&proj) {
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, &k, Tokens::ui(11.5), t.text_dim);
p.text(pos2(xv, y + 8.0), Align2::LEFT_CENTER, &val, Tokens::ui(11.5), t.text);
y += 18.0;
}
}
// ---------------------------------------------------------------- Progress
pub fn progress(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.panel_bg);
if !app.session.tasks.is_empty() {
app.session.poll_jobs();
}
let jobs = app.session.jobs();
let mut y = rect.min.y + 8.0;
let x0 = rect.min.x + 12.0;
let w = (rect.width() - 24.0).max(60.0);
if jobs.is_empty() {
p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, "No background jobs running", Tokens::ui(12.0), t.text_faint);
y += 28.0;
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(150));
}
for j in &jobs {
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, &j.label, Tokens::medium(12.0), t.text);
let frac = j.fraction.unwrap_or(0.0) as f32;
let pct = j.fraction.map(|f| format!("{:.0} %", f * 100.0)).unwrap_or_else(|| "…".into());
p.text(pos2(x0 + w - 70.0, y + 8.0), Align2::RIGHT_CENTER, format!("{pct} {}", j.message), Tokens::ui(11.0), t.text_dim);
let bar = Rect::from_min_size(pos2(x0, y + 20.0), vec2(w - 76.0, 6.0));
p.rect_filled(bar, 3.0, t.field_bg);
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * frac.clamp(0.0, 1.0), bar.height())), 3.0, t.accent);
app.auto.add(&format!("progress.job.{}", j.id), Rect::from_min_size(pos2(x0, y), vec2(w, 28.0)), &j.label);
if j.cancellable
&& kit::button(app, ui, Rect::from_min_size(pos2(x0 + w - 64.0, y + 12.0), vec2(64.0, 18.0)), &format!("progress.cancel.{}", j.id), "Cancel", false)
{
kit::exec(app, "jobs.cancel", json!({"job": j.id}));
}
y += 38.0;
}
if !app.session.job_log.is_empty() {
p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator));
y += 6.0;
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Finished", Tokens::semibold(11.5), t.text_dim);
y += 20.0;
for r in app.session.job_log.iter().rev().take(20) {
let col = match r.status.as_str() {
"done" => t.text,
"cancelled" => t.text_dim,
_ => t.danger,
};
let msg = if r.message.is_empty() { format!("{} · {:.1} s", r.status, r.seconds) } else { format!("{} · {}", r.status, r.message) };
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, &r.label, Tokens::ui(11.5), col);
p.text(pos2(x0 + w, y + 8.0), Align2::RIGHT_CENTER, msg, Tokens::ui(11.0), t.text_faint);
y += 18.0;
if y > rect.max.y {
break;
}
}
}
}

View File

@ -3,13 +3,16 @@
pub mod anim_tools;
pub mod camera_tracker_ui;
pub mod comp_settings;
pub mod content_fill_panel;
pub mod dialogs;
pub mod dialogs_3d;
pub mod effect_controls;
pub mod effects_presets;
pub mod essential;
pub mod expr_bar;
pub mod expr_editor;
pub mod flowchart;
pub mod footage_panel;
pub mod forms;
pub mod fx_widgets;
pub mod graph;
@ -20,14 +23,17 @@ pub mod key_dialogs;
pub mod layer_panel;
pub mod layer_styles_dialog;
pub mod markers_ui;
pub mod media_panels;
pub mod misc;
pub mod paint_panels;
pub mod panel_kit;
pub mod precomp;
pub mod project;
pub mod properties;
pub mod puppet_tool;
pub mod render_queue;
pub mod roto_tool;
pub mod scopes_panel;
pub mod script_console;
pub mod settings;
pub mod shortcut_editor;
@ -57,6 +63,8 @@ pub enum DragPayload {
Effect(String),
/// A property dragged from the timeline (to the Essential Graphics panel).
Property { layer: u64, prop: u64 },
/// Files from the Media Browser (import; into the timeline: import and add).
Files(Vec<String>),
}
/// The current time formatted per project settings (timecode with `;` for drop-frame, frames
@ -99,6 +107,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::Brushes => paint_panels::brushes(app, ui, rect),
PanelKind::EssentialGraphics => essential::show(app, ui, rect),
PanelKind::Flowchart => flowchart::show(app, ui, rect),
PanelKind::LumetriScopes => scopes_panel::show(app, ui, rect),
PanelKind::Footage => footage_panel::show(app, ui, rect),
PanelKind::MediaBrowser => media_panels::media_browser(app, ui, rect),
PanelKind::Metadata => media_panels::metadata(app, ui, rect),
PanelKind::Progress => media_panels::progress(app, ui, rect),
PanelKind::ContentAwareFill => content_fill_panel::show(app, ui, rect),
}
}

View File

@ -0,0 +1,70 @@
//! Small immediate-mode controls shared by the Lumetri Scopes, Footage, Media Browser, Metadata,
//! Progress and Content-Aware Fill panels. Each registers its automation id.
use egui::{Align2, Rect, pos2, vec2};
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// A dropdown; returns the newly chosen index.
pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, options: &[&str], cur: usize) -> Option<usize> {
let t = app.tokens;
let id = egui::Id::new(("kit-dd", auto));
let text = options.get(cur).copied().unwrap_or("");
if widgets::dropdown(ui, r, text, &t, id).clicked() {
widgets::open_popup(ui, id);
}
app.auto.add(auto, r, text);
let opts: Vec<String> = options.iter().map(|s| s.to_string()).collect();
widgets::popup_menu(ui, id, r.left_bottom(), &opts, Some(cur)).filter(|i| *i != cur)
}
/// A checkbox with a label; returns the new state.
pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).clicked();
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0));
app.auto.add(auto, full, text);
if clicked { !on } else { on }
}
/// A text button; true when clicked.
pub fn button(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, text: &str, primary: bool) -> bool {
let t = app.tokens;
let clicked = widgets::text_button(ui, r, text, primary, &t, egui::Id::new(("kit-btn", auto))).clicked();
app.auto.add(auto, r, text);
clicked
}
/// A dimmed label at (x, y-row).
pub fn label(ui: &egui::Ui, at: egui::Pos2, text: &str, t: &Tokens) {
ui.painter().text(pos2(at.x, at.y + 9.0), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text_dim);
}
/// A draggable number; returns the new value.
#[allow(clippy::too_many_arguments)]
pub fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
let t = app.tokens;
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::new(("kit-n", auto)), v, speed, range, dec, suffix, &t);
app.auto.add(auto, r, &format!("{v}"));
nv.unwrap_or(v)
}
/// Run a command, reporting errors in the status bar.
pub fn exec(app: &mut EffectcraftApp, id: &str, p: serde_json::Value) -> Option<serde_json::Value> {
match app.session.execute(id, p) {
Ok(v) => Some(v),
Err(e) => {
app.ui.status = e.to_string();
None
}
}
}
/// Format seconds as `m:ss.ff`.
pub fn seconds(s: f64) -> String {
let m = (s / 60.0).floor();
format!("{}:{:05.2}", m as i64, s - m * 60.0)
}

View File

@ -481,6 +481,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
app.ui.project_open_folders.insert(id.0);
}
// Footage (not data) and solids open in the Footage panel.
ItemKind::Footage(f) if f.kind != effectcraft_engine::project::FootageKind::Data => {
actions.push(("footage.open".into(), json!({"item": id.0})))
}
ItemKind::Solid(_) => actions.push(("footage.open".into(), json!({"item": id.0}))),
_ => {}
}
}
@ -539,6 +544,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
egui::DragAndDrop::clear_payload(&ctx);
}
}
// Files dragged from the Media Browser: import.
if let Some(DragPayload::Files(paths)) = egui::DragAndDrop::payload::<DragPayload>(&ctx).as_deref()
&& hover_y.is_some()
{
lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside);
if ctx.input(|i| i.pointer.any_released()) {
actions.push(("mediaBrowser.import".into(), json!({"paths": paths})));
egui::DragAndDrop::clear_payload(&ctx);
}
}
// Drag ghost.
if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::<DragPayload>(&ctx).as_deref()
&& let Some(pos) = ctx.pointer_hover_pos()

View File

@ -0,0 +1,199 @@
//! Lumetri Scopes panel (Window ▸ Lumetri Scopes): waveform (RGB / Luma / YC), vectorscope
//! (YUV / HLS), histogram and parade (RGB / YUV) of the active composition's frame at the current
//! time, with the colour standard (Rec. 601 / 709 / 2020), 8-bit or float scale and clamp. The
//! maths is `effectcraft_raster::scopes`; agents read the same numbers with `scopes.analyze`.
use effectcraft_engine::commands::panels_cmds::scope_frame;
use effectcraft_engine::raster::scopes::{self, ColorStandard, Scope, ScopeKind, ScopeOpts};
use egui::{Align2, Color32, Rect, Stroke, pos2, vec2};
use serde::{Deserialize, Serialize};
use super::panel_kit as kit;
use crate::EffectcraftApp;
use crate::theme::Tokens;
/// Panel options (serde in the UI state).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ScopesState {
pub scope: String,
pub standard: String,
pub float: bool,
pub clamp: bool,
}
impl Default for ScopesState {
fn default() -> Self {
ScopesState { scope: "waveformRgb".into(), standard: "rec709".into(), float: false, clamp: true }
}
}
const STANDARDS: [&str; 3] = ["Rec. 601", "Rec. 709", "Rec. 2020"];
#[derive(Clone)]
struct Cached {
key: (u64, u64, i64, String, u32),
scope: Scope,
tex: Option<egui::TextureHandle>,
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.panel_bg);
let mut st = app.ui.scopes.clone();
let kind = ScopeKind::from_name(&st.scope).unwrap_or(ScopeKind::WaveformRgb);
let standard = ColorStandard::from_name(&st.standard).unwrap_or_default();
// Top bar: scope, standard, 8-bit / float, clamp.
let mut x = rect.min.x + 8.0;
let y = rect.min.y + 6.0;
let labels: Vec<&str> = ScopeKind::ALL.iter().map(|k| k.label()).collect();
let cur = ScopeKind::ALL.iter().position(|k| *k == kind).unwrap_or(0);
if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(x, y), vec2(150.0, 20.0)), "scopes.kind", &labels, cur) {
st.scope = ScopeKind::ALL[i].name().into();
}
x += 158.0;
let si = ColorStandard::ALL.iter().position(|c| *c == standard).unwrap_or(1);
if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(x, y), vec2(90.0, 20.0)), "scopes.standard", &STANDARDS, si) {
st.standard = ColorStandard::ALL[i].name().into();
}
x += 98.0;
if let Some(i) = kit::dropdown(app, ui, Rect::from_min_size(pos2(x, y), vec2(70.0, 20.0)), "scopes.scale", &["8 Bit", "Float"], usize::from(st.float)) {
st.float = i == 1;
}
x += 78.0;
// Narrow panels: Clamp Signal on a second row.
let (cx, cy, top) = if x + 110.0 > rect.max.x { (rect.min.x + 8.0, y + 26.0, 60.0) } else { (x, y, 34.0) };
st.clamp = kit::checkbox(app, ui, pos2(cx, cy + 2.0), "scopes.clamp", "Clamp Signal", st.clamp);
if st != app.ui.scopes {
app.ui.scopes = st.clone();
}
let area = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.min.y + top), pos2(rect.max.x - 8.0, rect.max.y - 8.0));
if area.width() < 40.0 || area.height() < 40.0 {
return;
}
p.rect_filled(area, 2.0, Color32::from_rgb(0x10, 0x10, 0x10));
app.auto.add("scopes.plot", area, kind.label());
let Some(cid) = app.session.active_comp_id() else {
p.text(area.center(), Align2::CENTER_CENTER, "Open a composition to see its scopes", Tokens::ui(12.0), t.text_faint);
return;
};
// Plot rectangle: square for vectorscopes.
let plot = if kind.is_vectorscope() {
let s = area.width().min(area.height()) - 16.0;
Rect::from_center_size(area.center(), vec2(s, s))
} else {
Rect::from_min_max(area.min + vec2(34.0, 8.0), area.max - vec2(8.0, 8.0))
};
let size = (plot.width().max(plot.height()) as u32).clamp(64, 512);
let o = ScopeOpts {
standard,
float: st.float,
clamp: st.clamp,
width: if kind.is_vectorscope() { size } else { (plot.width() as u32).clamp(64, 768) },
height: if kind.is_vectorscope() { size } else { (plot.height() as u32).clamp(32, 512) },
};
let time = app.session.time();
let key = (app.session.revision, cid.0, time.0, format!("{}{}{}{}{}x{}", st.scope, st.standard, st.float, st.clamp, o.width, o.height), 0);
let ctx = ui.ctx().clone();
let id = egui::Id::new("scopes-cache");
let cached: Option<Cached> = ctx.data(|d| d.get_temp(id));
let cached = match cached {
Some(c) if c.key == key => c,
_ => {
let Some(frame) = scope_frame(&app.session, cid, time, 480) else { return };
let scope = scopes::compute(&frame, kind, &o);
let tex = (kind != ScopeKind::Histogram).then(|| {
let mono = matches!(kind, ScopeKind::WaveformLuma | ScopeKind::VectorscopeYuv | ScopeKind::VectorscopeHls);
let img = egui::ColorImage::from_rgba_unmultiplied([scope.width as usize, scope.height as usize], &tint(&scope, mono));
ctx.load_texture("lumetri-scope", img, egui::TextureOptions::LINEAR)
});
let c = Cached { key, scope, tex };
ctx.data_mut(|d| d.insert_temp(id, c.clone()));
c
}
};
graticule(&p, plot, kind, &o, &t);
if let Some(tex) = &cached.tex {
p.image(tex.id(), plot, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
} else {
histogram(&p, plot, &cached.scope);
}
}
/// Trace colours: green-tinted mono for luma/vectorscope, per-channel colours otherwise.
fn tint(s: &Scope, mono: bool) -> Vec<u8> {
let n = s.width as usize * s.height as usize;
let mut out = vec![0u8; n * 4];
for i in 0..n {
let (r, g, b) = (s.planes[0][i], s.planes[1][i], s.planes[2][i]);
let (r, g, b) = if mono {
let m = r.max(g).max(b);
(m * 0.75, m, m * 0.75)
} else {
(r, g, b)
};
let a = r.max(g).max(b).min(1.0);
out[i * 4] = (r.min(1.0) * 255.0) as u8;
out[i * 4 + 1] = (g.min(1.0) * 255.0) as u8;
out[i * 4 + 2] = (b.min(1.0) * 255.0) as u8;
out[i * 4 + 3] = (a * 255.0) as u8;
}
out
}
fn graticule(p: &egui::Painter, plot: Rect, kind: ScopeKind, o: &ScopeOpts, t: &Tokens) {
let line = Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 28));
if kind.is_vectorscope() {
let c = plot.center();
for r in [0.25f32, 0.5, 0.75, 1.0] {
p.circle_stroke(c, plot.width() / 2.0 * r, line);
}
p.line_segment([pos2(plot.min.x, c.y), pos2(plot.max.x, c.y)], line);
p.line_segment([pos2(c.x, plot.min.y), pos2(c.x, plot.max.y)], line);
for (name, [u, v]) in scopes::vector_targets(o.standard, kind == ScopeKind::VectorscopeHls) {
let q = pos2(plot.min.x + (u + 0.5) * plot.width(), plot.min.y + (0.5 - v) * plot.height());
p.rect_stroke(Rect::from_center_size(q, vec2(8.0, 8.0)), 0.0, Stroke::new(1.0, Color32::from_rgb(0xc8, 0x6a, 0x6a)), egui::StrokeKind::Middle);
p.text(q + vec2(7.0, -7.0), Align2::LEFT_BOTTOM, name, Tokens::ui(10.0), t.text_dim);
}
return;
}
if kind == ScopeKind::Histogram {
return;
}
let (lo, hi) = o.range();
let marks: &[f32] = if o.float { &[-0.25, 0.0, 0.25, 0.5, 0.75, 1.0, 1.25] } else { &[0.0, 0.25, 0.5, 0.75, 1.0] };
for v in marks {
let y = plot.max.y - (v - lo) / (hi - lo) * plot.height();
p.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], line);
let label = if o.float { format!("{v:.2}") } else { format!("{}", (v * 255.0).round() as i32) };
p.text(pos2(plot.min.x - 4.0, y), Align2::RIGHT_CENTER, label, Tokens::ui(9.5), t.text_faint);
}
if matches!(kind, ScopeKind::ParadeRgb | ScopeKind::ParadeYuv) {
for k in 1..3 {
let x = plot.min.x + plot.width() * k as f32 / 3.0;
p.line_segment([pos2(x, plot.min.y), pos2(x, plot.max.y)], line);
}
}
}
fn histogram(p: &egui::Painter, plot: Rect, s: &Scope) {
let bars = scopes::histogram_bars(s);
let max = bars.iter().flat_map(|b| b.iter()).copied().max().unwrap_or(1).max(1) as f32;
let cols = [
Color32::from_rgba_unmultiplied(230, 70, 70, 150),
Color32::from_rgba_unmultiplied(70, 220, 90, 150),
Color32::from_rgba_unmultiplied(80, 120, 255, 150),
];
let n = bars[0].len().max(1) as f32;
for (c, b) in bars.iter().enumerate() {
let pts: Vec<egui::Pos2> =
b.iter().enumerate().map(|(i, v)| pos2(plot.min.x + i as f32 / n * plot.width(), plot.max.y - (*v as f32 / max).sqrt() * plot.height())).collect();
for (i, q) in pts.iter().enumerate() {
p.line_segment([pos2(q.x, plot.max.y), *q], Stroke::new((plot.width() / n).max(1.0), cols[c].gamma_multiply(0.5)));
if i > 0 {
p.line_segment([pts[i - 1], *q], Stroke::new(1.0, cols[c]));
}
}
}
}

View File

@ -98,9 +98,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
widgets::open_popup(ui, pop);
}
app.auto.add("character.font", fr, "Font family");
let fams: Vec<String> = effectcraft_engine::text_families();
if let Some(i) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)) {
actions.push(json!({"font": fams[i]}));
if let Some(f) = font_popup(app, ui, pop, fr.left_bottom(), &doc.font) {
actions.push(json!({"font": f}));
}
y += 28.0;
let sr = Rect::from_min_size(pos2(x0, y), vec2(w - 84.0, 22.0));
@ -469,20 +468,29 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
y += 3.0 * 28.0 + 6.0;
// Direction and composer popups.
let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0));
let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()];
let dpop = egui::Id::new("para-direction-pop");
// Direction and composer popups. Settings ▸ Type ▸ Text Engine: the South Asian and Middle
// Eastern engine offers the paragraph direction and the World-Ready composers (the Latin
// engine shows the direction only for right-to-left text already set).
let world_ready = app.session.prefs.type_.text_engine == "southAsian";
let rtl = doc.direction == Direction::Rtl;
if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled {
widgets::open_popup(ui, dpop);
}
app.auto.add("paragraph.direction", dr, "Text direction");
if let Some(i) = widgets::popup_menu(ui, dpop, dr.left_bottom(), &dirs, Some(rtl as usize)) {
actions.push(json!({"direction": if i == 1 { "rtl" } else { "ltr" }}));
if world_ready || rtl {
let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0));
let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()];
let dpop = egui::Id::new("para-direction-pop");
if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled {
widgets::open_popup(ui, dpop);
}
app.auto.add("paragraph.direction", dr, "Text direction");
if let Some(i) = widgets::popup_menu(ui, dpop, dr.left_bottom(), &dirs, Some(rtl as usize)) {
actions.push(json!({"direction": if i == 1 { "rtl" } else { "ltr" }}));
}
}
let cr = Rect::from_min_size(pos2(x0 + w / 2.0, y), vec2(w / 2.0, 22.0));
let comps = vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()];
let comps = if world_ready {
vec!["World-Ready Every-line Composer".to_string(), "World-Ready Single-line Composer".to_string()]
} else {
vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()]
};
let cpop = egui::Id::new("para-composer-pop");
let single = doc.composer == Composer::SingleLine;
if widgets::dropdown(ui, cr, &comps[single as usize], &t, egui::Id::new("para-composer")).clicked() && enabled {
@ -510,6 +518,56 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// The font menu: recent fonts first, names in English or the fonts' own language, and a
/// "Sample" preview in each font (Settings ▸ Type).
fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option<String> {
if !ui.data(|d| d.get_temp::<bool>(id.with("open")).unwrap_or(false)) {
return None;
}
let rows = effectcraft_engine::font_menu(&app.session.prefs);
let preview = app.session.prefs.type_.font_preview;
let t = app.tokens;
let mut chosen = None;
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(if preview { 300.0 } else { 180.0 });
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
for r in &rows {
if r.family.is_empty() {
ui.separator();
continue;
}
let resp = ui.selectable_label(r.family == current, &r.display);
if preview && ui.is_rect_visible(resp.rect) {
let key = egui::Id::new(("font-preview", &r.family));
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
Some(l) => l,
None => {
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
ui.data_mut(|d| d.insert_temp(key, l.clone()));
l
}
};
let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0);
for poly in lines.iter() {
let pts: Vec<egui::Pos2> = poly.iter().map(|p| o + vec2(p[0], p[1])).collect();
ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
}
}
if resp.clicked() {
chosen = Some(r.family.clone());
}
}
});
});
});
let outside = ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !area.response.hovered();
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(id.with("open"), false));
}
chosen
}
/// Align panel: align selected layers to the composition (or to the selection).
pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;

View File

@ -1573,15 +1573,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
let buf_id = egui::Id::new(("expr-buf", uid));
let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er));
let resp = child.add(
egui::TextEdit::multiline(&mut buf)
.id(egui::Id::new(("expr-edit", uid)))
.font(Tokens::mono(11.5))
.text_color(if ex.enabled { expr_col } else { t.text_dim })
.desired_width(er.width())
.desired_rows((*lines).clamp(1, 8))
.frame(egui::Frame::NONE),
// Settings ▸ Scripting & Expressions ▸ Expressions Editor.
let sp = app.session.prefs.scripting.clone();
let resp = super::expr_editor::editor(
ui,
egui::Id::new(("expr-edit", uid)),
&mut buf,
er,
&sp,
if ex.enabled { expr_col } else { t.text_dim },
(*lines).clamp(1, 8),
&t,
);
app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
@ -1986,6 +1988,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]})));
}
}
crate::panels::DragPayload::Files(paths) => actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true}))),
crate::panels::DragPayload::Property { .. } => {}
}
egui::DragAndDrop::clear_payload(&ctx);

View File

@ -517,12 +517,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let sub = g.grid_subdivisions.max(1) as f32;
let sstep = step / sub;
let draw_minor = sstep >= 4.0;
// Settings ▸ Grids & Guides ▸ Grid Style (lines, dashed lines or dots).
let style = g.grid_style.clone();
let mut i = 0u32;
let mut gx = comp_rect.min.x;
while gx <= comp_rect.max.x {
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
if is_major || draw_minor {
painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor });
styled_line(&painter, [pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor }, &style);
}
gx += sstep;
i += 1;
@ -532,7 +534,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
while gy <= comp_rect.max.y {
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
if is_major || draw_minor {
painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor });
styled_line(&painter, [pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor }, &style);
}
gy += sstep;
i += 1;
@ -545,13 +547,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if app.ui.viewer.guides {
let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b));
// Settings ▸ Grids & Guides ▸ Guide Style.
let style = app.session.prefs.grids.guide_style.clone();
for g in &comp.guides {
if g.vertical {
let x = comp_rect.min.x + g.position as f32 * zoom;
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], stroke);
styled_line(&painter, [pos2(x, area.min.y), pos2(x, area.max.y)], stroke, &style);
} else {
let y = comp_rect.min.y + g.position as f32 * zoom;
painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], stroke);
styled_line(&painter, [pos2(area.min.x, y), pos2(area.max.x, y)], stroke, &style);
}
}
}
@ -613,6 +617,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let hover = ui.input(|i| i.pointer.hover_pos());
ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths);
}
// Settings ▸ Previews ▸ Show Internal Wireframes: outlines of the layers inside a
// selected collapsed precomp.
if app.session.prefs.previews.show_internal_wireframes
&& l.switches.collapse
&& let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
&& let Some(nc) = app.session.project.comp(*item)
{
let (outer, _) = l2c(&ectx, l);
let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.source_time(l));
for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) {
if let Some((_, q, _)) = layer_quad(&nctx, nl) {
let sq: Vec<Pos2> = q
.iter()
.map(|p| {
let c = outer.apply(gv2(p[0], p[1]));
map.to_screen([c.x, c.y])
})
.collect();
painter.add(egui::Shape::closed_line(sq, Stroke::new(1.0, col.gamma_multiply(0.6))));
}
}
}
let Some((m, q, _)) = layer_quad(&ectx, l) else {
// Cameras, lights, empty layers: just the anchor.
if let Some(tr) = l.transform() {
@ -661,6 +687,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
// Settings ▸ 3D: Extended Viewer (3D layers outlined beyond the comp frame) and Show 3D
// Reference Axes (world X/Y/Z in the corner, turning with the view).
let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time));
if has_3d && app.session.prefs.three_d.extended_viewer {
for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) {
if let Some((_, q, _)) = layer_quad(&ectx, l) {
let sq: Vec<Pos2> = q.iter().map(|p| map.to_screen(*p)).collect();
let outside = sq.iter().any(|p| !comp_rect.contains(*p));
if outside {
painter.add(egui::Shape::closed_line(sq, Stroke::new(1.0, t.label(l.label).gamma_multiply(0.55))));
}
}
}
}
if has_3d && app.session.prefs.three_d.show_reference_axes {
let pc = cam_state(&ectx).projection(cw as f64, ch as f64);
let o3 = effectcraft_engine::geom::vec3(cw as f64 / 2.0, ch as f64 / 2.0, 0.0);
let o = pc.apply(o3);
let corner = pos2(area.min.x + 34.0, area.max.y - 34.0);
for (axis, col, name) in [
(effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50), "X"),
(effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60), "Y"),
(effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x8c, 0xf0), "Z"),
] {
let e = pc.apply(o3 + axis * 10.0);
let d = vec2((e.x - o.x) as f32, (e.y - o.y) as f32);
let d = if d.length() > 1e-4 { d.normalized() * 22.0 } else { vec2(0.0, 0.0) };
painter.arrow(corner, d, Stroke::new(1.5, col));
painter.text(corner + d * 1.25, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col);
}
app.auto.add("viewer.referenceAxes", Rect::from_center_size(corner, vec2(56.0, 56.0)), "3D reference axes");
}
super::expr_editor::error_banner(app, ui, area);
// Track points of the current track (Tracker panel) on its layer, while that layer is selected.
let mut track_hits = vec![];
let track_cur = super::tracker::viewer_track(app);
@ -1564,6 +1625,26 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
/// `#rrggbb` → sRGB bytes.
/// A grid or guide line in the Grids & Guides style: `lines`, `dashed` or `dots`.
pub(crate) fn styled_line(painter: &egui::Painter, pts: [Pos2; 2], stroke: Stroke, style: &str) {
match style {
"dashed" => {
painter.add(egui::Shape::dashed_line(&pts, stroke, 6.0, 4.0));
}
"dots" => {
painter.add(egui::Shape::dotted_line(&pts, stroke.color, 4.0, stroke.width.max(1.0) * 0.75));
}
_ => {
painter.line_segment(pts, stroke);
}
}
}
/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest.
pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions {
if smooth { egui::TextureOptions::LINEAR } else { egui::TextureOptions::NEAREST }
}
pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
@ -1577,14 +1658,17 @@ pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> {
/// straight from their wgpu texture (registered with egui-wgpu, no readback).
fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage) {
use crate::frames::FrameImage;
// Settings ▸ Previews ▸ Viewer Zoom Quality.
let smooth = app.session.prefs.viewer_zoom_smooth();
let opts = zoom_texture_options(smooth);
match img {
FrameImage::Cpu(img) => {
match &mut app.viewer_tex {
Some((tex, k)) if tex.size() == img.size => {
tex.set((*img).clone(), egui::TextureOptions::LINEAR);
tex.set((*img).clone(), opts);
*k = key;
}
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), egui::TextureOptions::LINEAR), key)),
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), opts), key)),
}
app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k));
app.viewer_image = Some(img);
@ -1594,12 +1678,13 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames:
let Some(rs) = &app.wgpu else { return };
let view = f.texture.create_view(&Default::default());
let mut ren = rs.renderer.write();
let filter = if smooth { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest };
let id = match &app.viewer_native {
Some((id, _, _)) => {
ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, eframe::wgpu::FilterMode::Linear, *id);
ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, *id);
*id
}
None => ren.register_native_texture(&rs.device, &view, eframe::wgpu::FilterMode::Linear),
None => ren.register_native_texture(&rs.device, &view, filter),
};
drop(ren);
app.viewer_native = Some((id, key, f));

View File

@ -269,7 +269,9 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map
let c = p2c.apply(gv2(v[0], v[1]));
map.to_screen([c.x, c.y])
};
let (lt0, lt1) = (pos.keys[0].time, pos.keys[pos.keys.len() - 1].time);
// Settings ▸ Composition ▸ Motion Path: all keyframes, none, or a window around the CTI.
let times: Vec<Tick> = pos.keys.iter().map(|k| k.time).collect();
let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&times, l.layer_time(ectx.time)) else { return };
let fd = ectx.comp.frame_duration();
let mut pts = vec![];
let mut lt = lt0;
@ -285,6 +287,9 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map
}
let sel = &app.session.state.selected_keys;
for (i, k) in pos.keys.iter().enumerate() {
if k.time < lt0 || k.time > lt1 {
continue;
}
let v = k.value.as_vec3();
let s = to_scr(v);
let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i);

View File

@ -282,11 +282,11 @@ pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, c
let (nx, ny) = (g.proportional_horizontal.max(1), g.proportional_vertical.max(1));
for i in 1..nx {
let x = comp_rect.min.x + comp_rect.width() * i as f32 / nx as f32;
painter.line_segment([pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s);
super::viewer::styled_line(painter, [pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s, &g.grid_style);
}
for i in 1..ny {
let y = comp_rect.min.y + comp_rect.height() * i as f32 / ny as f32;
painter.line_segment([pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s);
super::viewer::styled_line(painter, [pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s, &g.grid_style);
}
}
@ -324,6 +324,11 @@ fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32,
pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) {
let opts = app.session.state.viewer.clone();
let snap = showing_snapshot(app, ctx);
// Settings ▸ Video ▸ Mirror on Computer Monitor off: playback goes to Video Preview only.
if crate::prefs_live::main_viewer_hidden(app) && !snap {
painter.text(comp_rect.center(), Align2::CENTER_CENTER, "Playing on Video Preview", Tokens::ui(13.0), Color32::from_gray(150));
return;
}
if opts.fast_previews == FastPreviews::Wireframe && !snap {
let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32;
let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect };
@ -400,6 +405,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
rect
};
let Some(src) = app.viewer_image.clone() else { return };
let zoom_opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
@ -410,7 +416,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), egui::TextureOptions::LINEAR);
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}

View File

@ -47,10 +47,25 @@ pub fn summary(app: &EffectcraftApp, ctx: &egui::Context, item: ItemId) -> Optio
let ItemKind::Footage(f) = &app.session.project.item(item)?.kind else { return None };
m.insert(item.0, None);
let (f, footage, map, ctx) = (f.clone(), app.session.footage.clone(), app.waveforms.map.clone(), ctx.clone());
// Settings ▸ Disk ▸ Database and Cache Folder: summaries are kept on disk between sessions.
let cache = app.session.media_cache_folder();
std::thread::Builder::new()
.name("ec-waveform".into())
.spawn(move || {
let peaks = effectcraft_engine::render::audio::footage_peaks(footage.as_ref(), item, &f, f.duration, BINS_PER_SEC);
use effectcraft_engine::media_cache;
let file = cache.and_then(|d| media_cache::peaks_path(&d, &f.path, BINS_PER_SEC));
let peaks = match file.as_deref().and_then(media_cache::load_peaks) {
Some(p) => p,
None => {
let p = effectcraft_engine::render::audio::footage_peaks(footage.as_ref(), item, &f, f.duration, BINS_PER_SEC);
if let Some(file) = &file
&& let Err(e) = media_cache::store_peaks(file, &p)
{
log::warn!("media cache: {}: {e}", file.display());
}
p
}
};
if let Ok(mut m) = map.lock() {
m.insert(item.0, Some(Arc::new(Summary { bins_per_sec: BINS_PER_SEC, peaks })));
}

View File

@ -0,0 +1,141 @@
//! Settings the desktop frontend applies every frame: dropped files (Import ▸ Default Drag
//! Import As), the memory watch (Memory & CPU ▸ RAM Reserved / Reduce Cache Size When System Is
//! Low on Memory), Startup ▸ Show Home Screen When Opening a Project, and Video ▸ Video Preview
//! output (a second window showing the composition frame, Mercury Transmit-style).
use egui::{Color32, Rect};
use serde_json::{Value, json};
use crate::EffectcraftApp;
use crate::frames::FrameKey;
/// Run once per frame, before the panels draw.
pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
dropped_files(app, ctx);
memory_watch(app, ctx);
home_on_open(app, ctx);
video_preview(app, ctx);
}
/// Files dropped on the window are imported (layered files as Default Drag Import As says).
fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) {
let paths: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| std::path::Path::new(p).is_absolute()).collect());
if paths.is_empty() {
return;
}
let (proj, rest): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| {
let l = p.to_ascii_lowercase();
l.ends_with(".ecproj") || l.ends_with(".ecprojx")
});
if let Some(p) = proj.first()
&& let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p}))
{
app.ui.status = e;
}
if !rest.is_empty() {
let params = json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as()});
if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) {
app.ui.status = e;
}
}
}
/// Re-read the system's free memory every few seconds (cache budgets follow it).
fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let id = egui::Id::new("prefs-memory-watch");
let last = ctx.data(|d| d.get_temp::<f64>(id));
if last.is_some_and(|t| now - t < 10.0) {
return;
}
ctx.data_mut(|d| d.insert_temp(id, now));
// The first frame only records the time: tests and short runs don't query the system.
if last.is_some() {
app.session.memory_tick();
}
}
/// Startup ▸ Show Home Screen When Opening a Project.
fn home_on_open(app: &mut EffectcraftApp, ctx: &egui::Context) {
let id = egui::Id::new("prefs-last-project");
let path = app.session.path.clone();
let last = ctx.data(|d| d.get_temp::<Option<String>>(id));
ctx.data_mut(|d| d.insert_temp(id, path.clone()));
let Some(last) = last else { return };
let opened = app.session.journal.last().is_some_and(|(c, _)| matches!(c.as_str(), "file.open" | "file.openRecent" | "file.revert"));
if path.is_some() && path != last && opened && app.session.prefs.startup.show_home_on_open_project {
app.ui.start_screen = true;
}
}
/// Video ▸ Mirror on Computer Monitor off: during playback the Composition panel leaves the
/// frame to the Video Preview window.
pub fn main_viewer_hidden(app: &EffectcraftApp) -> bool {
let v = &app.session.prefs.video;
v.enable_output && !v.mirror_on_monitor && app.playback.playing
}
/// Whether the Video Preview window shows now (Disable Video Output When in Background).
pub fn video_preview_active(app: &EffectcraftApp, focused: bool) -> bool {
let v = &app.session.prefs.video;
v.enable_output && (focused || !v.disable_when_background)
}
/// Video ▸ Enable Video Preview Output: a second native window with the current composition
/// frame, letterboxed on black. Video Device "Full Screen" opens it full screen (move it to the
/// external display first); Video Output During Playback off holds the last frame while playing.
fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) {
let focused = ctx.input(|i| i.focused);
if !video_preview_active(app, focused) {
return;
}
let v = app.session.prefs.video.clone();
let tex_id = egui::Id::new("video-preview-tex");
let update = !app.playback.playing || v.output_during_playback;
let key = app.viewer_shown.as_ref().map(|(_, k)| *k);
let cur: Option<(FrameKey, egui::TextureHandle)> = ctx.data(|d| d.get_temp(tex_id));
let tex = match (update, key, cur) {
(true, Some(k), Some((ck, t))) if ck == k => Some(t),
(true, Some(k), prev) => match app.viewer_pixels() {
Some(img) => {
let opts = crate::panels::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let t = match prev {
Some((_, mut t)) => {
t.set((*img).clone(), opts);
t
}
None => ctx.load_texture("video-preview", (*img).clone(), opts),
};
ctx.data_mut(|d| d.insert_temp(tex_id, (k, t.clone())));
Some(t)
}
None => prev.map(|p| p.1),
},
(_, _, prev) => prev.map(|p| p.1),
};
let title =
format!("Video Preview — {}", app.session.active_comp_id().and_then(|c| app.session.project.item(c)).map(|i| i.name.clone()).unwrap_or_default());
let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([960.0, 540.0]);
if v.device == "fullscreen" {
builder = builder.with_fullscreen(true);
}
let mut closed = false;
ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("effectcraft-video-preview"), builder, |vctx, _| {
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(vctx, |ui| {
let area = ui.max_rect();
if let Some(t) = &tex {
let [w, h] = t.size().map(|x| x.max(1) as f32);
let s = (area.width() / w).min(area.height() / h);
let r = Rect::from_center_size(area.center(), egui::vec2(w * s, h * s));
ui.painter().image(t.id(), r, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
}
});
if vctx.input(|i| i.viewport().close_requested()) {
closed = true;
}
});
if closed && let Err(e) = app.set_pref("video.enableOutput", Value::Bool(false)) {
app.ui.status = e;
}
}

View File

@ -612,6 +612,9 @@ pub struct UiState {
/// Flowchart panel options (layers, effects, solids, direction, root comp).
#[serde(default)]
pub flowchart: crate::panels::flowchart::FlowOptions,
/// Lumetri Scopes panel options.
#[serde(default)]
pub scopes: crate::panels::scopes_panel::ScopesState,
}
impl Default for UiState {
@ -665,6 +668,7 @@ impl Default for UiState {
anim_tools: AnimToolsState::default(),
mini_flowchart: None,
flowchart: Default::default(),
scopes: Default::default(),
}
}
}

View File

@ -75,6 +75,29 @@ pub struct Tokens {
pub gap: f32,
pub tab_h: f32,
pub row_h: f32,
/// Settings ▸ Appearance ▸ Use Gradients: panel tab strips and buttons get a soft vertical
/// gradient (off: flat fills).
pub gradients: bool,
}
/// A vertical gradient fill (`top` → `bottom`) over `rect`.
pub fn gradient_rect(painter: &egui::Painter, rect: egui::Rect, top: Color32, bottom: Color32) {
let mut m = egui::Mesh::default();
m.colored_vertex(rect.left_top(), top);
m.colored_vertex(rect.right_top(), top);
m.colored_vertex(rect.right_bottom(), bottom);
m.colored_vertex(rect.left_bottom(), bottom);
m.add_triangle(0, 1, 2);
m.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(m));
}
impl Tokens {
/// The gradient top colour for a fill (a little lighter), or the fill itself when gradients
/// are off.
pub fn grad_top(&self, c: Color32) -> Color32 {
if self.gradients { c.lerp_to_gamma(Color32::WHITE, 0.06) } else { c }
}
}
impl Tokens {
@ -123,6 +146,7 @@ impl Tokens {
gap: 4.0,
tab_h: 30.0,
row_h: 22.0,
gradients: true,
};
match kind {
ThemeKind::Dark => dark,
@ -192,6 +216,7 @@ impl Tokens {
let [r, g, b] = p.label_rgb(*l);
t.labels[i] = Color32::from_rgb(r, g, b);
}
t.gradients = p.appearance.use_gradients;
t
}

View File

@ -0,0 +1,144 @@
//! Headless checks for the M13.6 panels: each opens from the Window menu as a real panel and
//! registers its automation ids; the Footage panel's buttons edit into the comp; the Progress
//! panel lists and cancels a job; Lumetri Scopes switch scope through their dropdown.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Event, Pos2, pos2};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Panels", "width": 320, "height": 180, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn open(h: &mut Harness<'_, EffectcraftApp>, panel: &str) {
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel": panel})).unwrap();
h.run_steps(3);
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
let p: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
#[test]
fn window_menu_opens_the_new_panels() {
let mut h = harness();
for (name, kind, auto) in [
("lumetriScopes", PanelKind::LumetriScopes, "scopes.kind"),
("footage", PanelKind::Footage, "footage.empty"),
("mediaBrowser", PanelKind::MediaBrowser, "mediaBrowser.path"),
("metadata", PanelKind::Metadata, "metadata.projectComment"),
("progress", PanelKind::Progress, ""),
("contentAwareFill", PanelKind::ContentAwareFill, "contentFill.method"),
] {
open(&mut h, name);
assert!(h.state().ui.dock.contains(kind) || h.state().ui.floating.iter().any(|f| f.panels.contains(&kind)), "{name} shown");
if !auto.is_empty() {
assert!(h.state().auto.find(auto).is_some(), "{name}: {auto}");
}
}
}
#[test]
fn scopes_switch_kind() {
let mut h = harness();
open(&mut h, "lumetriScopes");
assert!(h.state().auto.find("scopes.plot").is_some());
h.state_mut().ui.scopes.scope = "vectorscopeYuv".into();
h.run_steps(2);
assert_eq!(h.state().auto.find("scopes.plot").unwrap().label, "Vectorscope YUV");
h.state_mut().ui.scopes.scope = "histogram".into();
h.run_steps(2);
assert_eq!(h.state().auto.find("scopes.kind").unwrap().label, "Histogram");
}
#[test]
fn footage_panel_buttons_edit_into_the_comp() {
let mut h = harness();
let item = h.state().session.project.items.values().find(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).unwrap().id.0;
h.state_mut().session.execute("footage.open", json!({"item": item})).unwrap();
h.run_steps(4);
assert!(h.state().ui.dock.contains(PanelKind::Footage) || h.state().ui.floating.iter().any(|f| f.panels.contains(&PanelKind::Footage)));
assert!(h.state().auto.find("footage.overlayEdit").is_some());
let before = h.state().session.active_comp().unwrap().layers.len();
click(&mut h, "footage.overlayEdit");
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), before + 1);
click(&mut h, "footage.rippleInsertEdit");
assert!(h.state().session.active_comp().unwrap().layers.len() >= before + 2);
}
#[test]
fn progress_panel_cancels_a_job() {
let mut h = harness();
h.state_mut()
.session
.spawn_task("test", "Long analysis", false, |ctl| {
while ctl.progress(1, 2) {
std::thread::sleep(std::time::Duration::from_millis(2));
}
Err("cancelled".into())
})
.unwrap();
open(&mut h, "progress");
let id = h.state().session.jobs()[0].id.clone();
assert!(h.state().auto.find(&format!("progress.job.{id}")).is_some());
click(&mut h, &format!("progress.cancel.{id}"));
h.state_mut().session.wait_jobs();
h.run_steps(2);
assert!(h.state().session.jobs().is_empty());
assert_eq!(h.state().session.job_log.last().unwrap().status, "cancelled");
}
/// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with
/// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test ui_panels -- --ignored`.
#[test]
#[ignore]
fn panels_snapshot() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let solid = s.project.items.values().find(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).map(|i| i.id.0);
let mut app = Some(EffectcraftApp::new(s));
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app"));
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "All Panels"})).unwrap();
h.run_steps(3);
let dir = std::env::var("PANELS_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out").into());
std::fs::create_dir_all(&dir).unwrap();
if let Some(id) = solid {
h.state_mut().session.execute("footage.open", json!({"item": id})).unwrap();
}
for (panel, scope) in [
("lumetriScopes", "waveformRgb"),
("lumetriScopes", "vectorscopeYuv"),
("lumetriScopes", "histogram"),
("lumetriScopes", "paradeRgb"),
("contentAwareFill", ""),
("metadata", ""),
("progress", ""),
("mediaBrowser", ""),
("footage", ""),
] {
if !scope.is_empty() {
h.state_mut().ui.scopes.scope = scope.into();
}
open(&mut h, panel);
h.run_steps(3);
let img = h.render().expect("render");
img.save(format!("{dir}/{panel}{scope}.png")).unwrap();
}
}

View File

@ -134,7 +134,9 @@ fn double_click_enters_editing_and_selects_words() {
assert_eq!(edited(&h).1, "alpha gamma");
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(edited(&h).1, "alpha beta");
// Paragraph panel: alignment applies to the edited paragraph.
// Paragraph panel: alignment applies to the edited paragraph (the South Asian and Middle
// Eastern text engine shows the direction popup too).
h.state_mut().session.prefs.type_.text_engine = "southAsian".into();
h.state_mut().show_panel(PanelKind::Paragraph);
h.run_steps(3);
let r = rect(&h, "paragraph.right");

View File

@ -288,6 +288,42 @@ demand (`self_at`) and `roto.propagate` fills in the background; `Session::edit`
Key in step with the layer's source, masks and upstream effects. Freeze stores final 8-bit mattes
in the effect.
**Content-Aware Fill** (`raster::inpaint`, `engine::commands::content_fill`): the hole is
where the layer's masked source (`Renderer::layer_input` with no effects) is under half
opacity, grown by Alpha Expansion. Optical flow between neighbouring membrane-filled frames is
*completed* inside the holes (membrane interpolation of the surrounding tiles' motion); each
missing pixel follows it forwards and backwards to the nearest frame (or reference frame) where
it is visible, the two candidates blended by temporal distance, with Lighting Correction adding
the membrane of the border mismatch (Poisson-style). What no frame shows is synthesised by
PatchMatch (Barnes et al. 2009) inside Wexler et al.'s (2007) multi-scale EM: Object refines each
frame starting from the previous result warped along the flow; Surface carries one synthesised
fill through the range; Edge Blend is the membrane fill alone. The frames are written as a PNG
sequence and imported into a "Fill" layer above the source. It runs as a generic **background
task** (`engine::jobs`: a closure with progress and cancel on a thread, inline on wasm32 or
with `wait`, whose result is applied on the UI thread as one undo step); the Progress panel and
`jobs.list` / `jobs.cancel` show these tasks together with the render queue and the analyses.
**Scene Edit Detection** (`raster::cuts`) compares consecutive frames by colour-histogram
distance and motion-compensated (block-flow warped) luma error, takes their geometric mean and
marks frames that exceed a threshold and several times their neighbours' median; the cuts
become layer markers, split layers, or split-and-precomposed layers. **Auto-trace**
(`path::trace`) runs marching squares on the chosen channel, Douglas–Peucker simplification and
cubic fitting with Corner Roundness blending chord and smooth tangents; holes become Subtract
masks, and a work-area trace keys Mask Path per frame.
**Align Video to Data** reads data footage (File ▸ Import of `.json`, `.csv`, `.tsv`, kept as
text in the project). Supported data: JSON as an array of objects or an object holding one
(`{"samples": [...]}`), CSV/TSV with a header row (quoted fields allowed). The time key is the
`key` parameter or the first field named `time`, `timestamp`, `t`, `date`, `datetime`, `utc`,
`gps_time`, `seconds` or `time_s`; values may be ISO 8601 date-times (with `Z` or `±hh:mm`),
times of day (`hh:mm:ss.sss`), timecode (`hh:mm:ss:ff` at the footage rate), Unix seconds or
Unix milliseconds. The layer's Start Time becomes `dataStart + videoStart − firstSample`
(`videoStart` given, or the file's creation date on the desktop; reduced to a time of day when
the data has no dates).
**Lumetri Scopes** (`raster::scopes`) are density plots of the viewer frame (rendered over the
comp background) with BT.601 / BT.709 / BT.2020 luma and colour-difference coefficients.
## 5. Expressions
Expressions are JavaScript, run by boa, with After Effects' object model (`thisComp`, `thisLayer`,

View File

@ -22,9 +22,7 @@ Tracking 88%, Effects 85%, Web 65%.
What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; Render Queue field render, crop/resize and templates;
approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; text in imported
PDF/AI files and Illustrator procset EPS; more codecs; 59 preferences not yet wired; ScriptUI;
Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and
the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7).
PDF/AI files and Illustrator procset EPS; more codecs; 59 preferences not yet wired; ScriptUI; face tracking; and the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7).
The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for
history.
@ -165,6 +163,25 @@ style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and
Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry
taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change advances.
## Update: M13.6 panels and content tools
The last placeholder panels became real panels with automation ids: **Lumetri Scopes**
(waveform RGB / Luma / YC, vectorscope YUV / HLS, histogram, parade RGB / YUV; Rec. 601 / 709 /
2020, 8-bit or float scale, clamp; `scopes.analyze`), **Footage** (double-click footage: its own
time ruler, play, In/Out, Overlay Edit and Ripple Insert Edit; `footage.*`), **Media Browser**
(desktop file system, favourites, thumbnails, import and drag to the Project panel or timeline;
`mediaBrowser.*`), **Metadata** (codec, size, rate, duration, colour profile, file dates; item
and project comments; `item.metadata`) and **Progress** (every render and analysis with
progress and cancel; `jobs.*`). **Content-Aware Fill** (panel and Layer ▸ New ▸ Content-Aware
Fill Layer): Object / Surface / Edge Blend, Work Area / Entire Duration, Alpha Expansion,
Lighting Correction and a reference frame from a layer, rendered to a PNG sequence in a Fill
layer above the source as a background job. **Scene Edit Detection** (markers, split, split and
precompose), **Auto-trace** (alpha / RGB / luminance, current frame or work area, to the layer
or a new layer) and **Align Video to Data** (JSON / CSV / TSV time keys) replace their disabled
menu entries. Not yet: creating a reference frame by editing a still (After Effects hands it to
Photoshop), Content-Aware Fill's learned model (ours is PatchMatch + flow propagation), and Media
Browser in the web app.
## Highest-value gaps, in order
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).

View File

@ -70,72 +70,80 @@ Commands (CLI, MCP, control channel):
- `threeD.defaultRenderer`: Default 3D Renderer
- `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu,
the timeline, project panel, render queue and viewer handles
- `general.switchesAffectNestedComps`: a precomp layer's Quality (Draft / Wireframe) and Motion
Blur switches limit the layers of the nested comp (viewer, Render Queue, `render`)
- `general.preserveConstantVertexCount`: adding or deleting mask / shape path vertices at one
keyframe does the same on every other keyframe (added vertices split the matching segment)
- `general.syncTimeRelatedItems`: moving the current time moves it in the comps nested in this
one and in the comps that nest it (through the precomp layers' timing)
- `general.expressionPickWhipCompact`: off, the pick whip writes match names
(`thisComp.layer("A")("transform")("opacity")`)
- `general.createSplitLayersAbove`: where Edit ▸ Split Layer puts the new layer
- `startup.showHomeOnOpenProject`: the Home screen comes up after File ▸ Open / Open Recent
- `composition.motionPath`, `composition.motionPathSeconds`, `composition.motionPathKeyframes`:
how much of a position motion path the viewer draws (all, none, N seconds or N keyframes
around the current time)
- `previews.showInternalWireframes`: outlines of the layers inside a selected collapsed precomp
- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = bilinear
- `previews.displayProfile`: the monitor's colour space for View ▸ Use Display Color Management
- `appearance.useLabelColorForTabs`: Composition / Timeline tabs show the comp's label colour,
Effect Controls / Properties the layer's
- `appearance.cycleMaskColors`: new masks cycle through the mask colours (off: all the first)
- `appearance.useGradients`: soft gradient on panel tab strips
- `grids.gridStyle`, `grids.guideStyle`: lines, dashed lines or dots (grid, proportional grid,
guides); `grids.proportionalHorizontal` / `grids.proportionalVertical`: View ▸ Show
Proportional Grid divisions
- `type.textEngine`: South Asian and Middle Eastern shows the paragraph direction and the
World-Ready composers in the Paragraph panel
- `type.fontPreview`: a "Sample" preview in each font of the Character panel's font menu
- `type.recentFonts`: how many recently used fonts head the font menu
- `type.fontNamesInEnglish`: off, fonts with a native-language family name show it
- `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import
- `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage;
Guess takes premultiplied for movies, straight for stills)
- `import.dragImportAs`: layered files dropped on the window import as footage or a comp
- `export.defaultOutputFolder`: where relative Render Queue outputs go
- `export.segmentSequences`, `export.segmentSequenceFiles`: sequences split into numbered
folders of N files; `export.segmentMovies`, `export.segmentMovieMb`: movies split into
numbered files of about N MB (cut at the frame count the output's data rate gives)
- `export.appendBitsToName`: `Comp 1.png` → `Comp 1_16bpc.png`
- `audio.previewSampleRate`: the rate previews are mixed at (and the device is opened at, when
it supports it)
- `disk.diskCacheEnabled`, `disk.diskCacheMaxGb`, `disk.diskCacheFolder`: the persistent disk
cache of processed layers
- `disk.mediaCacheFolder`: audio waveform summaries kept between sessions (`Peaks/`)
- `disk.conformedMediaFolder`: decoded (conformed) footage audio, written once per file and
sample rate and read back instead of decoding again
- `memory.ramReservedGb`: the cache budgets together leave this much physical memory free
- `memory.reduceCacheWhenLow`: cache budgets halve while the system is low on memory (checked
every 10 s by the desktop app)
- `video.enableOutput`, `video.device`, `video.outputDuringPlayback`, `video.mirrorOnMonitor`,
`video.disableWhenBackground`: Video Preview, a second window (or full screen on the display
it is on) showing the composition frame, Mercury Transmit-style
- `threeD.showReferenceAxes`: world X/Y/Z axes in the viewer corner of comps with 3D layers
- `threeD.extendedViewer`: 3D layers reaching beyond the comp frame are outlined on the
pasteboard
- `threeD.realtimeShadows`: draft (Fast Previews) renders keep 3D shadows
- `scripting.allowScriptsWriteFiles`: the scripting file / network gate (and `File.execute()`)
- `scripting.warnExecutingFiles`: `File.execute()` asks before opening a file
- `scripting.editorFontSize`, `scripting.syntaxHighlighting`, `scripting.lineNumbers`,
`scripting.autoComplete` (Tab accepts), `scripting.bracketMatching`, `scripting.wordWrap`:
the Timeline's expression editor
- `scripting.errorBanner`: a banner along the bottom of the Composition panel names the first
failing expression (click: reveal it)
### Not wired yet (TODO)
### Display-only settings
These rows are in the dialog for After Effects parity and are stored, but nothing reads them yet.
`prefs.pages` reports them with `"live": false`. A test keeps this list in step with the schema.
These rows are in the dialog for After Effects parity and are stored, but have no EffectCraft
behaviour to change. `prefs.pages` reports them with `"live": false`; a test keeps this list in
step with the schema.
- `general.switchesAffectNestedComps`: Switches Affect Nested Comps
- `general.preserveConstantVertexCount`: Preserve Constant Vertex and Feather Point Count when Editing Masks
- `general.syncTimeRelatedItems`: Synchronize Time of All Related Items
- `general.expressionPickWhipCompact`: Expression Pick Whip Writes Compact English
- `general.createSplitLayersAbove`: Create Split Layers Above Original Layer
- `general.useSystemColorPicker`: Use System Color Picker
- `startup.showHomeOnOpenProject`: Show Home Screen When Opening a Project
- `composition.motionPath`: Motion Path
- `composition.motionPathSeconds`: Seconds
- `composition.motionPathKeyframes`: Keyframes
- `composition.hardwareAcceleratePanels`: Hardware Accelerate Composition, Layer and Footage Panels
- `previews.showInternalWireframes`: Show Internal Wireframes
- `previews.zoomQuality`: Viewer Zoom Quality
- `previews.displayProfile`: Display Color Space
- `appearance.useLabelColorForTabs`: Use Label Color for Related Tabs
- `appearance.cycleMaskColors`: Cycle Mask Colors
- `appearance.useGradients`: Use Gradients
- `grids.gridStyle`: Style
- `grids.proportionalHorizontal`: Horizontal
- `grids.proportionalVertical`: Vertical
- `grids.guideStyle`: Style
- `type.textEngine`: Text Engine
- `type.fontPreview`: Show Font Preview
- `type.recentFonts`: Number of Recent Fonts to Display
- `type.fontNamesInEnglish`: Show Font Names in English
- `import.reportMissingFrames`: Report Missing Frames
- `import.unlabeledAlpha`: Interpret Unlabeled Alpha As
- `import.dragImportAs`: Default Drag Import As
- `export.defaultOutputFolder`: Default Output Folder
- `export.segmentSequences`: Segment Sequences
- `export.segmentSequenceFiles`: Files per Segment
- `export.segmentMovies`: Segment Movie Files
- `export.segmentMovieMb`: Segment Size
- `export.appendBitsToName`: Append Bit Depth to File Name
- `audio.previewSampleRate`: Preview Sample Rate
- `disk.diskCacheEnabled`: Enable Disk Cache
- `disk.diskCacheMaxGb`: Maximum Disk Cache Size
- `disk.diskCacheFolder`: Disk Cache Folder
- `disk.mediaCacheFolder`: Database and Cache Folder
- `disk.conformedMediaFolder`: Conformed Audio Folder
- `memory.ramReservedGb`: RAM Reserved for Other Applications
- `memory.reduceCacheWhenLow`: Reduce Cache Size When System Is Low on Memory
- `video.enableOutput`: Enable Video Preview Output
- `video.device`: Video Device
- `video.outputDuringPlayback`: Video Output During Playback
- `video.mirrorOnMonitor`: Mirror on Computer Monitor
- `video.disableWhenBackground`: Disable Video Output When in Background
- `threeD.showReferenceAxes`: Show 3D Reference Axes
- `threeD.extendedViewer`: Extended Viewer
- `threeD.realtimeShadows`: Realtime Shadows in Draft
- `scripting.allowScriptsWriteFiles`: Allow Scripts to Write Files and Access Network
- `scripting.warnExecutingFiles`: Warn User When Executing Files
- `scripting.enableJsDebugger`: Enable JavaScript Debugger
- `scripting.editorFontSize`: Font Size
- `scripting.syntaxHighlighting`: Syntax Highlighting
- `scripting.lineNumbers`: Line Numbers
- `scripting.autoComplete`: Auto-complete
- `scripting.bracketMatching`: Bracket Matching
- `scripting.wordWrap`: Word Wrap
- `scripting.errorBanner`: Show Expression Error Banner
- `general.useSystemColorPicker`: EffectCraft has one colour picker on every platform and the
web; there is no pure-Rust way to open the system colour panels.
- `composition.hardwareAcceleratePanels`: the Composition, Layer and Footage panels are always
drawn by the GPU (egui on wgpu).
- `scripting.enableJsDebugger`: the JavaScript engine has no step debugger; script errors report
their file and line in the Script Console.
## Keyboard shortcuts