Merge main into M13.2
This commit is contained in:
commit
7ac5317ce9
|
|
@ -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>>(
|
||||
|
|
|
|||
|
|
@ -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())));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
)]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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, ×, &o, &|d, t| ctl.progress(d, t))?;
|
||||
let apply: Apply = Box::new(move |s: &mut Session| apply(s, cid, lid, mode, &cut_times));
|
||||
Ok(apply)
|
||||
})
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, cid: ItemId, lid: LayerId, mode: Mode, cuts: &[Tick]) -> Result<Value> {
|
||||
let secs: Vec<f64> = cuts.iter().map(|t| t.seconds()).collect();
|
||||
if cuts.is_empty() {
|
||||
return Ok(json!({"cuts": secs, "layers": [lid.0]}));
|
||||
}
|
||||
if mode == Mode::Markers {
|
||||
s.edit("Scene Edit Detection", None, |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
for (i, t) in cuts.iter().enumerate() {
|
||||
let lt = l.layer_time(*t);
|
||||
l.markers.retain(|m| m.time != lt);
|
||||
l.markers.push(Marker { time: lt, comment: format!("Scene {}", i + 2), ..Default::default() });
|
||||
}
|
||||
l.markers.sort_by_key(|m| m.time);
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({"cuts": secs, "layers": [lid.0]}));
|
||||
}
|
||||
let snap = s.project.clone();
|
||||
// Split from the source layer: each piece ends at the next cut (pieces stacked like Split
|
||||
// Layer: later pieces above).
|
||||
let pieces = s.edit("Scene Edit Detection", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let i = cm.layers.iter().position(|l| l.id == lid).ok_or(EngineError::NoComp)?;
|
||||
let mut ids = vec![lid];
|
||||
for t in cuts {
|
||||
let cur = cm.layers.iter().position(|l| l.id == *ids.last().expect("piece")).unwrap_or(i);
|
||||
let l = &mut cm.layers[cur];
|
||||
if !(*t > l.in_point && *t < l.out_point) {
|
||||
continue;
|
||||
}
|
||||
let mut b = l.clone();
|
||||
super::edit::reid(&mut b, &mut next);
|
||||
b.in_point = *t;
|
||||
l.out_point = *t;
|
||||
ids.push(b.id);
|
||||
cm.layers.insert(cur, b);
|
||||
}
|
||||
proj.next_id = next;
|
||||
st.selected_layers = ids.clone();
|
||||
Ok(ids)
|
||||
})?;
|
||||
let mut out = pieces.clone();
|
||||
if mode == Mode::SplitPrecompose {
|
||||
let base = s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| l.name.clone()).unwrap_or_default();
|
||||
out.clear();
|
||||
for (k, id) in pieces.iter().enumerate() {
|
||||
let r = s.execute(
|
||||
"layer.precompose",
|
||||
json!({"comp": cid.0, "layers": [id.0], "mode": "move", "adjustDuration": true, "name": format!("{base} Scene {}", k + 1)}),
|
||||
)?;
|
||||
let new = r.get("layer").and_then(Value::as_u64).map(LayerId).unwrap_or(*id);
|
||||
out.push(new);
|
||||
}
|
||||
s.collapse_undo("Scene Edit Detection", &snap);
|
||||
}
|
||||
Ok(json!({"cuts": secs, "layers": out.iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"layer.sceneEditDetection",
|
||||
"Scene Edit Detection...",
|
||||
["Layer"],
|
||||
None,
|
||||
"{layer?, mode?: markers|split|splitPrecompose, threshold? (0…1, 0.25), wait?: bool}",
|
||||
has_layers,
|
||||
run
|
||||
)]
|
||||
}
|
||||
|
|
@ -4,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![]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 |_| {});
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(®ion));
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\r' => out.push_str(" "),
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(>, &hole);
|
||||
let mem = membrane_fill(&src, &hole);
|
||||
let pm = patch_fill(&src, &hole, None, 7, 1);
|
||||
let (pm_db, mem_db) = (psnr(&pm, >, Some(&hole)), psnr(&mem, >, 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(>, &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], >s[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(>, &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, >, Some(&hole)) > 35.0, "frame {t}: {:.1}", psnr(f, >, 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], >s[t], Some(&input.holes[t]))).sum::<f64>();
|
||||
assert!(score(&lit) > score(&plain), "lit {} vs plain {}", score(&lit), score(&plain));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({})) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!({}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(×, 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue