Merge branch 'worktree-agent-aba044eaf08eded94'

# Conflicts:
#	crates/render/src/three_d/compose.rs
This commit is contained in:
Brandon Thomas 2026-10-03 03:33:36 -04:00
commit 871362ba96
58 changed files with 4325 additions and 291 deletions

View File

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

View File

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

View File

@ -103,6 +103,12 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad("anim.applyPreset", "select a layer"));
}
// Animation ▸ Recent Animation Presets.
if let Some(path) = str_p(p, "path") {
s.prefs.push_recent_preset(path);
s.save_prefs();
s.prefs_revision += 1;
}
let effects: Vec<PropGroup> =
doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();
let props: Vec<(String, Property)> = doc["properties"]

View File

@ -0,0 +1,593 @@
//! Menu entries of M14.5: Edit ▸ History, File ▸ Import Recent Footage, Replace Footage ▸ With
//! Layered Comp, Save a Copy As XML, Animation ▸ Recent Animation Presets, Composition ▸ VR
//! (Create VR Environment, Extract Cubemap), the "Assign Shortcut to …" submenus, Help ▸ Enable
//! Logging / Reveal Logging File / System Compatibility Report, and `File.execute()` for scripts.
use effectcraft_geom::{Mat4, Vec3, vec3};
use effectcraft_project::{ItemId, ItemKind, LayerSource};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_project_selection, str_p};
use crate::{EngineError, Event, Result, Session, cmd, query};
/// Run `f` (which may run several undoable commands) as one undo step named `label`; on error
/// the project is restored.
pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
// The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot
// taken before them), then become one step on the real one.
let saved = std::mem::take(&mut s.history.undo);
let before = s.project.clone();
let r = f(s);
let changed = !s.history.undo.is_empty();
s.history.undo = saved;
s.history.merge_key = None;
match r {
Ok(v) => {
if changed {
s.history.undo.push((label.to_string(), before));
let levels = s.prefs.general.undo_levels.max(1) as usize;
if s.history.undo.len() > levels {
let extra = s.history.undo.len() - levels;
s.history.undo.drain(..extra);
}
}
Ok(v)
}
Err(e) => {
if changed {
s.project = before;
s.sanitize_state();
s.bump();
}
Err(e)
}
}
}
fn id_of(v: &Value, key: &str) -> Result<u64> {
v.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::Other(format!("expected `{key}` in {v}")))
}
// ---------------------------------------------------------------- Edit ▸ History
/// Edit ▸ History: `{steps: n}` undoes the last n steps (the entry n−1 of the list), `{redo: n}`
/// redoes n; without either, lists the steps (newest first).
fn history(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(n) = p.get("steps").and_then(Value::as_u64) {
if n as usize > s.history.undo.len() {
return Err(bad("edit.history", format!("only {} undo steps", s.history.undo.len())));
}
for _ in 0..n {
s.undo();
}
} else if let Some(n) = p.get("redo").and_then(Value::as_u64) {
if n as usize > s.history.redo.len() {
return Err(bad("edit.history", format!("only {} redo steps", s.history.redo.len())));
}
for _ in 0..n {
s.redo();
}
}
let undo: Vec<&str> = s.history.undo.iter().rev().map(|(l, _)| l.as_str()).collect();
let redo: Vec<&str> = s.history.redo.iter().rev().map(|(l, _)| l.as_str()).collect();
Ok(json!({"undo": undo, "redo": redo}))
}
fn has_history(s: &Session) -> std::result::Result<(), String> {
if s.history.undo.is_empty() && s.history.redo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
}
// ---------------------------------------------------------------- recent footage / presets
fn import_recent(s: &mut Session, p: &Value) -> Result<Value> {
let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) {
(Some(x), _) => x.to_string(),
(None, Some(i)) => s.prefs.recent_footage.get(i as usize).cloned().ok_or_else(|| bad("file.importRecent", format!("no recent footage #{i}")))?,
_ => s.prefs.recent_footage.first().cloned().ok_or_else(|| bad("file.importRecent", "no recent footage"))?,
};
super::file::import(s, &json!({"paths": [path]}))
}
fn has_recent_footage(s: &Session) -> std::result::Result<(), String> {
if s.prefs.recent_footage.is_empty() { Err("no recent footage".into()) } else { Ok(()) }
}
fn clear_recent_footage(s: &mut Session, _: &Value) -> Result<Value> {
s.prefs.recent_footage.clear();
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
fn apply_recent_preset(s: &mut Session, p: &Value) -> Result<Value> {
let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) {
(Some(x), _) => x.to_string(),
(None, Some(i)) => s.prefs.recent_presets.get(i as usize).cloned().ok_or_else(|| bad("anim.applyRecentPreset", format!("no recent preset #{i}")))?,
_ => s.prefs.recent_presets.first().cloned().ok_or_else(|| bad("anim.applyRecentPreset", "no recent animation presets"))?,
};
let mut q = json!({"path": path});
if let Some(l) = p.get("layers") {
q["layers"] = l.clone();
}
s.execute("anim.applyPreset", q)
}
fn has_recent_presets(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
if s.prefs.recent_presets.is_empty() { Err("no recent animation presets".into()) } else { Ok(()) }
}
fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
s.prefs.recent_presets.clear();
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
// ---------------------------------------------------------------- File menu
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
let xml = crate::xml_project::to_xml(&s.project.to_json()).map_err(EngineError::Other)?;
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved an XML copy to {path}"));
Ok(json!({"path": path, "bytes": xml.len()}))
}
/// File ▸ Replace Footage ▸ With Layered Comp: a layered (Photoshop) footage item becomes a
/// composition of its layers; every layer using the footage now uses the comp, and the footage
/// item goes. One undo step.
fn replace_with_layered_comp(s: &mut Session, p: &Value) -> Result<Value> {
let item = p.get("item").and_then(Value::as_u64).map(ItemId).or_else(|| s.state.project_selection.first().copied());
let item = item.ok_or_else(|| bad("file.replaceWithLayeredComp", "select a layered footage item"))?;
let path = match s.project.item(item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) if f.codec == "PSD" || f.path.to_ascii_lowercase().ends_with(".psd") => f.path.clone(),
_ => return Err(bad("file.replaceWithLayeredComp", "the item is not layered footage (a Photoshop file)")),
};
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
grouped(s, "Replace Footage", |s| {
let (comp, _, _) = super::file::import_psd_comp(s, &path, bytes, false)?;
let comp = ItemId(comp);
s.edit("Replace Footage", None, |proj, st| {
let ids: Vec<ItemId> = proj.items.keys().copied().collect();
for cid in ids {
if cid == comp {
continue;
}
if let Some(c) = proj.comp_mut(cid) {
for l in &mut c.layers {
if matches!(&l.source, LayerSource::Footage { item: i } if *i == item) {
l.source = LayerSource::Comp { item: comp };
}
}
}
}
proj.items.remove(&item);
st.project_selection = vec![comp];
Ok(())
})?;
Ok(json!({"comp": comp.0}))
})
}
fn has_footage(s: &Session) -> std::result::Result<(), String> {
has_project_selection(s)?;
let ok = s.state.project_selection.iter().any(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_))));
if ok { Ok(()) } else { Err("select a footage item".into()) }
}
/// `File.execute()` from scripts (and agents): open a file with its default application.
/// Needs Allow Scripts to Write Files and Access Network; with Warn User When Executing Files on,
/// the frontend asks first (`confirmed: true` skips the question).
fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.executeFile", "missing `path`"))?;
if !s.prefs.scripting.allow_scripts_write_files {
return Err(bad(
"file.executeFile",
"scripts can't execute files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on",
));
}
if !std::path::Path::new(path).exists() {
return Err(bad("file.executeFile", format!("no file {path}")));
}
if s.prefs.scripting.warn_executing_files && !b_p(p, "confirmed").unwrap_or(false) {
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
return Ok(json!({"needsConfirmation": path}));
}
let url = format!("file://{}", path.replace(' ', "%20"));
s.events.push(Event::OpenUrl(url));
Ok(json!({"opened": path}))
}
// ---------------------------------------------------------------- Assign Shortcut to …
/// The F10 / F11 / F12 (3D views) or Shift+F10 / F11 / F12 (workspaces) slots.
fn slot_p(p: &Value, cmd: &str, shift: bool) -> Result<String> {
let raw = str_p(p, "slot").ok_or_else(|| bad(cmd, "missing `slot` (F10, F11 or F12)"))?;
let k = raw.trim().trim_start_matches("Shift+").to_ascii_uppercase();
if !matches!(k.as_str(), "F10" | "F11" | "F12") {
return Err(bad(cmd, "slot: F10, F11 or F12"));
}
Ok(if shift { format!("Shift+{k}") } else { k })
}
/// Bind `keys` to the bindable `key`, taking it from whatever had it.
fn reassign(s: &mut Session, key: &str, slot: &str, cmd: &str) -> Result<Value> {
let t = s.shortcuts();
let b = t.find(key).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut")))?;
let holders: Vec<(crate::shortcuts::Bindable, Vec<String>)> = t
.bindables
.iter()
.filter(|x| x.key != key && t.keys.get(&x.key).is_some_and(|k| k.iter().any(|k| k == slot)))
.map(|x| (x.clone(), t.keys.get(&x.key).cloned().unwrap_or_default()))
.collect();
let mine = t.keys.get(key).cloned().unwrap_or_default();
let replaced: Vec<String> = holders.iter().map(|(h, _)| h.label.clone()).collect();
for (h, keys) in &holders {
let rest: Vec<String> = keys.iter().filter(|k| *k != slot).cloned().collect();
s.keymaps.set(h, &rest).map_err(|e| bad(cmd, e))?;
}
let mut keys = vec![slot.to_string()];
keys.extend(mine.into_iter().filter(|k| k != slot));
let created = s.keymaps.set(&b, &keys).map_err(|e| bad(cmd, e))?;
s.shortcuts_changed();
if let Some(n) = &created {
s.toast(format!("The default shortcuts can't be changed: created the preset \"{n}\""));
}
Ok(json!({"command": key, "keys": keys, "replaced": replaced, "preset": s.keymaps.active}))
}
/// The command id of a 3D view.
pub(crate) fn view_command(v: effectcraft_render::three_d::View3D) -> &'static str {
use effectcraft_render::three_d::View3D::*;
match v {
ActiveCamera => "view.3d.activeCamera",
Default => "view.3d.default",
Front => "view.3d.front",
Left => "view.3d.left",
Top => "view.3d.top",
Back => "view.3d.back",
Right => "view.3d.right",
Bottom => "view.3d.bottom",
Custom1 => "view.3d.custom1",
Custom2 => "view.3d.custom2",
Custom3 => "view.3d.custom3",
}
}
/// View ▸ Assign Shortcut to "<current 3D view>": F10, F11 or F12 switches to the view.
fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result<Value> {
let slot = slot_p(p, "view.assign3dShortcut", false)?;
let cid = comp_id(s, p)?;
let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
reassign(s, view_command(cur), &slot, "view.assign3dShortcut")
}
/// Window ▸ Assign Shortcut to "<workspace>" Workspace: Shift+F10, Shift+F11 or Shift+F12.
/// The frontend fills in the current workspace.
fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result<Value> {
let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?;
let Some(ws) = str_p(p, "workspace") else { return super::frontend(s, "window.assignWorkspaceShortcut", p) };
let key = crate::shortcuts::binding_key("window.workspace", &json!({"name": ws}));
reassign(s, &key, &slot, "window.assignWorkspaceShortcut")
}
// ---------------------------------------------------------------- Help
fn log_file(s: &Session) -> std::path::PathBuf {
crate::logging::log_path(s.config.as_ref().and_then(|c| c.dir()).as_deref())
}
fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
let on = b_p(p, "on").unwrap_or(!crate::logging::is_enabled());
let file = log_file(s);
crate::logging::set_enabled(on, file.clone()).map_err(|e| EngineError::Other(format!("cannot write {}: {e}", file.display())))?;
s.prefs_revision += 1;
s.toast(if on { format!("Logging to {}", file.display()) } else { "Logging off".into() });
Ok(json!({"enabled": on, "path": file.to_string_lossy()}))
}
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
let path = file.to_string_lossy().to_string();
let url = super::layer_menu::folder_url(&path);
s.events.push(Event::OpenUrl(url.clone()));
Ok(json!({"path": path, "url": url}))
}
/// Media formats File ▸ Import reads (with the desktop host's decoders).
pub const IMPORT_FORMATS: &[&str] = &[
"MP4/MOV (H.264, HEVC, ProRes, AV1)",
"WebM/MKV (VP8, VP9, AV1)",
"PNG",
"JPEG",
"GIF",
"WebP",
"TIFF",
"BMP",
"OpenEXR",
"Photoshop (PSD)",
"SVG",
"WAV",
"AIFF",
"MP3",
"FLAC",
"AAC",
"Opus",
"glTF/GLB",
"OBJ",
"Lottie JSON",
];
/// Help ▸ System Compatibility Report: OS, CPU, memory, GPU adapter, import/export formats.
pub fn system_report(s: &Session) -> Value {
let mem = crate::sysinfo::memory();
let gb = |b: u64| format!("{:.1} GB", b as f64 / (1u64 << 30) as f64);
let export: Vec<&str> = s.exporter.as_ref().map(|e| e.formats().into_iter().map(|f| f.label()).collect()).unwrap_or_default();
let gpu = s.accel.as_ref().map(|a| a.name());
let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory));
let mut issues = vec![];
if gpu.is_none() {
issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string());
}
if mem.is_some_and(|m| m.total < 8 << 30) {
issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string());
}
if s.exporter.is_none() {
issues.push("Export is not available in this build.".to_string());
}
json!({
"app": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")),
"os": crate::sysinfo::os_version(),
"arch": std::env::consts::ARCH,
"cpu": crate::sysinfo::cpu_name(),
"cores": crate::sysinfo::cpu_cores(),
"memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})),
"gpu": gpu,
"caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)},
"import": IMPORT_FORMATS,
"export": export,
"logging": crate::logging::is_enabled(),
"recentWarnings": crate::logging::recent().into_iter().rev().take(20).collect::<Vec<_>>(),
"issues": issues,
})
}
/// The report as text (the dialog and `--` CLI output).
pub fn report_text(r: &Value) -> String {
let st = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("unknown").to_string();
let list = |k: &str| r.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")).unwrap_or_default();
let mut t = format!(
"{}\nOperating system: {} ({})\nCPU: {} ({} logical cores)\n",
st("app"),
st("os"),
st("arch"),
st("cpu"),
r.get("cores").and_then(Value::as_u64).unwrap_or(1)
);
match r.get("memory").filter(|m| !m.is_null()) {
Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))),
None => t.push_str("Memory: unknown\n"),
}
t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)")));
let c = &r["caches"];
t.push_str(&format!(
"Cache budgets: layer {}, footage {}, preview {}\n",
c["layer"].as_str().unwrap_or("?"),
c["footage"].as_str().unwrap_or("?"),
c["preview"].as_str().unwrap_or("?")
));
t.push_str(&format!("Import: {}\nExport: {}\n", list("import"), list("export")));
let issues = list("issues");
t.push_str(&if issues.is_empty() { "No compatibility issues found.".to_string() } else { format!("Issues: {issues}") });
t
}
fn report(s: &mut Session, p: &Value) -> Result<Value> {
let r = system_report(s);
if !b_p(p, "quiet").unwrap_or(false) {
s.events.push(Event::Frontend { command: "app.showReport".into(), params: json!({"title": "System Compatibility Report", "text": report_text(&r)}) });
}
Ok(r)
}
// ---------------------------------------------------------------- Composition ▸ VR
/// Orientation (degrees, AE order) of a camera whose +X, +Y (down) and +Z axes point along the
/// given world directions: searched over quarter turns, which is all the cube faces need.
fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] {
let mut best = ([0.0; 3], f64::MAX);
for a in 0..4 {
for b in 0..4 {
for c in 0..4 {
let o = vec3(a as f64 * 90.0, b as f64 * 90.0, c as f64 * 90.0);
let m = Mat4::orientation(o);
let err = (m.apply_vec(vec3(1.0, 0.0, 0.0)) - x).length()
+ (m.apply_vec(vec3(0.0, 1.0, 0.0)) - y).length()
+ (m.apply_vec(vec3(0.0, 0.0, 1.0)) - z).length();
if err < best.1 {
best = ([o.x, o.y, o.z], err);
}
}
}
}
best.0
}
/// Cube faces in the VR Converter's order (+X, −X, +Y, −Y, +Z, −Z): name, forward, right and up
/// in the VR convention (X right, Y up, Z front).
const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [
("Right", [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
("Left", [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
("Top", [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]),
("Bottom", [0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
("Front", [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
("Back", [0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
];
/// (column, row) of each face in the 3:2 cube map.
const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)];
/// VR world direction → After Effects world (Y down).
fn ae(v: [f64; 3]) -> Vec3 {
vec3(v[0], -v[1], v[2])
}
/// Composition ▸ VR ▸ Create VR Environment: renders the active comp's 3D scene in every
/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, with a
/// copy of the scene's layers), a 3:2 cube map of them, and an equirectangular output comp
/// (VR Converter, cube map → equirectangular). One undo step.
fn create_vr_environment(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let size = p.get("size").and_then(Value::as_u64).unwrap_or(1024).clamp(64, 8192);
let center = match p.get("position").and_then(Value::as_array) {
Some(a) => [0, 1, 2].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)),
None => [src.width as f64 / 2.0, src.height as f64 / 2.0, 0.0],
};
let fps = src.frame_rate.as_f64();
let dur = src.duration.seconds();
let r = grouped(s, "Create VR Environment", |s| {
let mut faces = vec![];
for (fname, fw, r, u) in FACES {
let fc = id_of(
&s.execute(
"comp.new",
json!({"name": format!("{name} VR {fname}"), "width": size, "height": size, "frameRate": fps, "duration": dur, "open": false}),
)?,
"comp",
)?;
let o = orientation_for_axes(ae(r), ae([-u[0], -u[1], -u[2]]), ae(fw));
let cam =
id_of(&s.execute("layer.newCamera", json!({"comp": fc, "name": format!("{fname} Camera"), "angleOfView": 90.0, "type": "oneNode"}))?, "layer")?;
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/position", "value": center}))?;
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/orientation", "value": o}))?;
// The scene's layers (not its cameras), in the scene's coordinates, under the face
// camera.
let layers: Vec<effectcraft_project::Layer> = src.layers.iter().filter(|l| !l.is_camera()).cloned().collect();
s.edit("Create VR Environment", None, |proj, _| {
let mut next = proj.next_id;
let mut map = std::collections::BTreeMap::new();
let mut copies = vec![];
for l in &layers {
let mut c = l.clone();
super::edit::reid(&mut c, &mut next);
map.insert(l.id, c.id);
copies.push(c);
}
for c in &mut copies {
c.parent = c.parent.and_then(|p| map.get(&p).copied());
if let Some(m) = &mut c.track_matte {
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
}
}
proj.next_id = next;
let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?;
comp.layers.extend(copies);
Ok(())
})?;
faces.push(fc);
}
let cube = id_of(
&s.execute(
"comp.new",
json!({"name": format!("{name} VR Cube Map"), "width": size * 3, "height": size * 2, "frameRate": fps, "duration": dur, "open": false}),
)?,
"comp",
)?;
for (i, fc) in faces.iter().enumerate() {
let l = id_of(&s.execute("layer.addItem", json!({"comp": cube, "item": fc, "time": 0.0}))?, "layer")?;
let (c, rr) = CELLS_3X2[i];
let pos = [(c as f64 + 0.5) * size as f64, (rr as f64 + 0.5) * size as f64];
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "transform/position", "value": pos}))?;
}
let out = id_of(
&s.execute(
"comp.new",
json!({"name": format!("{name} VR Output"), "width": size * 2, "height": size, "frameRate": fps, "duration": dur, "open": false}),
)?,
"comp",
)?;
let l = id_of(&s.execute("layer.addItem", json!({"comp": out, "item": cube, "time": 0.0}))?, "layer")?;
s.execute("prop.set", json!({"comp": out, "layer": l, "path": "transform/scale", "value": [200.0 / 3.0, 50.0, 100.0]}))?;
s.execute("effect.apply", json!({"comp": out, "layers": [l], "effect": "ec.vr.converter"}))?;
s.execute("prop.set", json!({"comp": out, "layer": l, "path": "effects/#1/sourceProjection", "value": 2}))?;
s.execute("prop.set", json!({"comp": out, "layer": l, "path": "effects/#1/targetProjection", "value": 0}))?;
Ok(json!({"faces": faces, "cubeMap": cube, "output": out}))
})?;
s.open_comp(ItemId(r["output"].as_u64().unwrap_or(0)));
Ok(r)
}
/// Composition ▸ VR ▸ Extract Cubemap: a 3:2 cube-map comp of an equirectangular comp (VR
/// Converter, equirectangular → cube map), for editing the faces. One undo step.
fn extract_cubemap(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let face = p.get("faceSize").and_then(Value::as_u64).unwrap_or((src.width / 4).max(16) as u64).clamp(16, 8192);
let (w, h) = (face * 3, face * 2);
let r = grouped(s, "Extract Cubemap", |s| {
let cube = id_of(
&s.execute(
"comp.new",
json!({"name": format!("{name} Cubemap"), "width": w, "height": h, "frameRate": src.frame_rate.as_f64(), "duration": src.duration.seconds(), "open": false}),
)?,
"comp",
)?;
let l = id_of(&s.execute("layer.addItem", json!({"comp": cube, "item": cid.0, "time": 0.0}))?, "layer")?;
let scale = [w as f64 / src.width.max(1) as f64 * 100.0, h as f64 / src.height.max(1) as f64 * 100.0, 100.0];
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "transform/scale", "value": scale}))?;
s.execute("effect.apply", json!({"comp": cube, "layers": [l], "effect": "ec.vr.converter"}))?;
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "effects/#1/sourceProjection", "value": 0}))?;
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "effects/#1/targetProjection", "value": 2}))?;
Ok(json!({"cubeMap": cube, "layer": l, "faceSize": face}))
})?;
s.open_comp(ItemId(r["cubeMap"].as_u64().unwrap_or(0)));
Ok(r)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history),
cmd!("file.importRecent", "Import Recent Footage", [], None, "{index? | path?}", has_recent_footage, import_recent),
cmd!("file.clearRecentFootage", "Clear Recent Footage", ["File", "Import Recent Footage"], None, "{}", has_recent_footage, clear_recent_footage),
cmd!("anim.applyRecentPreset", "Recent Animation Presets", [], None, "{index? | path?, layers?}", has_recent_presets, apply_recent_preset),
cmd!(
"anim.clearRecentPresets",
"Clear Recent Presets",
["Animation", "Recent Animation Presets"],
None,
"{}",
|s| if s.prefs.recent_presets.is_empty() { Err("no recent animation presets".into()) } else { Ok(()) },
clear_recent_presets
),
cmd!("file.saveCopyAsXml", "Save a Copy As XML...", ["File", "Save As"], None, "{path: .ecprojx}", always, save_copy_xml),
cmd!("file.replaceWithLayeredComp", "With Layered Comp", ["File", "Replace Footage"], None, "{item?}", has_footage, replace_with_layered_comp),
cmd!("file.executeFile", "Execute File", [], None, "{path, confirmed?}", always, execute_file),
cmd!("view.assign3dShortcut", "Assign Shortcut to 3D View", [], None, "{slot: F10|F11|F12, comp?}", has_comp, assign_view_shortcut),
cmd!(
"window.assignWorkspaceShortcut",
"Assign Shortcut to Workspace",
[],
None,
"{slot: Shift+F10|Shift+F11|Shift+F12, workspace?}",
always,
assign_workspace_shortcut
),
cmd!("help.enableLogging", "Enable Logging", ["Help"], None, "{on?}", always, enable_logging),
cmd!("help.revealLogFile", "Reveal Logging File", ["Help"], None, "{}", always, reveal_log),
cmd!("help.systemReport", "System Compatibility Report...", ["Help"], None, "{quiet?}", always, report),
cmd!(
"comp.vr.createEnvironment",
"Create VR Environment...",
["Composition", "VR"],
None,
"{comp?, size?: face pixels (1024), position?: [x,y,z]}",
has_comp,
create_vr_environment
),
cmd!("comp.vr.extractCubemap", "Extract Cubemap...", ["Composition", "VR"], None, "{comp?, faceSize?}", has_comp, extract_cubemap),
query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))),
]
}

View File

@ -384,6 +384,8 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
// Settings ▸ General ▸ Create Split Layers Above Original Layer.
let above = s.prefs.general.create_split_layers_above;
let new = s.edit("Split Layer", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
@ -398,7 +400,7 @@ fn split(s: &mut Session, p: &Value) -> Result<Value> {
b.in_point = t;
comp.layers[i].out_point = t;
created.push(b.id);
comp.layers.insert(i, b);
comp.layers.insert(if above { i } else { i + 1 }, b);
}
proj.next_id = next;
st.selected_layers = created.clone();
@ -408,6 +410,11 @@ fn split(s: &mut Session, p: &Value) -> Result<Value> {
}
fn label(s: &mut Session, p: &Value) -> Result<Value> {
// Keyframe colour labels: selected keyframes take the label (unless layers are named).
let explicit_layers = p.get("layers").is_some() || p.get("layer").is_some();
if p.get("keys").is_some() || (!s.state.selected_keys.is_empty() && !explicit_layers && str_p(p, "target") != Some("layers")) {
return super::keys_more::label_keys(s, p);
}
let name = str_p(p, "label").unwrap_or("Red");
let lab = s.prefs.label_from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?;
let (cid, ids) = layers_p(s, p)?;
@ -624,7 +631,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all),
cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always_ok, deselect_all),
cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?}", always_ok, label),
cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?, keys?, target?: layers}", always_ok, label),
cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group),
cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge),
cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches),

View File

@ -40,6 +40,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
// XML copies (File ▸ Save a Copy As XML…) open like the JSON project.
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
let proj = Project::from_json(&text)?;
s.replace_project(proj, Some(path.to_string()));
s.note_project_path(path);
@ -48,6 +50,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
fn save_to(s: &mut Session, path: &str) -> Result<Value> {
let json = s.project.to_json();
// A project opened from an XML copy saves as XML again.
let json = if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other)? } else { json };
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.path = Some(path.to_string());
s.saved_revision = s.revision;
@ -78,14 +82,20 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
open(s, &json!({"path": path}))
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
let paths: Vec<String> = match p.get("paths").or(p.get("path")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(Value::String(x)) => vec![x.clone()],
_ => return Err(bad("file.import", "missing `paths`")),
};
// Photoshop documents as compositions (Import As: Composition / – Retain Layer Sizes).
let import_as = str_p(p, "importAs").unwrap_or("footage");
// Files dragged in follow Settings ▸ Import ▸ Default Drag Import As.
let drag = p.get("drag").and_then(Value::as_bool).unwrap_or(false);
let import_as = str_p(p, "importAs").unwrap_or(if drag { s.prefs.drag_import_as() } else { "footage" });
for path in &paths {
s.prefs.push_recent_footage(path);
}
s.save_prefs();
let retain = match import_as {
"footage" => None,
"composition" | "comp" => Some(false),
@ -126,6 +136,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) {
@ -141,6 +153,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;
@ -165,6 +191,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());
@ -178,8 +205,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(())
@ -187,7 +218,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).
@ -201,7 +269,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;
@ -381,7 +449,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer)}",
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
always,
import
),

View File

@ -0,0 +1,251 @@
//! Keyframe features of M14.5: Animation ▸ Keyframe Assistant ▸ Convert Audio to Keyframes and
//! RPF Camera Import, and Edit ▸ Label on selected keyframes.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::LayerId;
use effectcraft_project::build::Ids;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::app_more::grouped;
use super::{CommandSpec, bad, comp_id, has_comp, layer_mut, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd};
// ---------------------------------------------------------------- keyframe labels
/// The label index (0 = None, 1–16) of a label name (built-in or renamed in Settings ▸ Labels).
fn label_index(s: &Session, name: &str) -> Option<u8> {
let l = if name.eq_ignore_ascii_case("none") { Label::None } else { s.prefs.label_from_name(name)? };
Label::ALL.iter().position(|x| *x == l).map(|i| i as u8)
}
fn keys_p(s: &Session, p: &Value) -> Vec<KeyRef> {
match p.get("keys") {
Some(Value::Array(a)) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
_ => s.state.selected_keys.clone(),
}
}
/// Edit ▸ Label on keyframes: colour the selected (or given) keyframes (label names as renamed
/// in Settings ▸ Labels; `keys.setLabel` is the Timeline context menu's command).
pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "label").unwrap_or("Red");
let idx = label_index(s, name).ok_or_else(|| bad("keys.label", format!("unknown label `{name}`")))?;
let keys = keys_p(s, p);
if keys.is_empty() {
return Err(bad("keys.label", "select keyframes first"));
}
let cid = comp_id(s, p)?;
let n = s.edit("Keyframe Label", None, |proj, _| {
let mut n = 0;
for k in &keys {
let Ok(l) = layer_mut(proj, cid, k.layer) else { continue };
let Some(pr) = l.props.find_mut(k.prop) else { continue };
for key in pr.keys.iter_mut().filter(|x| x.time == k.time) {
key.label = idx;
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"keys": n, "label": idx}))
}
// ---------------------------------------------------------------- Convert Audio to Keyframes
/// Per-frame amplitude of interleaved stereo: (left, right, both), mean absolute sample × 100.
pub fn amplitudes(buf: &[f32]) -> (f64, f64, f64) {
let n = (buf.len() / 2).max(1) as f64;
let (mut l, mut r) = (0.0f64, 0.0f64);
for c in buf.chunks_exact(2) {
l += c[0].abs() as f64;
r += c[1].abs() as f64;
}
let (l, r) = (l / n * 100.0, r / n * 100.0);
(l, r, (l + r) / 2.0)
}
/// Animation ▸ Keyframe Assistant ▸ Convert Audio to Keyframes: an "Audio Amplitude" null with
/// Left Channel, Right Channel and Both Channels sliders keyed on every frame of the work area
/// with the amplitude of the comp's audible layers. One undo step.
fn audio_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let fr = comp.frame_rate;
let (a, b) = comp.work_area;
let (f0, f1) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(fr.snap_nearest(b)).max(fr.frame_at(fr.snap_nearest(a)) + 1));
const RATE: u32 = 48_000;
let per = ((RATE as f64 / fr.as_f64()).round() as usize).max(1);
let mut rows = vec![];
for f in f0..f1 {
let t = fr.tick_of(f);
let buf = effectcraft_render::audio::mix_comp(&s.project, s.footage.as_ref(), s.expr.as_deref(), cid, t, per, RATE);
rows.push((t, amplitudes(&buf)));
}
let spec = effectcraft_effects::lookup("ec.control.slider").ok_or_else(|| EngineError::Other("Slider Control is missing".into()))?;
let peak = rows.iter().map(|(_, (_, _, both))| *both).fold(0.0, f64::max);
let r = grouped(s, "Convert Audio to Keyframes", |s| {
let nl = s.execute("layer.newNull", json!({"comp": cid.0, "name": "Audio Amplitude"}))?["layer"].as_u64().unwrap_or(0);
let lid = LayerId(nl);
s.edit("Convert Audio to Keyframes", None, |proj, st| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let start = l.start_time;
let fx = l.props.sub_mut("effects").ok_or_else(|| bad("keys.audioToKeyframes", "the null has no effects group"))?;
for (i, name) in ["Left Channel", "Right Channel", "Both Channels"].into_iter().enumerate() {
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), name, [comp.width as f64, comp.height as f64]);
if let Some(pr) = g.get_mut("slider") {
pr.keys = rows
.iter()
.map(|(t, amp)| {
let v = [amp.0, amp.1, amp.2][i];
Keyframe::new(*t - start, KV::Scalar((v * 100.0).round() / 100.0))
})
.collect();
}
fx.children.push(g.into());
}
proj.next_id = next;
st.selected_layers = vec![lid];
Ok(())
})?;
Ok(json!({"layer": nl, "frames": rows.len(), "peak": peak}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- RPF Camera Import
/// One camera sample: time, position, orientation (degrees, X/Y/Z) and zoom (px).
#[derive(Clone, Debug, PartialEq)]
pub struct CamSample {
pub time: Tick,
pub position: [f64; 3],
pub orientation: [f64; 3],
pub zoom: Option<f64>,
}
fn zoom_of(fov: Option<f64>, zoom: Option<f64>, comp_w: f64) -> Option<f64> {
zoom.or_else(|| fov.map(|a| comp_w * 0.5 / (a.clamp(0.1, 179.0).to_radians() * 0.5).tan()))
}
/// Parse camera data: JSON `{frameRate?, frames: [{frame | time, position, orientation |
/// rotation, zoom? | fov?}]}` or CSV with a header naming `frame`/`time`, `px py pz`
/// (or `x y z`), `rx ry rz` and `zoom`/`fov` columns.
pub fn parse_camera(text: &str, rate: effectcraft_time::FrameRate, comp_w: f64) -> std::result::Result<Vec<CamSample>, String> {
let t = text.trim_start();
if t.starts_with('{') || t.starts_with('[') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("camera JSON: {e}"))?;
let rate = v.get("frameRate").and_then(Value::as_f64).map(effectcraft_time::FrameRate::from_f64).unwrap_or(rate);
let frames = v.get("frames").or(Some(&v)).and_then(Value::as_array).ok_or("camera JSON: expected `frames`")?;
let v3 = |x: Option<&Value>| -> Option<[f64; 3]> {
let a = x?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
};
return frames
.iter()
.enumerate()
.map(|(i, f)| {
let time = match (f.get("time").and_then(Value::as_f64), f.get("frame").and_then(Value::as_i64)) {
(Some(s), _) => Tick::from_seconds_f64(s),
(None, Some(n)) => rate.tick_of(n),
_ => rate.tick_of(i as i64),
};
Ok(CamSample {
time,
position: v3(f.get("position")).ok_or(format!("camera JSON: frame {i} needs `position` [x,y,z]"))?,
orientation: v3(f.get("orientation").or(f.get("rotation"))).unwrap_or([0.0; 3]),
zoom: zoom_of(f.get("fov").and_then(Value::as_f64), f.get("zoom").and_then(Value::as_f64), comp_w),
})
})
.collect();
}
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#'));
let head: Vec<String> = lines.next().ok_or("camera CSV: empty")?.split([',', ';', '\t']).map(|h| h.trim().to_ascii_lowercase()).collect();
let col = |names: &[&str]| head.iter().position(|h| names.contains(&h.as_str()));
let (cf, ct) = (col(&["frame", "f"]), col(&["time", "seconds", "t"]));
let pos = [col(&["px", "x", "posx", "position x"]), col(&["py", "y", "posy", "position y"]), col(&["pz", "z", "posz", "position z"])];
let rot = [col(&["rx", "rotx", "orientation x", "tilt"]), col(&["ry", "roty", "orientation y", "pan"]), col(&["rz", "rotz", "orientation z", "roll"])];
let (cz, cfov) = (col(&["zoom"]), col(&["fov", "angle", "angleofview"]));
if pos.iter().any(Option::is_none) {
return Err("camera CSV: needs px, py, pz (or x, y, z) columns".into());
}
lines
.enumerate()
.map(|(i, l)| {
let cells: Vec<f64> = l.split([',', ';', '\t']).map(|c| c.trim().parse().unwrap_or(f64::NAN)).collect();
let get = |c: Option<usize>| c.and_then(|c| cells.get(c).copied()).filter(|v| v.is_finite());
let time = match (get(ct), get(cf)) {
(Some(s), _) => Tick::from_seconds_f64(s),
(None, Some(n)) => rate.tick_of(n as i64),
_ => rate.tick_of(i as i64),
};
Ok(CamSample {
time,
position: pos.map(|c| get(c).unwrap_or(0.0)),
orientation: rot.map(|c| get(c).unwrap_or(0.0)),
zoom: zoom_of(get(cfov), get(cz), comp_w),
})
})
.collect()
}
/// Animation ▸ Keyframe Assistant ▸ RPF Camera Import: a camera layer keyed from camera data.
/// RPF/RLA files carry their camera in an undocumented block, so EffectCraft reads the same data
/// from JSON or CSV exports (see `docs/preferences.md` ▸ RPF Camera Import). One undo step.
fn rpf_camera_import(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("keys.rpfCameraImport", "missing `path` (.json or .csv camera data)"))?;
let lower = path.to_ascii_lowercase();
if lower.ends_with(".rpf") || lower.ends_with(".rla") {
return Err(bad(
"keys.rpfCameraImport",
"the camera block of RPF/RLA files is not publicly documented: export the camera from your 3D application as JSON or CSV (frame, px, py, pz, rx, ry, rz, zoom|fov)",
));
}
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let samples = parse_camera(&String::from_utf8_lossy(&bytes), comp.frame_rate, comp.width as f64).map_err(|e| bad("keys.rpfCameraImport", e))?;
if samples.is_empty() {
return Err(bad("keys.rpfCameraImport", "no camera samples"));
}
let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "RPF Camera".into());
grouped(s, "RPF Camera Import", |s| {
let cam = s.execute("layer.newCamera", json!({"comp": cid.0, "name": name, "type": "oneNode"}))?["layer"].as_u64().unwrap_or(0);
let lid = LayerId(cam);
s.edit("RPF Camera Import", None, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let start = l.start_time;
let keyed = |vals: Vec<(Tick, KV)>| -> Vec<Keyframe> { vals.into_iter().map(|(t, v)| Keyframe::new(t - start, v)).collect() };
if let Some(pr) = l.props.prop_mut("transform/position") {
pr.keys = keyed(samples.iter().map(|c| (c.time, KV::Vec3(c.position))).collect());
}
if let Some(pr) = l.props.prop_mut("transform/orientation") {
pr.keys = keyed(samples.iter().map(|c| (c.time, KV::Vec3(c.orientation))).collect());
}
if samples.iter().any(|c| c.zoom.is_some())
&& let Some(pr) = l.props.prop_mut("cameraOptions/zoom")
{
pr.keys = keyed(samples.iter().filter_map(|c| Some((c.time, KV::Scalar(c.zoom?)))).collect());
}
Ok(())
})?;
Ok(json!({"layer": cam, "keyframes": samples.len()}))
})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], None, "{comp?}", has_comp, audio_to_keys),
cmd!(
"keys.rpfCameraImport",
"RPF Camera Import",
["Animation", "Keyframe Assistant"],
None,
"{path: .json|.csv camera data, comp?}",
has_comp,
rpf_camera_import
),
]
}

View File

@ -826,12 +826,13 @@ fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
_ => ShapePath::rect([cx, cy], rw, rh),
};
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let cycle = s.prefs.appearance.cycle_mask_colors;
let uid = s.edit("New Mask", None, |proj, _| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.addMask", "this layer can't have masks"))?;
let n = masks.children.len();
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, build::MASK_COLORS[n % build::MASK_COLORS.len()]);
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, crate::prefs::mask_color(cycle, n));
let uid = g.uid;
masks.children.push(g.into());
proj.next_id = next;
@ -913,6 +914,11 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setText")?;
// The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display).
if let Some(f) = str_p(p, "font") {
s.prefs.push_recent_font(f);
s.save_prefs();
}
let t = s.time();
let range = text_range_p(p);
// Character attributes with only a caret (no selected text) set the insertion style.

View File

@ -652,33 +652,38 @@ fn reveal_comp(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"item": cid.0}))
}
fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = s.time();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut errors = vec![];
for l in &comp.layers {
let mut has = vec![];
l.props.walk("", &mut |_, pr| {
if pr.has_expression() {
has.push(pr);
}
});
for pr in has {
let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default();
let err = match &s.expr {
Some(host) => {
let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
ctx.expr = Some(host.as_ref());
host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))).err()
impl Session {
/// Expressions of comp `cid` that fail at its current time: `{layer, prop, name, expression,
/// error}` (Reveal Expression Errors, the viewer's expression error banner).
pub fn expression_errors(&self, cid: effectcraft_project::ItemId) -> Vec<Value> {
let t = self.time_of(cid);
let Some(comp) = self.project.comp(cid) else { return vec![] };
let Some(host) = &self.expr else { return vec![] };
let mut errors = vec![];
for l in &comp.layers {
let mut has = vec![];
l.props.walk("", &mut |_, pr| {
if pr.has_expression() {
has.push(pr);
}
});
for pr in has {
let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default();
let mut ctx = effectcraft_render::EvalCtx::new(&self.project, cid, comp, t);
ctx.expr = Some(host.as_ref());
if let Err(e) = host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))) {
errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e}));
}
None => None,
};
if let Some(e) = err {
errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e}));
}
}
errors
}
}
fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.project.comp(cid).ok_or(EngineError::NoComp)?;
let errors = s.expression_errors(cid);
let props: Vec<Value> = errors.iter().map(|e| json!({"layer": e["layer"], "prop": e["prop"]})).collect();
frontend(s, "timeline.revealProps", &json!({"props": props}))?;
if errors.is_empty() {

View File

@ -101,7 +101,7 @@ fn quote(s: &str) -> String {
}
/// The AE expression reference to property `uid` of `target` as seen from a property of `from`.
pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) -> Option<String> {
pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option<String> {
let chain = target.props.node_chain(uid)?;
let mut parts: Vec<String> = vec![];
let mut pending: Option<&str> = None;
@ -125,6 +125,15 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) ->
}
let mut out = if from.id == target.id { String::new() } else { format!("thisComp.layer({}).", quote(&target.name)) };
let _ = comp;
if !compact {
// Match names: language-independent, like After Effects with Compact English off.
let chain = target.props.node_chain(uid)?;
let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) };
for n in chain {
out.push_str(&format!("({})", quote(n.match_id())));
}
return Some(out);
}
for (i, p) in parts.iter().enumerate() {
if i > 0 && !p.starts_with('(') {
out.push('.');
@ -152,7 +161,7 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let from = comp.layer(lid).ok_or(EngineError::NoComp)?;
let target = comp.layer(tl).ok_or(EngineError::NoComp)?;
let r = reference(comp, from, target, tu).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
let (dm, dt) = (dims(from, me), dims(target, them));
let text = if dt == 1 && dm > 1 {

View File

@ -54,9 +54,76 @@ pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&'
/// Edit the mask path of (layer, mask) at the current time.
fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<T>) -> Result<(Uid, T)> {
edit_path_counted(s, p, label, |sp| f(sp).map(|r| (r, None)))
}
/// A change of a path's vertex count, repeated on the other keyframes of an animated path when
/// Settings ▸ General ▸ Preserve Constant Vertex and Feather Point Count is on.
#[derive(Clone, Debug)]
pub(crate) enum CountOp {
/// A vertex was inserted at this index.
Insert(usize),
/// These vertices were removed.
Remove(Vec<usize>),
}
/// Apply a vertex-count change to another keyframe's path: an inserted vertex splits the
/// matching segment in half (the shape doesn't change), removed vertices go.
pub(crate) fn apply_count_op(sp: &mut ShapePath, op: &CountOp) {
match op {
CountOp::Insert(at) => {
let n = sp.vertices.len();
if n == 0 {
return;
}
if *at > 0 && split_segment(sp, at - 1, 0.5).is_some() {
return;
}
let at = (*at).min(n);
let v = sp.vertices[at.min(n - 1)];
sp.in_tangents.resize(n, [0.0; 2]);
sp.out_tangents.resize(n, [0.0; 2]);
sp.vertices.insert(at, v);
sp.in_tangents.insert(at, [0.0; 2]);
sp.out_tangents.insert(at, [0.0; 2]);
}
CountOp::Remove(idx) => {
let mut idx = idx.clone();
idx.sort_unstable();
idx.dedup();
for &i in idx.iter().rev() {
if i < sp.vertices.len() {
sp.vertices.remove(i);
if i < sp.in_tangents.len() {
sp.in_tangents.remove(i);
}
if i < sp.out_tangents.len() {
sp.out_tangents.remove(i);
}
}
}
if sp.vertices.len() < 3 {
sp.closed = false;
}
}
}
}
/// Repeat `op` on every keyframe of `pr` other than the one at `lt`.
fn sync_keys(pr: &mut effectcraft_project::Property, lt: effectcraft_time::Tick, op: &CountOp) {
for k in pr.keys.iter_mut().filter(|k| k.time != lt) {
if let KV::Path(sp) = &mut k.value {
apply_count_op(sp, op);
}
}
}
/// [`edit_path`] whose edit may change the vertex count (`f` says how).
fn edit_path_counted<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<(T, Option<CountOp>)>) -> Result<(Uid, T)> {
let (cid, lid) = layer_p(s, p, label)?;
let key = p.get("mask").cloned().ok_or_else(|| bad(label, "missing `mask` (uid, index or name)"))?;
let t = s.time();
let preserve = s.prefs.general.preserve_constant_vertex_count;
s.edit(label, merge_p(p), |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
@ -65,11 +132,14 @@ fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha
let roto = super::paths::is_roto(g);
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) };
let r = f(&mut sp)?;
let (r, op) = f(&mut sp)?;
if roto {
super::paths::roto_smooth(&mut sp);
}
pr.set_value_at(lt, KV::Path(sp));
if let Some(op) = op.filter(|_| preserve) {
sync_keys(pr, lt, &op);
}
Ok((uid, r))
})
}
@ -88,12 +158,13 @@ fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
outs.resize(n, [0.0; 2]);
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let cycle = s.prefs.appearance.cycle_mask_colors;
let uid = s.edit("New Mask", None, |proj, st| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.new", "this layer can't have masks"))?;
let k = masks.children.len();
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, build::MASK_COLORS[k % build::MASK_COLORS.len()]);
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, crate::prefs::mask_color(cycle, k));
let uid = g.uid;
masks.children.push(g.into());
proj.next_id = next;
@ -109,13 +180,13 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
let tout = pt(p.get("out")).unwrap_or([0.0; 2]);
let at = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
let (_, lid) = layer_p(s, p, "mask.addVertex")?;
let (uid, i) = edit_path(s, p, "mask.addVertex", |sp| {
let (uid, i) = edit_path_counted(s, p, "mask.addVertex", |sp| {
let i = at.unwrap_or(sp.vertices.len()).min(sp.vertices.len());
sp.vertices.insert(i, point);
sp.in_tangents.insert(i, tin);
sp.out_tangents.insert(i, tout);
sp.feather_after_insert(i, None);
Ok(i)
Ok((i, Some(CountOp::Insert(i))))
})?;
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
Ok(json!(i))
@ -197,9 +268,16 @@ fn has_vertices(s: &Session) -> std::result::Result<(), String> {
/// Apply `f` to the selected vertices' paths (each mask once).
fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapePath, &[usize])) -> Result<Value> {
edit_vertices_counted(s, p, label, false, f)
}
/// [`edit_vertices`]; `removes`: `f` deletes the given vertices (repeated on the other keyframes
/// when Preserve Constant Vertex Count is on).
fn edit_vertices_counted(s: &mut Session, p: &Value, label: &str, removes: bool, f: impl Fn(&mut ShapePath, &[usize])) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let groups = vertex_groups(s, p)?;
let t = s.time();
let preserve = removes && s.prefs.general.preserve_constant_vertex_count;
s.edit(label, merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for ((l, m), idx) in &groups {
@ -214,6 +292,9 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP
super::paths::roto_smooth(&mut sp);
}
pr.set_value_at(lt, KV::Path(sp));
if preserve {
sync_keys(pr, lt, &CountOp::Remove(idx.clone()));
}
}
Ok(())
})?;
@ -233,7 +314,7 @@ fn move_vertices(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
let n = edit_vertices(s, p, "Delete Mask Vertices", |sp, idx| {
let n = edit_vertices_counted(s, p, "Delete Mask Vertices", true, |sp, idx| {
let mut idx = idx.to_vec();
idx.sort_unstable();
idx.dedup();
@ -525,7 +606,10 @@ fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize;
let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0);
let (_, lid) = layer_p(s, p, "mask.insertVertex")?;
let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?;
let (uid, i) = edit_path_counted(s, p, "Add Vertex", |sp| {
let i = split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment"))?;
Ok((i, Some(CountOp::Insert(i))))
})?;
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
Ok(json!(i))
}

View File

@ -4,6 +4,7 @@
mod anim;
pub mod anim_tools;
mod animation;
mod app_more;
mod camera_cmds;
mod comp;
mod comp_more;
@ -19,6 +20,7 @@ mod frontend;
mod help;
mod key_labels;
mod key_transform;
mod keys_more;
mod layer;
mod layer_menu;
mod layer_time;
@ -53,6 +55,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,6 +164,8 @@ 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());

View File

@ -184,7 +184,7 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
enabled = Some(false);
}
// Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`).
let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid))).unwrap_or_else(|| "value".into());
let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid, true))).unwrap_or_else(|| "value".into());
let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| {
match (&text, enabled) {
(Some(t), _) if t.trim().is_empty() => pr.expr = None,

View File

@ -499,8 +499,15 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
let mut item = RenderQueueItem::new(0, cid);
item.output = OutputModule::for_format(format);
item.output.output = path.clone();
let job =
crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), accel: s.accel.as_deref(), item: &item, path: &path };
let job = crate::ExportJob {
project: &s.project,
footage: s.footage.as_ref(),
expr: s.expr.as_deref(),
accel: s.accel.as_deref(),
item: &item,
path: &path,
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
s.toast(format!("Saved preview to {}", r.path));
Ok(serde_json::to_value(&r).unwrap_or_default())

View File

@ -26,8 +26,6 @@ pub fn specs() -> Vec<CommandSpec> {
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}"),
]

View File

@ -14,7 +14,9 @@ pub mod commands;
pub mod config;
pub mod demo;
pub mod links;
pub mod logging;
pub mod mask_track;
pub mod media_cache;
pub mod menus;
pub mod offload;
pub mod prefs;
@ -23,10 +25,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;
@ -320,6 +324,11 @@ pub struct Session {
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
pub script: Option<ScriptRunner>,
/// 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>>,
@ -379,6 +388,8 @@ impl Default for Session {
autosave: autosave::AutoSaveState::default(),
snapshot: None,
script: None,
sys_memory: None,
applied_nested_switches: None,
offload: None,
offloaded: vec![],
}
@ -548,6 +559,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 +641,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);
@ -704,6 +757,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;
@ -747,6 +802,68 @@ pub fn text_presets() -> Vec<(String, String)> {
pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
/// 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)]

View File

@ -0,0 +1,99 @@
//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends
//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder),
//! and always keeps the last few hundred warnings and errors in memory for the System
//! Compatibility Report.
//!
//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging`
//! command) turns file logging on and off.
use std::collections::VecDeque;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
static ENABLED: AtomicBool = AtomicBool::new(false);
static STATE: Mutex<State> = Mutex::new(State { file: None, recent: VecDeque::new() });
struct State {
file: Option<PathBuf>,
recent: VecDeque<String>,
}
struct Logger;
const RECENT: usize = 300;
impl log::Log for Logger {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft"))
}
fn log(&self, r: &log::Record) {
if !self.enabled(r.metadata()) {
return;
}
let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args());
let Ok(mut st) = STATE.lock() else { return };
if r.level() <= log::Level::Warn {
st.recent.push_back(line.clone());
if st.recent.len() > RECENT {
st.recent.pop_front();
}
}
if ENABLED.load(Ordering::Relaxed)
&& let Some(f) = &st.file
&& let Ok(mut out) = std::fs::OpenOptions::new().create(true).append(true).open(f)
{
let _ = writeln!(out, "{line}");
}
}
fn flush(&self) {}
}
fn unix_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// Install the logger (once; later calls and other installed loggers are left alone).
pub fn install() {
static LOGGER: Logger = Logger;
if log::set_logger(&LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Info);
}
}
/// The log file inside `config_dir` (the settings folder), or the temporary folder.
pub fn log_path(config_dir: Option<&Path>) -> PathBuf {
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt")
}
/// Turn file logging on (writing to `file`) or off.
pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> {
install();
if on {
if let Some(d) = file.parent() {
std::fs::create_dir_all(d)?;
}
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?;
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
}
if let Ok(mut st) = STATE.lock() {
st.file = Some(file);
}
ENABLED.store(on, Ordering::Relaxed);
Ok(())
}
pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// The current log file (set once logging was enabled).
pub fn current_file() -> Option<PathBuf> {
STATE.lock().ok().and_then(|s| s.file.clone())
}
/// Recent warnings and errors, oldest first.
pub fn recent() -> Vec<String> {
STATE.lock().map(|s| s.recent.iter().cloned().collect()).unwrap_or_default()
}

View File

@ -0,0 +1,65 @@
//! The media cache (Settings ▸ Disk ▸ Database and Cache Folder): audio waveform summaries kept
//! on disk so a footage file is summarised once, not in every session.
//!
//! A summary is stored as `Peaks/<hash>_<bins>.ecpk` (raw little-endian `f32` quadruples
//! `[min L, max L, min R, max R]` per bin), named by a hash of the footage path, its size and
//! modification time, so an edited file is summarised again.
use std::path::{Path, PathBuf};
/// The peak file of footage `path` at `bins` bins per second in the media cache `folder`
/// (`None` when the file can't be inspected).
pub fn peaks_path(folder: &Path, path: &str, bins: u32) -> Option<PathBuf> {
use std::hash::{Hash, Hasher};
let meta = std::fs::metadata(path).ok()?;
let mut h = std::collections::hash_map::DefaultHasher::new();
(path, meta.len(), meta.modified().ok()).hash(&mut h);
Some(folder.join("Peaks").join(format!("{:016x}_{bins}.ecpk", h.finish())))
}
/// Read a stored summary.
pub fn load_peaks(file: &Path) -> Option<Vec<[f32; 4]>> {
let b = std::fs::read(file).ok()?;
if b.len() % 16 != 0 {
return None;
}
let f = |c: &[u8]| f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
Some(b.chunks_exact(16).map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect())
}
/// Store a summary (atomically: written beside, then renamed).
pub fn store_peaks(file: &Path, peaks: &[[f32; 4]]) -> std::io::Result<()> {
if let Some(d) = file.parent() {
std::fs::create_dir_all(d)?;
}
let mut b = Vec::with_capacity(peaks.len() * 16);
for p in peaks {
for v in p {
b.extend_from_slice(&v.to_le_bytes());
}
}
crate::config::atomic_write(file, &b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peaks_round_trip_and_follow_the_file() {
let d = std::env::temp_dir().join(format!("ec-media-cache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let src = d.join("a.wav");
std::fs::write(&src, b"one").unwrap();
let p = peaks_path(&d, src.to_str().unwrap(), 200).unwrap();
assert!(p.starts_with(d.join("Peaks")));
let peaks = vec![[-0.5, 0.5, -0.25, 0.25], [0.0, 0.1, -0.1, 0.0]];
store_peaks(&p, &peaks).unwrap();
assert_eq!(load_peaks(&p).unwrap(), peaks);
// A changed file gets another name.
std::fs::write(&src, b"longer").unwrap();
assert_ne!(peaks_path(&d, src.to_str().unwrap(), 200).unwrap(), p);
let _ = std::fs::remove_dir_all(&d);
}
}

View File

@ -12,6 +12,8 @@
//! Next Blending Mode | layer.setBlendMode {"step":1} | Shift+=
//! --- separator
//! @effects the effect categories (Effect menu)
//! @dynamic:history entries computed from the session when the menu
//! opens ([`dynamic`]: recent files, undo history…)
//! [mac] Quit EffectCraft | app.quit only on macOS ([!mac] = everywhere else)
//! ```
//!
@ -35,11 +37,120 @@ use crate::commands::{self, CommandSpec};
pub enum MenuNode {
Item(MenuEntry),
Separator,
Submenu { label: String, children: Vec<MenuNode> },
Submenu {
label: String,
children: Vec<MenuNode>,
},
/// Entries computed when the menu opens (see [`dynamic`]).
Dynamic {
name: String,
},
}
/// Frontend state dynamic menus depend on.
#[derive(Clone, Copy, Debug, Default)]
pub struct DynCtx<'a> {
/// The current workspace (Window ▸ Assign Shortcut to "…" Workspace).
pub workspace: Option<&'a str>,
}
fn file_name(p: &str) -> String {
std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())
}
fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry {
MenuEntry { label, command: command.into(), params, shortcut: None }
}
/// The bindable holding shortcut `keys` in the active preset (its label).
fn holder_of(s: &Session, keys: &str) -> Option<String> {
let t = s.shortcuts();
t.bindables.iter().find(|b| t.keys.get(&b.key).is_some_and(|k| k.iter().any(|k| k == keys))).map(|b| b.label.clone())
}
/// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
/// first), `view3dShortcuts`, `workspaceShortcuts` and `openViewers` (open compositions other
/// than the active one).
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
use serde_json::json;
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
list.iter()
.enumerate()
.map(|(i, p)| {
let n =
if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) };
dyn_entry(n, cmd, json!({"index": i}))
})
.collect()
};
match name {
"recentProjects" => (indexed(&s.prefs.recent_projects, "file.openRecent", false), Some("No Recent Projects")),
"recentFootage" => (indexed(&s.prefs.recent_footage, "file.importRecent", false), Some("No Recent Footage")),
"recentPresets" => (indexed(&s.prefs.recent_presets, "anim.applyRecentPreset", true), Some("No Recent Presets")),
"history" => (
s.history.undo.iter().rev().enumerate().map(|(i, (l, _))| dyn_entry(format!("Undo {l}"), "edit.history", json!({"steps": i + 1}))).collect(),
Some("No History"),
),
"view3dShortcuts" => (
["F10", "F11", "F12"]
.iter()
.map(|k| {
let label = match holder_of(s, k) {
Some(h) => format!("{k} (Replace “{h}”)"),
None => k.to_string(),
};
dyn_entry(label, "view.assign3dShortcut", json!({"slot": k}))
})
.collect(),
None,
),
"workspaceShortcuts" => (
["Shift+F10", "Shift+F11", "Shift+F12"]
.iter()
.map(|k| {
let label = match holder_of(s, k) {
Some(h) => format!("{k} (Replace “{h}”)"),
None => k.to_string(),
};
let mut p = json!({"slot": k});
if let Some(w) = cx.workspace {
p["workspace"] = json!(w);
}
dyn_entry(label, "window.assignWorkspaceShortcut", p)
})
.collect(),
None,
),
"openViewers" => (
s.state
.open_comps
.iter()
.filter(|c| Some(**c) != s.state.active_comp)
.filter_map(|c| s.project.item(*c).map(|i| dyn_entry(format!("Composition: {}", i.name), "comp.open", json!({"comp": c.0}))))
.collect(),
None,
),
_ => (vec![], None),
}
}
/// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or
/// workspace, like After Effects).
pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String {
match label {
"Assign Shortcut to 3D View" => {
let v = s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
let name = crate::commands::find(crate::commands::view_command(v)).map(|c| c.label).unwrap_or("Active Camera");
format!("Assign Shortcut to “{name}”")
}
"Assign Shortcut to Workspace" => format!("Assign Shortcut to “{}” Workspace", cx.workspace.unwrap_or("Default")),
_ => label.to_string(),
}
}
/// A clickable menu entry.
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct MenuEntry {
pub label: String,
pub command: String,
@ -81,7 +192,7 @@ pub fn entries() -> Vec<(Vec<String>, &'static MenuEntry)> {
for n in nodes {
match n {
MenuNode::Item(e) => out.push((path.clone(), e)),
MenuNode::Separator => {}
MenuNode::Separator | MenuNode::Dynamic { .. } => {}
MenuNode::Submenu { label, children } => {
path.push(label.clone());
rec(children, path, out);
@ -97,7 +208,23 @@ 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()),
_ => e.label.clone(),
},
"window.unavailablePanel" if e.params.get("panel").and_then(Value::as_str) == Some("footage") => {
let f = 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()))
}
"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 +236,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 +378,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 +467,8 @@ File
New Folder | project.newFolder
Open Project... | file.open
Open Recent
@dynamic:recentProjects
---
Clear Recent Projects | file.clearRecent
---
Close | file.close
@ -341,6 +477,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 +488,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 +529,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 +539,8 @@ File
Edit
Undo | edit.undo
Redo | edit.redo
History
@dynamic:history
---
Quick Apply... | app.commandPalette
---
@ -508,6 +652,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 +908,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 +1074,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,6 +1105,8 @@ 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
@ -984,6 +1140,7 @@ Window
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 +1149,10 @@ Help
Animation Presets... | anim.browsePresets
Keyboard Shortcuts... | app.keyboardShortcuts
---
System Compatibility Report... | help.systemReport
Enable Logging | help.enableLogging
Reveal Logging File | help.revealLogFile
---
Join the ArtCraft Discord... | help.discord
Provide Feedback... | help.reportIssue
---

View File

@ -6,7 +6,7 @@ fn all_entries(nodes: &[MenuNode], path: &mut Vec<String>, out: &mut Vec<(Vec<St
for n in nodes {
match n {
MenuNode::Item(e) => out.push((path.clone(), e.clone())),
MenuNode::Separator => {}
MenuNode::Separator | MenuNode::Dynamic { .. } => {}
MenuNode::Submenu { label, children } => {
path.push(label.clone());
all_entries(children, path, out);

View File

@ -13,6 +13,7 @@
use std::collections::BTreeMap;
use effectcraft_color::Label;
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
@ -273,6 +274,13 @@ pub struct Prefs {
pub scripting: Scripting,
/// File ▸ Open Recent, newest first.
pub recent_projects: Vec<String>,
/// File ▸ Import Recent Footage, newest first.
pub recent_footage: Vec<String>,
/// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest
/// first.
pub recent_presets: Vec<String>,
/// Character panel: recently used font families, newest first.
pub recent_fonts: Vec<String>,
/// Top-level keys this version doesn't know (kept for newer versions).
#[serde(flatten)]
pub extra: Extra,
@ -301,6 +309,9 @@ impl Default for Prefs {
three_d: ThreeD::default(),
scripting: Scripting::default(),
recent_projects: vec![],
recent_footage: vec![],
recent_presets: vec![],
recent_fonts: vec![],
extra: Extra::new(),
}
}
@ -430,7 +441,13 @@ impl Prefs {
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
let d = Prefs::default();
let Some(page) = page else {
*self = Prefs { recent_projects: std::mem::take(&mut self.recent_projects), ..d };
*self = Prefs {
recent_projects: std::mem::take(&mut self.recent_projects),
recent_footage: std::mem::take(&mut self.recent_footage),
recent_presets: std::mem::take(&mut self.recent_presets),
recent_fonts: std::mem::take(&mut self.recent_fonts),
..d
};
return Ok(());
};
let keys: Vec<&str> = match page {
@ -490,6 +507,174 @@ impl Prefs {
_ => 0.125,
}
}
/// The Memory & CPU cache budgets in effect: the configured layer / footage / preview
/// budgets, scaled down so together they leave **RAM Reserved for Other Applications** free,
/// and halved while the system is low on memory when **Reduce Cache Size When System Is Low
/// on Memory** is on. `mem` = the system's memory (`None` = unknown: configured budgets).
pub fn cache_budgets(&self, mem: Option<crate::sysinfo::SysMemory>) -> CacheBudgets {
let (mut l, mut m, mut p) = (self.layer_cache_bytes() as u64, self.media_cache_bytes() as u64, self.preview_cache_bytes() as u64);
let floor = 64u64 << 20;
let mut capped = false;
let mut reduced = false;
if let Some(mem) = mem.filter(|m| m.total > 0) {
let reserved = (self.memory.ram_reserved_gb as u64) << 30;
let cap = mem.total.saturating_sub(reserved).max(3 * floor);
let sum = l + m + p;
if sum > cap {
let k = cap as f64 / sum as f64;
(l, m, p) = ((l as f64 * k) as u64, (m as f64 * k) as u64, (p as f64 * k) as u64);
capped = true;
}
if self.memory.reduce_cache_when_low && mem.is_low() {
(l, m, p) = (l / 2, m / 2, p / 2);
reduced = true;
}
}
CacheBudgets { layer: l.max(floor) as usize, media: m.max(floor) as usize, preview: p.max(floor) as usize, capped, reduced }
}
/// Composition ▸ Motion Path: the layer-time span of a motion path to draw around `now`
/// (layer time), for keyframes at `keys` (sorted). `None` = No Motion Path.
pub fn motion_path_span(&self, keys: &[Tick], now: Tick) -> Option<(Tick, Tick)> {
let (first, last) = (*keys.first()?, *keys.last()?);
let c = &self.composition;
match c.motion_path.as_str() {
"none" => None,
"seconds" => {
let half = Tick::from_seconds_f64(c.motion_path_seconds.max(0.0) / 2.0);
let (a, b) = ((now - half).max(first), (now + half).min(last));
(a < b).then_some((a, b))
}
"keyframes" => {
let n = (c.motion_path_keyframes.max(1) as usize).min(keys.len());
if n < 2 {
return None;
}
// The n keyframes centred on the current time.
let at = keys.partition_point(|k| *k < now);
let start = at.saturating_sub(n / 2).min(keys.len() - n);
Some((keys[start], keys[start + n - 1]))
}
_ => Some((first, last)),
}
}
/// Previews ▸ Viewer Zoom Quality: smooth (bilinear) scaling of the viewer image, or nearest
/// neighbour ("Faster").
pub fn viewer_zoom_smooth(&self) -> bool {
self.previews.zoom_quality != "faster"
}
/// Import ▸ Interpret Unlabeled Alpha As, for footage whose file has an alpha channel that
/// doesn't state how it is stored. `None` = Ask User (keep straight and open Interpret
/// Footage); `guess` takes premultiplied for movies (codecs with alpha are usually written
/// premultiplied) and straight for stills.
pub fn unlabeled_alpha(&self, movie: bool) -> Option<effectcraft_project::AlphaMode> {
use effectcraft_project::AlphaMode::*;
Some(match self.import.unlabeled_alpha.as_str() {
"ignore" => Ignore,
"straight" => Straight,
"premultiplied" => Premultiplied,
"guess" if movie => Premultiplied,
"guess" => Straight,
_ => return None,
})
}
/// Import ▸ Default Drag Import As, as `file.import`'s `importAs`.
pub fn drag_import_as(&self) -> &'static str {
match self.import.drag_import_as.as_str() {
"comp" => "composition",
"compLayerSizes" => "compositionLayerSizes",
_ => "footage",
}
}
/// Export ▸ Append Bit Depth to File Name: `Comp 1.png` → `Comp 1_16bpc.png` (sequence frame
/// runs and folders are kept).
pub fn output_name(&self, path: &str, bits: u32) -> String {
if !self.export.append_bits_to_name {
return path.to_string();
}
let p = std::path::Path::new(path);
let (Some(stem), dir) = (p.file_stem().map(|s| s.to_string_lossy().to_string()), p.parent()) else { return path.to_string() };
let tag = format!("_{bits}bpc");
if stem.ends_with(&tag) {
return path.to_string();
}
let name = match p.extension() {
Some(e) => format!("{stem}{tag}.{}", e.to_string_lossy()),
None => format!("{stem}{tag}"),
};
match dir.filter(|d| !d.as_os_str().is_empty()) {
Some(d) => d.join(name).to_string_lossy().to_string(),
None => name,
}
}
/// Export ▸ Segment Sequences / Segment Movie Files: how many frames go in each segment of an
/// output of `frames` frames (`None` = one file / folder). Sequences split every Files per
/// Segment frames; movies every Segment Size MB at the output's data rate
/// (`bytes_per_frame`, estimated from the format's bitrate).
pub fn segment_frames(&self, frames: u64, sequence: bool, bytes_per_frame: f64) -> Option<u64> {
let e = &self.export;
let per = if sequence {
e.segment_sequences.then_some(e.segment_sequence_files.max(1) as u64)?
} else {
if !e.segment_movies || bytes_per_frame <= 0.0 {
return None;
}
(((e.segment_movie_mb.max(1) as f64) * 1_048_576.0 / bytes_per_frame).floor() as u64).max(1)
};
(per < frames).then_some(per)
}
/// Remember a font in the Character panel's recent fonts (Type ▸ Number of Recent Fonts).
pub fn push_recent_font(&mut self, family: &str) {
push_mru(&mut self.recent_fonts, family, 30);
}
/// The recent fonts the Character panel shows (at most Number of Recent Fonts to Display).
pub fn recent_fonts_shown(&self) -> &[String] {
let n = (self.type_.recent_fonts as usize).min(self.recent_fonts.len());
&self.recent_fonts[..n]
}
/// Remember imported footage for File ▸ Import Recent Footage.
pub fn push_recent_footage(&mut self, path: &str) {
push_mru(&mut self.recent_footage, path, self.general.recent_items as usize);
}
/// Remember an applied animation preset for Animation ▸ Recent Animation Presets.
pub fn push_recent_preset(&mut self, path: &str) {
push_mru(&mut self.recent_presets, path, self.general.recent_items as usize);
}
}
fn push_mru(list: &mut Vec<String>, v: &str, max: usize) {
list.retain(|x| x != v);
list.insert(0, v.to_string());
list.truncate(max.max(1));
}
/// Effective cache budgets in bytes (see [`Prefs::cache_budgets`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct CacheBudgets {
pub layer: usize,
pub media: usize,
pub preview: usize,
/// Scaled down to leave the reserved RAM free.
pub capped: bool,
/// Halved because the system is low on memory.
pub reduced: bool,
}
/// Colour of the `k`-th mask of a layer: Appearance ▸ Cycle Mask Colors cycles through the mask
/// colours, otherwise every new mask takes the first one.
pub fn mask_color(cycle: bool, k: usize) -> [u8; 3] {
use effectcraft_project::build::MASK_COLORS;
if cycle { MASK_COLORS[k % MASK_COLORS.len()] } else { MASK_COLORS[0] }
}
fn label_index(l: Label) -> Option<usize> {
@ -659,13 +844,14 @@ pub fn pages() -> Vec<Page> {
Section("Options"),
s("general.showToolTips", "Show Tool Tips", B, true),
s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true),
s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, false),
s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true),
s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true),
s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, false),
s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, false),
s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, false),
s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, false),
s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, true),
s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, true),
s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, true),
s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, true),
s("general.useSystemColorPicker", "Use System Color Picker", B, false),
Note("EffectCraft always uses its own colour picker (the same on every platform and the web)."),
],
},
Page {
@ -673,7 +859,7 @@ pub fn pages() -> Vec<Page> {
title: "Startup & Repair",
items: vec![
s("startup.showHomeOnLaunch", "Show Home Screen When Launching", B, true),
s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, false),
s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, true),
s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true),
Section("Repair"),
Button { label: "Reset Settings", command: "prefs.reset", params: "{}" },
@ -712,13 +898,14 @@ pub fn pages() -> Vec<Page> {
("No More Than (Seconds)", "seconds"),
("No More Than (Keyframes)", "keyframes"),
]),
false,
true,
),
s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), false),
s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), false),
s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), true),
s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), true),
Section("Display"),
s("composition.showRenderingProgress", "Show Rendering Progress in Info Panel and Flowchart", B, true),
s("composition.hardwareAcceleratePanels", "Hardware Accelerate Composition, Layer and Footage Panels", B, false),
Note("Panels are always drawn by the GPU (wgpu)."),
],
},
Page {
@ -726,10 +913,10 @@ pub fn pages() -> Vec<Page> {
title: "Previews",
items: vec![
s("previews.adaptiveResolutionLimit", "Adaptive Resolution Limit", Kind::Choice(ON_OFF_RES), true),
s("previews.showInternalWireframes", "Show Internal Wireframes", B, false),
s("previews.showInternalWireframes", "Show Internal Wireframes", B, true),
s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true),
s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true),
s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), false),
s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), true),
s("previews.displayProfile", "Display Color Space", Kind::Choice(&[("sRGB", "srgb"), ("Display P3", "p3")]), true),
Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" },
],
@ -742,9 +929,9 @@ pub fn pages() -> Vec<Page> {
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
Section("Labels and Colors"),
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, false),
s("appearance.cycleMaskColors", "Cycle Mask Colors", B, false),
s("appearance.useGradients", "Use Gradients", B, false),
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
s("appearance.cycleMaskColors", "Cycle Mask Colors", B, true),
s("appearance.useGradients", "Use Gradients", B, true),
Section("Menu Bar"),
s("appearance.inWindowMenuBarMac", "Use In-Window Menu Bar on macOS", B, true),
],
@ -757,13 +944,13 @@ pub fn pages() -> Vec<Page> {
s("grids.gridColor", "Color", Kind::Color, true),
s("grids.gridSpacing", "Gridline Every", Kind::Float(1.0, 10_000.0, "px"), true),
s("grids.gridSubdivisions", "Subdivisions", Kind::Int(1, 100, ""), true),
s("grids.gridStyle", "Style", Kind::Choice(STYLES), false),
s("grids.gridStyle", "Style", Kind::Choice(STYLES), true),
Section("Proportional Grid"),
s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), false),
s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), false),
s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), true),
s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), true),
Section("Guides"),
s("grids.guideColor", "Color", Kind::Color, true),
s("grids.guideStyle", "Style", Kind::Choice(STYLES), false),
s("grids.guideStyle", "Style", Kind::Choice(STYLES), true),
Section("Safe Margins"),
s("grids.actionSafe", "Action-safe", Kind::Float(0.0, 50.0, "%"), true),
s("grids.titleSafe", "Title-safe", Kind::Float(0.0, 50.0, "%"), true),
@ -774,10 +961,10 @@ pub fn pages() -> Vec<Page> {
id: "type",
title: "Type",
items: vec![
s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), false),
s("type.fontPreview", "Show Font Preview", B, false),
s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), false),
s("type.fontNamesInEnglish", "Show Font Names in English", B, false),
s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), true),
s("type.fontPreview", "Show Font Preview", B, true),
s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), true),
s("type.fontNamesInEnglish", "Show Font Names in English", B, true),
],
},
Page {
@ -787,7 +974,7 @@ pub fn pages() -> Vec<Page> {
s("import.stillFootage", "Still Footage", Kind::Choice(&[("Length of Composition", "compLength"), ("Duration (Seconds)", "seconds")]), true),
s("import.stillSeconds", "Still Duration", Kind::Float(0.04, 86_400.0, "s"), true),
s("import.sequenceFps", "Sequence Footage", Kind::Float(1.0, 999.0, "frames per second"), true),
s("import.reportMissingFrames", "Report Missing Frames", B, false),
s("import.reportMissingFrames", "Report Missing Frames", B, true),
s(
"import.unlabeledAlpha",
"Interpret Unlabeled Alpha As",
@ -798,13 +985,13 @@ pub fn pages() -> Vec<Page> {
("Straight", "straight"),
("Premultiplied", "premultiplied"),
]),
false,
true,
),
s(
"import.dragImportAs",
"Default Drag Import As",
Kind::Choice(&[("Footage", "footage"), ("Composition", "comp"), ("Composition - Retain Layer Sizes", "compLayerSizes")]),
false,
true,
),
],
},
@ -812,12 +999,12 @@ pub fn pages() -> Vec<Page> {
id: "export",
title: "Export",
items: vec![
s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, false),
s("export.segmentSequences", "Segment Sequences", B, false),
s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), false),
s("export.segmentMovies", "Segment Movie Files", B, false),
s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), false),
s("export.appendBitsToName", "Append Bit Depth to File Name", B, false),
s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, true),
s("export.segmentSequences", "Segment Sequences", B, true),
s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), true),
s("export.segmentMovies", "Segment Movie Files", B, true),
s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), true),
s("export.appendBitsToName", "Append Bit Depth to File Name", B, true),
],
},
Page {
@ -826,7 +1013,7 @@ pub fn pages() -> Vec<Page> {
items: vec![
Section("Audio Hardware"),
AudioDevices { key: "audio.outputDevice" },
s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), false),
s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), true),
Section("Audio Output Mapping"),
s("audio.outputLeft", "Left", Kind::Int(1, 64, "channel"), true),
s("audio.outputRight", "Right", Kind::Int(1, 64, "channel"), true),
@ -837,36 +1024,36 @@ pub fn pages() -> Vec<Page> {
title: "Disk",
items: vec![
Section("Disk Cache"),
s("disk.diskCacheEnabled", "Enable Disk Cache", B, false),
s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), false),
s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, false),
s("disk.diskCacheEnabled", "Enable Disk Cache", B, true),
s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), true),
s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, true),
Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# },
Section("Media Cache"),
s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, false),
s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, false),
s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, true),
s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, true),
],
},
Page {
id: "memory",
title: "Memory & CPU",
items: vec![
s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), false),
s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), true),
Section("Cache Budgets"),
s("memory.layerCacheMb", "Layer Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, false),
s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, true),
],
},
Page {
id: "video",
title: "Video",
items: vec![
s("video.enableOutput", "Enable Video Preview Output", B, false),
s("video.device", "Video Device", Kind::Text, false),
s("video.outputDuringPlayback", "Video Output During Playback", B, false),
s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, false),
s("video.disableWhenBackground", "Disable Video Output When in Background", B, false),
s("video.enableOutput", "Enable Video Preview Output", B, true),
s("video.device", "Video Device", Kind::Choice(&[("Floating Window", ""), ("Full Screen", "fullscreen")]), true),
s("video.outputDuringPlayback", "Video Output During Playback", B, true),
s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, true),
s("video.disableWhenBackground", "Disable Video Output When in Background", B, true),
],
},
Page {
@ -874,9 +1061,9 @@ pub fn pages() -> Vec<Page> {
title: "3D",
items: vec![
s("threeD.defaultRenderer", "Default 3D Renderer", Kind::Choice(&[("Classic 3D", "classic"), ("Advanced 3D", "advanced")]), true),
s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, false),
s("threeD.extendedViewer", "Extended Viewer", B, false),
s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, false),
s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, true),
s("threeD.extendedViewer", "Extended Viewer", B, true),
s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, true),
],
},
Page {
@ -884,18 +1071,18 @@ pub fn pages() -> Vec<Page> {
title: "Scripting & Expressions",
items: vec![
Section("Application Scripting"),
s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, false),
s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, false),
s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, true),
s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, true),
s("scripting.enableJsDebugger", "Enable JavaScript Debugger", B, false),
Section("Expressions Editor"),
s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), false),
s("scripting.syntaxHighlighting", "Syntax Highlighting", B, false),
s("scripting.lineNumbers", "Line Numbers", B, false),
s("scripting.autoComplete", "Auto-complete", B, false),
s("scripting.bracketMatching", "Bracket Matching", B, false),
s("scripting.wordWrap", "Word Wrap", B, false),
s("scripting.errorBanner", "Show Expression Error Banner", B, false),
Note("Expressions use the JavaScript engine."),
s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), true),
s("scripting.syntaxHighlighting", "Syntax Highlighting", B, true),
s("scripting.lineNumbers", "Line Numbers", B, true),
s("scripting.autoComplete", "Auto-complete", B, true),
s("scripting.bracketMatching", "Bracket Matching", B, true),
s("scripting.wordWrap", "Word Wrap", B, true),
s("scripting.errorBanner", "Show Expression Error Banner", B, true),
Note("Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger."),
],
},
]

View File

@ -10,7 +10,7 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::render_queue::{OutputFormat, OutputModule, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{ExprHost, FootageSource};
use serde::{Deserialize, Serialize};
@ -27,6 +27,8 @@ pub struct ExportJob<'a> {
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Resolved output path (templates expanded except the `#` frame-number run).
pub path: &'a str,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
pub nested_switches: bool,
}
/// What an export wrote.
@ -127,8 +129,56 @@ struct Work {
expr: Option<Arc<dyn ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
exporter: Arc<dyn Exporter>,
/// Per queue item: one (item with that output module, resolved path) per output module.
/// Per queue item: one (item with that output module, resolved path) per output module
/// (and per segment, see [`Session::segments`]).
items: Vec<Vec<(RenderQueueItem, String)>>,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
nested_switches: bool,
}
/// Bits per channel an output format writes for a project bit depth (named in the file when
/// Settings ▸ Export ▸ Append Bit Depth to File Name is on).
pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 {
use effectcraft_project::BitDepth;
let project = match depth {
BitDepth::Bpc8 => 8,
BitDepth::Bpc16 => 16,
BitDepth::Bpc32 => 32,
};
match format {
OutputFormat::ExrSequence => 32,
OutputFormat::PngSequence | OutputFormat::TiffSequence => project.min(16),
OutputFormat::ProRes => 10,
OutputFormat::Wav | OutputFormat::Aiff => 16,
_ => 8,
}
}
/// The bytes an output writes per frame, estimated from its data rate (H.264 bitrate, Apple's
/// published ProRes target rates at 1080p29.97 scaled by frame size, typical rates otherwise).
pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
use effectcraft_project::render_queue::ProResProfile::*;
let fps = fps.max(1.0);
let px = (w as f64 * h as f64) / (1920.0 * 1080.0);
let mbps = |m: f64| m * 1_000_000.0 / 8.0;
match om.format {
OutputFormat::H264 => om.bitrate_kbps as f64 * 1000.0 / 8.0 / fps,
OutputFormat::ProRes => {
let rate = match om.prores_profile {
Proxy => 45.0,
Lt => 102.0,
Standard => 147.0,
Hq => 220.0,
P4444 => 330.0,
P4444Xq => 500.0,
};
mbps(rate) * px / 29.97
}
OutputFormat::WebM => mbps(8.0) * px / fps,
OutputFormat::Gif => w as f64 * h as f64 * 0.5,
OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,
_ => w as f64 * h as f64 * 3.0,
}
}
fn unix_now() -> u64 {
@ -143,7 +193,15 @@ pub(crate) fn run_items(
shared: &JobShared,
notify: &mut dyn FnMut(&JobShared),
) {
let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items };
let work = Work {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
accel: s.accel.clone(),
exporter,
items,
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
run_work(work, shared, notify);
}
@ -174,7 +232,15 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
let job = ExportJob {
project: &w.project,
footage: w.footage.as_ref(),
expr: w.expr.as_deref(),
accel: w.accel.as_deref(),
item: mitem,
path,
nested_switches: w.nested_switches,
};
let rr = w.exporter.export(&job, &mut |done, total| {
{
let mut s = lock(&shared.state);
@ -248,12 +314,58 @@ impl Session {
if path.is_absolute() {
return Some(p);
}
let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty());
// Settings ▸ Export ▸ Default Output Folder, else the project's folder.
let folder = self.prefs.export.default_output_folder.trim();
let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder));
let base =
base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()));
// (the web has no working directory: relative outputs land in its virtual root)
let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?;
Some(base.join(path).to_string_lossy().to_string())
}
/// Settings ▸ Export ▸ Segment Sequences / Segment Movie Files: the (item, path) outputs one
/// output module writes. Sequences go into numbered folders of Files per Segment frames each
/// (`out/Comp 1_001/Comp 1_00000.png`…); movies into numbered files of about Segment Size MB
/// each (`Comp 1_001.mp4`…), cut at the frame count the output's data rate gives.
pub fn segments(&self, item: &RenderQueueItem, path: &str) -> Vec<(RenderQueueItem, String)> {
let Some(comp) = self.project.comp(item.comp) else { return vec![(item.clone(), path.to_string())] };
let fmt = item.output.format;
let frames = item.settings.frame_count(comp);
let (w, h) = item.settings.output_size(comp);
let bpf = bytes_per_frame(&item.output, w, h, item.settings.rate(comp).as_f64());
let Some(per) = self.prefs.segment_frames(frames, fmt.is_sequence(), bpf) else { return vec![(item.clone(), path.to_string())] };
let p = std::path::Path::new(path);
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let ext = p.extension().map(|s| s.to_string_lossy().to_string());
let rate = item.settings.rate(comp);
let f0 = item.settings.first_frame(comp);
let mut out = vec![];
let mut i = 0;
let mut k = 1;
while i < frames {
let n = per.min(frames - i);
let mut m = item.clone();
let (start, end) = (rate.tick_of(f0 + i as i64), rate.tick_of(f0 + (i + n) as i64));
m.settings.time_span = effectcraft_project::render_queue::TimeSpan::Custom { start, end };
let seg = if fmt.is_sequence() {
let base = stem.trim_end_matches(['#', '[', ']', '_']).to_string();
let folder = dir.join(format!("{base}_{k:03}"));
folder.join(p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())
} else {
dir.join(match &ext {
Some(e) => format!("{stem}_{k:03}.{e}"),
None => format!("{stem}_{k:03}"),
})
};
out.push((m, seg.to_string_lossy().to_string()));
i += n;
k += 1;
}
out
}
/// Whether a render job is running.
pub fn is_rendering(&self) -> bool {
self.render_job.as_ref().is_some_and(|j| !j.is_finished())
@ -286,7 +398,13 @@ impl Session {
let mut m = it.clone();
m.output = om.clone();
match self.resolve_output(&m) {
Some(p) if formats.contains(&om.format) => modules.push((m, p)),
Some(p) if formats.contains(&om.format) => {
// Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences /
// Movie Files.
let bits = output_bits(om.format, self.project.settings.bit_depth);
let p = self.prefs.output_name(&p, bits);
modules.extend(self.segments(&m, &p));
}
Some(_) => problem = Some(RenderStatus::Failed(format!("{} export is not available", om.format.label()))),
None if self.project.comp(it.comp).is_none() => problem = Some(RenderStatus::Failed("composition missing".into())),
None => problem = Some(RenderStatus::NeedsOutput),
@ -329,7 +447,15 @@ impl Session {
self.poll_render();
return Ok(ids);
}
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
let work = Work {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
accel: self.accel.clone(),
exporter,
items,
nested_switches: self.prefs.general.switches_affect_nested_comps,
};
let shared = Arc::new(JobShared::default());
if wait {
run_work(work, &shared, &mut |_| {});

View File

@ -25,9 +25,15 @@ impl Session {
/// Settings changed: apply what the engine owns (cache budgets) and tell frontends.
pub fn prefs_changed(&mut self) {
self.prefs.normalize();
self.layer_cache.set_budget(self.prefs.layer_cache_bytes());
self.footage.set_cache_budget(self.prefs.media_cache_bytes());
self.apply_cache_budgets();
self.configure_disk_cache();
self.footage.set_conform_folder(self.conformed_audio_folder());
// Switches Affect Nested Comps changes what precomp layers render: drop cached pixels.
let nested = self.prefs.general.switches_affect_nested_comps;
if self.applied_nested_switches.replace(nested).is_some_and(|was| was != nested) {
self.layer_cache.clear();
self.events.push(crate::Event::PurgeCaches);
}
// Fewer undo levels apply right away.
let levels = self.prefs.general.undo_levels.max(1) as usize;
if self.history.undo.len() > levels {
@ -37,6 +43,53 @@ impl Session {
self.prefs_revision += 1;
}
/// A Disk settings folder: the setting, or (for frontends with a settings store) `name` next
/// to the platform disk cache folder. Headless sessions without the setting stay off disk.
fn cache_folder(&self, setting: &str, name: &str) -> Option<std::path::PathBuf> {
let s = setting.trim();
if !s.is_empty() {
return Some(std::path::PathBuf::from(s));
}
let base = effectcraft_render::disk_cache::default_folder();
self.config.is_some().then(|| base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name)))
}
/// Settings ▸ Disk ▸ Database and Cache Folder (media cache: audio waveform summaries).
pub fn media_cache_folder(&self) -> Option<std::path::PathBuf> {
self.cache_folder(&self.prefs.disk.media_cache_folder, "Media Cache")
}
/// Settings ▸ Disk ▸ Conformed Audio Folder (decoded footage audio, see
/// `MediaPool::set_conform_folder`).
pub fn conformed_audio_folder(&self) -> Option<std::path::PathBuf> {
self.cache_folder(&self.prefs.disk.conformed_media_folder, "Conformed Audio")
}
/// Apply the Memory & CPU cache budgets, limited by RAM Reserved for Other Applications and
/// reduced while the system is low on memory (see [`crate::prefs::Prefs::cache_budgets`]).
pub fn apply_cache_budgets(&mut self) {
let b = self.prefs.cache_budgets(self.sys_memory);
self.layer_cache.set_budget(b.layer);
self.footage.set_cache_budget(b.media);
}
/// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply
/// the cache budgets when the reading changes them. Returns the effective budgets.
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
let before = self.prefs.cache_budgets(self.sys_memory);
self.sys_memory = crate::sysinfo::memory().or(self.sys_memory);
let after = self.prefs.cache_budgets(self.sys_memory);
if after != before {
self.apply_cache_budgets();
// Frontends re-apply their preview cache budget.
self.prefs_revision += 1;
if after.reduced && !before.reduced {
self.toast("The system is low on memory: cache sizes were reduced");
}
}
after
}
/// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache
/// and attach it to the layer cache. Without a folder setting the platform cache folder is
/// used, but only by frontends with a settings store (headless sessions stay off disk).

View File

@ -0,0 +1,140 @@
//! Host information for Settings ▸ Memory & CPU (RAM reserved for other applications, reducing
//! caches when the system is low on memory) and Help ▸ System Compatibility Report.
//!
//! Pure Rust and dependency-free: the values come from the operating system's own reporting
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every
//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their
//! configured budgets.
use serde::Serialize;
/// Physical memory in bytes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
pub struct SysMemory {
pub total: u64,
/// Memory the system can hand out without swapping (free + reclaimable caches).
pub available: u64,
}
impl SysMemory {
/// The system is low on memory: less than 10% (or less than 1 GB) is available.
pub fn is_low(&self) -> bool {
self.total > 0 && (self.available < self.total / 10 || self.available < 1 << 30)
}
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
fn run(cmd: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(cmd).args(args).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string())
}
/// Parse `/proc/meminfo` (kB values).
pub fn parse_meminfo(text: &str) -> Option<SysMemory> {
let field = |name: &str| -> Option<u64> {
let line = text.lines().find(|l| l.starts_with(name))?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
Some(kb * 1024)
};
let total = field("MemTotal:")?;
let available = field("MemAvailable:").or_else(|| Some(field("MemFree:")? + field("Cached:").unwrap_or(0)))?;
Some(SysMemory { total, available })
}
/// Parse macOS `vm_stat` output: free + inactive + speculative + purgeable pages.
pub fn parse_vm_stat(text: &str, total: u64) -> Option<SysMemory> {
let page: u64 = text.split("page size of ").nth(1)?.split_whitespace().next()?.parse().ok()?;
let pages = |name: &str| -> u64 {
text.lines()
.find(|l| l.starts_with(name))
.and_then(|l| l.rsplit(':').next())
.and_then(|v| v.trim().trim_end_matches('.').parse::<u64>().ok())
.unwrap_or(0)
};
let free = pages("Pages free") + pages("Pages inactive") + pages("Pages speculative") + pages("Pages purgeable");
Some(SysMemory { total, available: (free * page).min(total) })
}
/// Total and available physical memory, when the system reports them.
pub fn memory() -> Option<SysMemory> {
#[cfg(target_os = "linux")]
{
return parse_meminfo(&std::fs::read_to_string("/proc/meminfo").ok()?);
}
#[cfg(target_os = "macos")]
{
let total: u64 = run("sysctl", &["-n", "hw.memsize"])?.trim().parse().ok()?;
return parse_vm_stat(&run("vm_stat", &[])?, total).or(Some(SysMemory { total, available: total }));
}
#[cfg(target_os = "windows")]
{
let text = run(
"powershell",
&["-NoProfile", "-Command", "$o=Get-CimInstance Win32_OperatingSystem; \"$($o.TotalVisibleMemorySize) $($o.FreePhysicalMemory)\""],
)?;
let mut it = text.split_whitespace().filter_map(|v| v.parse::<u64>().ok());
return Some(SysMemory { total: it.next()? * 1024, available: it.next()? * 1024 });
}
#[allow(unreachable_code)]
None
}
/// CPU model name.
pub fn cpu_name() -> Option<String> {
#[cfg(target_os = "linux")]
{
let t = std::fs::read_to_string("/proc/cpuinfo").ok()?;
return t.lines().find(|l| l.starts_with("model name")).and_then(|l| l.split(':').nth(1)).map(|s| s.trim().to_string());
}
#[cfg(target_os = "macos")]
{
return run("sysctl", &["-n", "machdep.cpu.brand_string"]).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
}
#[cfg(target_os = "windows")]
{
return std::env::var("PROCESSOR_IDENTIFIER").ok();
}
#[allow(unreachable_code)]
None
}
/// Operating system name and version.
pub fn os_version() -> String {
#[cfg(target_os = "macos")]
if let Some(v) = run("sw_vers", &["-productVersion"]) {
return format!("macOS {}", v.trim());
}
#[cfg(target_os = "linux")]
if let Ok(t) = std::fs::read_to_string("/etc/os-release")
&& let Some(n) = t.lines().find_map(|l| l.strip_prefix("PRETTY_NAME="))
{
return n.trim_matches('"').to_string();
}
#[cfg(target_os = "windows")]
if let Some(v) = run("cmd", &["/C", "ver"]) {
return v.trim().to_string();
}
format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)
}
/// Logical CPU cores.
pub fn cpu_cores() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_proc_meminfo_and_vm_stat() {
let m = parse_meminfo("MemTotal: 16000000 kB\nMemFree: 1000 kB\nMemAvailable: 8000000 kB\n").unwrap();
assert_eq!(m, SysMemory { total: 16_000_000 * 1024, available: 8_000_000 * 1024 });
assert!(!m.is_low());
let vm = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 1000.\nPages active: 5.\nPages inactive: 3000.\nPages speculative: 100.\n";
let m = parse_vm_stat(vm, 8 << 30).unwrap();
assert_eq!(m.available, 4100 * 16384);
assert!(m.is_low());
}
}

View File

@ -0,0 +1,666 @@
//! M14.5: settings that change behaviour, the keyframe assistants and labels, and the menu
//! entries added for After Effects parity (History, recent footage / presets, XML copies, VR,
//! shortcut slots, logging and the compatibility report).
use std::path::PathBuf;
use serde_json::{Value, json};
use crate::render::RenderOpts;
use crate::{KeyRef, Session};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{ItemId, LayerId};
use effectcraft_time::Tick;
fn tmp(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let d = std::env::temp_dir().join(format!("ec-m145-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 48, "duration": 2, "frameRate": 10})).unwrap();
s
}
fn solid(s: &mut Session, color: &str) -> u64 {
s.execute("layer.newSolid", json!({"color": color, "width": 32, "height": 24})).unwrap()["layer"].as_u64().unwrap()
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
// ---------------------------------------------------------------- General
#[test]
fn split_layers_go_above_or_below_the_original() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
s.execute("time.set", json!({"time": 1.0})).unwrap();
let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
assert_eq!(order, vec![a, below], "off (default): the new part goes below");
s.execute("edit.undo", json!({})).unwrap();
s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap();
let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
assert_eq!(order, vec![above, a]);
}
#[test]
fn new_masks_cycle_colours_only_when_asked() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
let colors = |s: &Session| -> Vec<[u8; 3]> {
layer(s, a)
.props
.sub("masks")
.unwrap()
.groups()
.map(|g| match g.kind {
effectcraft_project::GroupKind::Mask { color, .. } => color,
_ => [0; 3],
})
.collect()
};
s.execute("layer.addMask", json!({"layer": a})).unwrap();
s.execute("layer.addMask", json!({"layer": a})).unwrap();
let c = colors(&s);
assert_ne!(c[0], c[1], "cycling by default");
s.execute("prefs.set", json!({"key": "appearance.cycleMaskColors", "value": false})).unwrap();
s.execute("layer.addMask", json!({"layer": a})).unwrap();
s.execute("mask.new", json!({"layer": a, "vertices": [[0, 0], [5, 5]]})).unwrap();
let c = colors(&s);
assert_eq!(c[2], c[0]);
assert_eq!(c[3], c[0]);
}
#[test]
fn preserve_constant_vertex_count_edits_every_mask_key() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
let m = s.execute("layer.addMask", json!({"layer": a})).unwrap()["mask"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": a, "prop": layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid})).unwrap();
let path_uid = layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid;
s.execute("prop.toggleAnimation", json!({"layer": a, "prop": path_uid})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("mask.setVertex", json!({"layer": a, "mask": m, "index": 0, "point": [1, 1]})).unwrap();
let counts = |s: &Session| -> Vec<usize> {
layer(s, a)
.props
.find(path_uid)
.unwrap()
.keys
.iter()
.map(|k| match &k.value {
KV::Path(p) => p.vertices.len(),
_ => 0,
})
.collect()
};
assert_eq!(counts(&s), vec![4, 4]);
// On (the default): a vertex added at one key is added to the other.
s.execute("mask.insertVertex", json!({"layer": a, "mask": m, "segment": 0})).unwrap();
assert_eq!(counts(&s), vec![5, 5]);
s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(a), mask: m, index: 1 }];
s.execute("mask.deleteVertices", json!({})).unwrap();
assert_eq!(counts(&s), vec![4, 4]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(counts(&s), vec![5, 5], "one undo step");
// Off: only the current key changes.
s.execute("prefs.set", json!({"key": "general.preserveConstantVertexCount", "value": false})).unwrap();
s.execute("mask.addVertex", json!({"layer": a, "mask": m, "point": [3, 3]})).unwrap();
assert_eq!(counts(&s), vec![5, 6]);
}
#[test]
fn switches_affect_nested_comps_passes_quality_down() {
let mut s = Session::default();
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let a = solid(&mut s, "#ff8040");
// A rotated, scaled layer: bilinear (Best) and nearest (Draft) sampling differ.
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 17})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [131, 131]})).unwrap();
let outer = s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let pre = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.quality", json!({"layers": [pre], "quality": "draft"})).unwrap();
let draw = |s: &Session| s.render(ItemId(outer), Tick::ZERO, RenderOpts::default());
let on = draw(&s);
s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap();
let off = draw(&s);
assert_ne!(on.data, off.data, "the precomp's Draft switch reaches the nested layer only when on");
// On = the nested layer itself set to Draft.
s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": true})).unwrap();
s.open_comp(ItemId(inner));
s.execute("layer.quality", json!({"layers": [a], "quality": "draft"})).unwrap();
s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap();
assert_eq!(draw(&s).data, on.data);
}
#[test]
fn related_comps_share_the_current_time() {
let mut s = Session::default();
let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap();
let outer = s.execute("comp.new", json!({"name": "Outer", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap();
let l = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setTiming", json!({"layer": l, "startTime": 2.0})).ok();
s.edit("t", None, |p, _| {
p.comp_mut(ItemId(outer)).unwrap().layer_mut(LayerId(l)).unwrap().start_time = Tick::from_seconds_f64(2.0);
Ok(())
})
.unwrap();
s.set_time(Tick::from_seconds_f64(5.0));
assert!((s.time_of(ItemId(inner)).seconds() - 3.0).abs() < 1e-9, "nested comp follows through the layer's start");
s.open_comp(ItemId(inner));
s.set_time(Tick::from_seconds_f64(1.0));
assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9, "and the other way");
s.execute("prefs.set", json!({"key": "general.syncTimeRelatedItems", "value": false})).unwrap();
s.set_time(Tick::from_seconds_f64(7.0));
assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9);
}
#[test]
fn pick_whip_writes_match_names_without_compact_english() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
let r = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": r, "effect": "Gaussian Blur"})).unwrap();
s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap();
let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/opacity", "target": {"layer": r, "path": "transform/opacity"}})).unwrap();
assert_eq!(e, json!("thisComp.layer(\"Other\")(\"transform\")(\"opacity\")"));
let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/rotation", "target": {"layer": a, "path": "transform/opacity"}})).unwrap();
assert_eq!(e, json!("thisLayer(\"transform\")(\"opacity\")"));
}
// ---------------------------------------------------------------- pure setting helpers
#[test]
fn cache_budgets_leave_reserved_ram_and_shrink_when_low() {
use crate::sysinfo::SysMemory;
let mut p = crate::prefs::Prefs::default();
p.memory.layer_cache_mb = 4096;
p.memory.media_cache_mb = 4096;
p.memory.preview_cache_mb = 8192;
p.memory.ram_reserved_gb = 6;
let b = p.cache_budgets(None);
assert_eq!((b.layer >> 20, b.preview >> 20, b.capped), (4096, 8192, false));
// 16 GB machine, 6 GB reserved: 16 GB of caches scaled into 10 GB.
let m = SysMemory { total: 16 << 30, available: 12 << 30 };
let b = p.cache_budgets(Some(m));
assert!(b.capped && !b.reduced);
assert!(((b.layer + b.media + b.preview) as i64 - (10i64 << 30)).abs() < 4, "{b:?}");
// Low on memory: halved; off: not.
let low = SysMemory { total: 16 << 30, available: 512 << 20 };
let r = p.cache_budgets(Some(low));
assert!(r.reduced && r.preview < b.preview / 2 + 2);
p.memory.reduce_cache_when_low = false;
assert!(!p.cache_budgets(Some(low)).reduced);
}
#[test]
fn motion_path_span_follows_the_composition_setting() {
let mut p = crate::prefs::Prefs::default();
let s = |x: f64| Tick::from_seconds_f64(x);
let keys: Vec<Tick> = (0..40).map(|i| s(i as f64)).collect();
// Default: no more than 15 keyframes, centred on the current time.
assert_eq!(p.motion_path_span(&keys, s(20.0)), Some((s(13.0), s(27.0))));
assert_eq!(p.motion_path_span(&keys, s(0.0)), Some((s(0.0), s(14.0))));
p.composition.motion_path = "all".into();
assert_eq!(p.motion_path_span(&keys, s(5.0)), Some((s(0.0), s(39.0))));
p.composition.motion_path = "none".into();
assert_eq!(p.motion_path_span(&keys, s(5.0)), None);
p.composition.motion_path = "seconds".into();
p.composition.motion_path_seconds = 4.0;
assert_eq!(p.motion_path_span(&keys, s(10.0)), Some((s(8.0), s(12.0))));
}
#[test]
fn import_export_helpers() {
use effectcraft_project::AlphaMode;
let mut p = crate::prefs::Prefs::default();
assert_eq!(p.unlabeled_alpha(false), None, "Ask User");
for (v, movie, want) in [
("ignore", false, AlphaMode::Ignore),
("premultiplied", false, AlphaMode::Premultiplied),
("guess", true, AlphaMode::Premultiplied),
("guess", false, AlphaMode::Straight),
] {
p.import.unlabeled_alpha = v.into();
assert_eq!(p.unlabeled_alpha(movie), Some(want));
}
assert_eq!(p.drag_import_as(), "footage");
p.import.drag_import_as = "compLayerSizes".into();
assert_eq!(p.drag_import_as(), "compositionLayerSizes");
assert_eq!(
super::commands::missing_frames_for_tests(&["a_001.png".into(), "a_002.png".into(), "a_005.png".into(), "a_007.png".into()]).unwrap(),
"3 missing frames (3–4, 6)"
);
assert!(super::commands::missing_frames_for_tests(&["a1.png".into(), "a2.png".into()]).is_none());
// Append Bit Depth to File Name.
assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1.png");
p.export.append_bits_to_name = true;
assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1_16bpc.png");
assert_eq!(p.output_name("Comp_[#####].exr", 32), "Comp_[#####]_32bpc.exr");
// Segments: sequences by file count, movies by size at the data rate.
assert_eq!(p.segment_frames(100, true, 0.0), None);
p.export.segment_sequences = true;
p.export.segment_sequence_files = 30;
assert_eq!(p.segment_frames(100, true, 0.0), Some(30));
p.export.segment_movies = true;
p.export.segment_movie_mb = 1;
assert_eq!(p.segment_frames(100, false, 1_048_576.0 / 10.0), Some(10));
assert_eq!(p.segment_frames(5, false, 1_048_576.0 / 10.0), None);
// Recent fonts.
for f in ["A", "B", "C", "A"] {
p.push_recent_font(f);
}
p.type_.recent_fonts = 2;
assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]);
}
#[test]
fn render_queue_uses_default_folder_bit_depth_names_and_segments() {
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
let mut s = session();
solid(&mut s, "#ff0000");
let dir = tmp("out");
s.execute("prefs.set", json!({"values": {"export.defaultOutputFolder": dir.to_string_lossy(), "export.appendBitsToName": true}})).unwrap();
let cid = s.active_comp_id().unwrap();
let mut item = RenderQueueItem::new(1, cid);
item.output.output = "Main.png".into();
item.output.format = OutputFormat::PngSequence;
let out = s.resolve_output(&item).unwrap();
assert_eq!(PathBuf::from(&out), dir.join("Main.png"));
assert_eq!(
s.prefs.output_name(&out, crate::render_queue::output_bits(OutputFormat::PngSequence, s.project.settings.bit_depth)),
dir.join("Main_8bpc.png").to_string_lossy()
);
// 20 frames in segments of 8 files: three folders, spans tiling the work area.
s.prefs.export.segment_sequences = true;
s.prefs.export.segment_sequence_files = 8;
let segs = s.segments(&item, &out);
assert_eq!(segs.len(), 3);
let comp = s.active_comp().unwrap().clone();
let n: Vec<u64> = segs.iter().map(|(i, _)| i.settings.frame_count(&comp)).collect();
assert_eq!(n, vec![8, 8, 4]);
assert!(segs[1].1.contains("Main_002"), "{}", segs[1].1);
assert_eq!(segs[2].0.settings.first_frame(&comp), 16);
// Movies: a ~1 MB file per segment at the H.264 bitrate.
s.prefs.export.segment_movies = true;
s.prefs.export.segment_movie_mb = 1;
item.output.format = OutputFormat::H264;
item.output.bitrate_kbps = 4000; // 50 kB per frame at 10 fps
let segs = s.segments(&item, "/o/Main.mp4");
assert_eq!(segs.len(), 1, "20 frames × 50 kB fit in 1 MB");
item.output.bitrate_kbps = 8000; // 100 kB per frame: 10 frames per MB
let segs = s.segments(&item, "/o/Main.mp4");
assert_eq!(segs.iter().map(|(_, p)| p.as_str()).collect::<Vec<_>>(), ["/o/Main_001.mp4", "/o/Main_002.mp4"]);
}
#[test]
fn every_wired_setting_is_live_and_display_only_ones_are_documented() {
let todo = crate::prefs::todo_keys();
assert_eq!(todo, ["general.useSystemColorPicker", "composition.hardwareAcceleratePanels", "scripting.enableJsDebugger"]);
let docs = include_str!("../../../docs/preferences.md");
assert!(docs.contains("### Display-only settings"));
assert!(!docs.contains("Not wired yet"));
}
// ---------------------------------------------------------------- scripting gate
#[test]
fn execute_file_needs_the_gate_and_asks_first() {
let mut s = session();
let f = tmp("run.txt");
std::fs::write(&f, "x").unwrap();
let p = json!({"path": f.to_string_lossy()});
assert!(s.execute("file.executeFile", p.clone()).is_err(), "off by default");
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
s.drain_events();
let r = s.execute("file.executeFile", p.clone()).unwrap();
assert!(r.get("needsConfirmation").is_some());
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.confirmExecute")));
let r = s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap();
assert!(r.get("opened").is_some());
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.starts_with("file://"))));
s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap();
assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some());
}
// ---------------------------------------------------------------- Edit ▸ History, recent lists
#[test]
fn history_submenu_jumps_back_several_steps() {
let mut s = session();
solid(&mut s, "#ff0000");
solid(&mut s, "#00ff00");
solid(&mut s, "#0000ff");
let list = s.execute("edit.history", json!({})).unwrap();
assert_eq!(list["undo"][0], json!("New Solid"));
let n = s.history.undo.len();
let (entries, _) = crate::menus::dynamic(&s, "history", &Default::default());
assert_eq!(entries.len(), n);
assert_eq!(entries[1].params, json!({"steps": 2}));
s.execute("edit.history", json!({"steps": 2})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
assert_eq!(s.history.redo.len(), 2);
s.execute("edit.history", json!({"redo": 2})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 3);
assert!(s.execute("edit.history", json!({"steps": 999})).is_err());
}
#[test]
fn recent_footage_and_presets_are_remembered() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
// Imports are remembered even when the build has no decoder (the error comes after).
let _ = s.execute("file.import", json!({"paths": ["/x/clip.mov", "/x/still.png"]}));
assert_eq!(s.prefs.recent_footage, ["/x/still.png", "/x/clip.mov"]);
let (e, _) = crate::menus::dynamic(&s, "recentFootage", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["still.png", "clip.mov"]);
s.execute("file.clearRecentFootage", json!({})).unwrap();
assert_eq!(crate::menus::dynamic(&s, "recentFootage", &Default::default()), (vec![], Some("No Recent Footage")));
// Presets.
let path = tmp("fade.ecpreset");
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
s.execute("anim.savePreset", json!({"path": path})).unwrap();
s.execute("anim.applyPreset", json!({"path": path, "layers": [a]})).unwrap();
assert_eq!(s.prefs.recent_presets, [path.to_string_lossy().to_string()]);
let (e, _) = crate::menus::dynamic(&s, "recentPresets", &Default::default());
assert_eq!(e[0].label, "fade");
s.state.selected_layers = vec![LayerId(a)];
s.execute("anim.applyRecentPreset", json!({"index": 0})).unwrap();
s.execute("anim.clearRecentPresets", json!({})).unwrap();
assert!(s.prefs.recent_presets.is_empty());
// Recent lists survive a settings round trip and a reset.
s.prefs.push_recent_footage("/y/a.wav");
let back = crate::prefs::Prefs::from_json(&s.prefs.to_json());
assert_eq!(back.recent_footage, ["/y/a.wav"]);
s.execute("prefs.reset", json!({})).unwrap();
assert_eq!(s.prefs.recent_footage, ["/y/a.wav"]);
}
#[test]
fn save_a_copy_as_xml_opens_again() {
let mut s = session();
solid(&mut s, "#ff0000");
let path = tmp("copy.ecprojx");
s.execute("file.saveCopyAsXml", json!({"path": path.to_string_lossy()})).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("<?xml") && text.contains("<EffectCraftProject"));
let before = s.project.to_json();
let mut t = Session::default();
t.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(t.project.to_json(), before);
// Saving a project opened from XML writes XML.
t.execute("file.save", json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().starts_with("<?xml"));
}
// ---------------------------------------------------------------- shortcut slots
#[test]
fn assign_shortcut_slots_move_keys_between_views_and_workspaces() {
let mut s = session();
s.execute("view.3d.top", json!({})).unwrap();
let label = crate::menus::submenu_label(&s, "Assign Shortcut to 3D View", &Default::default());
assert_eq!(label, "Assign Shortcut to “Top”");
let (e, _) = crate::menus::dynamic(&s, "view3dShortcuts", &Default::default());
assert_eq!(e[0].label, "F10 (Replace “Front”)");
let r = s.execute("view.assign3dShortcut", json!({"slot": "F10"})).unwrap();
assert_eq!(r["replaced"], json!(["Front"]));
assert_eq!(s.shortcuts().shortcut_of("view.3d.top", &Value::Null), Some("F10"));
assert_eq!(s.shortcuts().shortcut_of("view.3d.front", &Value::Null), None);
// Workspaces: Shift+F11 now opens Animation instead of Standard.
s.execute("window.assignWorkspaceShortcut", json!({"slot": "Shift+F11", "workspace": "Animation"})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("window.workspace", &json!({"name": "Animation"})), Some("Shift+F11"));
assert_eq!(s.shortcuts().shortcut_of("window.workspace", &json!({"name": "Standard"})), None);
assert!(s.execute("view.assign3dShortcut", json!({"slot": "F9"})).is_err());
// Without a workspace the frontend fills it in.
s.drain_events();
s.execute("window.assignWorkspaceShortcut", json!({"slot": "F10"})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "window.assignWorkspaceShortcut")));
}
// ---------------------------------------------------------------- Help
#[test]
fn compatibility_report_and_logging() {
let mut s = session();
let r = s.execute("help.systemReport", json!({"quiet": true})).unwrap();
for k in ["os", "cpu", "cores", "memory", "gpu", "import", "export", "issues"] {
assert!(r.get(k).is_some(), "{k} in {r}");
}
let text = crate::commands::report_text_for_tests(&r);
assert!(text.contains("Operating system") && text.contains("Import:"));
s.drain_events();
s.execute("help.systemReport", json!({})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.showReport")));
// Logging into a file in the settings folder.
let dir = tmp("cfg");
std::fs::create_dir_all(&dir).unwrap();
s.config = Some(std::sync::Arc::new(crate::config::DirConfig::new(dir.clone())));
let r = s.execute("help.enableLogging", json!({"on": true})).unwrap();
let file = PathBuf::from(r["path"].as_str().unwrap());
assert!(file.starts_with(&dir));
log::warn!(target: "effectcraft::tests", "hello log");
assert!(std::fs::read_to_string(&file).unwrap().contains("logging enabled"));
assert_eq!(crate::menus::checked(&s, "help.enableLogging", &Value::Null), Some(true));
let r = s.execute("help.revealLogFile", json!({})).unwrap();
assert!(r["url"].as_str().unwrap().starts_with("file://"));
s.execute("help.enableLogging", json!({"on": false})).unwrap();
assert!(!crate::logging::is_enabled());
}
// ---------------------------------------------------------------- VR
#[test]
fn create_vr_environment_and_extract_cubemap() {
let mut s = session();
let main = s.active_comp_id().unwrap();
let a = solid(&mut s, "#ff0000");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap();
let undo = s.history.undo.len();
let aname = layer(&s, a).name.clone();
let r = s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.history.undo.last().unwrap().0, "Create VR Environment");
let faces: Vec<u64> = r["faces"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(faces.len(), 6);
for f in &faces {
let c = s.project.comp(ItemId(*f)).unwrap();
assert_eq!((c.width, c.height), (64, 64));
assert!(c.layers.iter().any(|l| l.is_camera()));
assert!(c.layers.iter().any(|l| l.name == aname && l.is_3d()), "the scene's layers");
let _ = main;
}
let cube = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap();
assert_eq!((cube.width, cube.height, cube.layers.len()), (192, 128, 6));
let out = s.project.comp(ItemId(r["output"].as_u64().unwrap())).unwrap();
assert_eq!((out.width, out.height), (128, 64));
let fx = out.layers[0].props.sub("effects").unwrap().groups().next().unwrap().clone();
assert_eq!(fx.match_id, "ec.vr.converter");
// Every face camera looks a different way.
let mut dirs = vec![];
for f in &faces {
let c = s.project.comp(ItemId(*f)).unwrap();
let cam = c.layers.iter().find(|l| l.is_camera()).unwrap();
dirs.push(format!("{:?}", cam.props.prop("transform/orientation").unwrap().value));
}
dirs.sort();
dirs.dedup();
assert_eq!(dirs.len(), 6, "{dirs:?}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.comp(ItemId(faces[0])).is_none());
// Extract Cubemap of a 2:1 comp.
let eq = s.execute("comp.new", json!({"name": "Equi", "width": 128, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let r = s.execute("comp.vr.extractCubemap", json!({"comp": eq})).unwrap();
let c = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap();
assert_eq!((c.width, c.height), (96, 64));
}
/// The equirectangular output sees each wall of a box around the rig in the right direction.
#[test]
fn vr_environment_faces_look_the_right_way() {
// One wall at a time (centre 100,100,0; AE's −Y is up), and where the equirectangular output
// must show it: front +Z, back −Z, right +X, left −X, up, down.
let walls = [
("front", [100.0, 100.0, 150.0], [0.0, 0.0], (0.5, 0.5)),
("back", [100.0, 100.0, -150.0], [0.0, 180.0], (0.01, 0.5)),
("right", [250.0, 100.0, 0.0], [0.0, 90.0], (0.75, 0.5)),
("left", [-50.0, 100.0, 0.0], [0.0, 90.0], (0.25, 0.5)),
("up", [100.0, -50.0, 0.0], [90.0, 0.0], (0.5, 0.03)),
("down", [100.0, 250.0, 0.0], [90.0, 0.0], (0.5, 0.97)),
];
for (name, pos, rot, (fx, fy)) in walls {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Room", "width": 200, "height": 200, "duration": 1, "frameRate": 10})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": pos})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/rotationX", "value": rot[0]})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/rotationY", "value": rot[1]})).unwrap();
let r = s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap();
let img = s.render(ItemId(r["output"].as_u64().unwrap()), Tick::ZERO, RenderOpts::default());
let (w, h) = (img.width as f64, img.height as f64);
let alpha =
|x: f64, y: f64| img.data[((y * h) as usize).min(img.height as usize - 1) * img.width as usize + ((x * w) as usize).min(img.width as usize - 1)][3];
assert!(alpha(fx, fy) > 0.5, "{name}: the wall shows in its direction");
// The opposite direction is empty.
let (ox, oy) = if fy != 0.5 { (fx, 1.0 - fy) } else { ((fx + 0.5) % 1.0, fy) };
assert!(alpha(ox, oy) < 0.5, "{name}: nothing in the opposite direction");
}
}
// ---------------------------------------------------------------- keyframes
#[test]
fn convert_audio_to_keyframes_makes_the_amplitude_null() {
assert_eq!(crate::commands::amplitudes_for_tests(&[0.5, -0.25, -0.5, 0.25]), (50.0, 25.0, 37.5));
let mut s = session();
let undo = s.history.undo.len();
let r = s.execute("keys.audioToKeyframes", json!({})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1);
let l = layer(&s, r["layer"].as_u64().unwrap());
assert_eq!(l.name, "Audio Amplitude");
let fx: Vec<_> = l.props.sub("effects").unwrap().groups().map(|g| (g.name.clone(), g.get("slider").unwrap().keys.len())).collect();
assert_eq!(fx, [("Left Channel".to_string(), 20), ("Right Channel".to_string(), 20), ("Both Channels".to_string(), 20)]);
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layers.is_empty());
}
#[test]
fn keyframe_labels_and_label_groups() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
for t in [0.0, 0.5, 1.0] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": 50})).unwrap();
}
let uid = layer(&s, a).props.prop("transform/opacity").unwrap().uid;
let k = |t: f64| KeyRef { layer: LayerId(a), prop: uid, time: Tick::from_seconds_f64(t) };
s.state.selected_keys = vec![k(0.0), k(1.0)];
// Edit ▸ Label colours the selected keyframes.
s.execute("edit.label", json!({"label": "Blue"})).unwrap();
let labels: Vec<u8> = layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.label).collect();
let blue = effectcraft_color::Label::ALL.iter().position(|l| *l == effectcraft_color::Label::Blue).unwrap() as u8;
assert_eq!(labels, [blue, 0, blue]);
assert_eq!(layer(&s, a).label, effectcraft_color::Label::Red, "the layer keeps its label");
// Serde: labels round-trip; unlabeled keys don't write the field.
let json = s.project.to_json();
assert!(json.contains(&format!("\"label\": {blue}")) || json.contains(&format!("\"label\":{blue}")));
let back = effectcraft_project::Project::from_json(&json).unwrap();
assert_eq!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[2].label, blue);
// Moving a labelled key's value keeps the label.
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 80})).unwrap();
assert_eq!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[2].label, blue);
// Select Keyframe Label Group.
s.state.selected_keys = vec![k(0.0)];
let r = s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap();
assert_eq!(r["keys"], 2);
s.state.selected_keys = vec![k(0.5)];
s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap();
assert_eq!(s.state.selected_keys, vec![k(0.5)]);
s.state.selected_keys = vec![k(0.0)];
let r = s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [12345]})).unwrap();
assert_eq!(r["keys"], 0, "only the given (visible) properties");
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().all(|k| k.label == 0));
}
#[test]
fn rpf_camera_import_reads_json_and_csv_camera_data() {
let mut s = session();
let json_path = tmp("cam.json");
std::fs::write(
&json_path,
r#"{"frameRate": 10, "frames": [{"frame": 0, "position": [0, 0, -500], "rotation": [0, 0, 0], "fov": 90}, {"frame": 10, "position": [100, 0, -500], "orientation": [0, 30, 0], "zoom": 400}]}"#,
)
.unwrap();
let undo = s.history.undo.len();
let r = s.execute("keys.rpfCameraImport", json!({"path": json_path.to_string_lossy()})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1);
let cam = layer(&s, r["layer"].as_u64().unwrap());
assert!(cam.is_camera());
let pos = &cam.props.prop("transform/position").unwrap().keys;
assert_eq!(pos.len(), 2);
assert_eq!(pos[1].value, KV::Vec3([100.0, 0.0, -500.0]));
assert!((pos[1].time.seconds() - 1.0).abs() < 1e-9);
let zoom = &cam.props.prop("cameraOptions/zoom").unwrap().keys;
assert!((zoom[0].value.as_f64() - 32.0).abs() < 1e-6, "90° across 64 px = zoom 32: {:?}", zoom[0].value);
let csv = tmp("cam.csv");
std::fs::write(&csv, "frame,px,py,pz,rx,ry,rz,zoom\n0,1,2,3,0,0,0,100\n5,4,5,6,10,20,30,200\n").unwrap();
let r = s.execute("keys.rpfCameraImport", json!({"path": csv.to_string_lossy()})).unwrap();
let cam = layer(&s, r["layer"].as_u64().unwrap());
let o = &cam.props.prop("transform/orientation").unwrap().keys;
assert_eq!(o[1].value, KV::Vec3([10.0, 20.0, 30.0]));
assert!(s.execute("keys.rpfCameraImport", json!({"path": "/x/scene.rpf"})).unwrap_err().to_string().contains("JSON or CSV"));
}
// ---------------------------------------------------------------- menus
#[test]
fn dynamic_menus_and_window_viewer_labels() {
let mut s = session();
s.prefs.push_recent("/p/One.ecproj");
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent"));
assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len());
let a = solid(&mut s, "#ff0000");
s.state.selected_layers = vec![LayerId(a)];
let entry = |cmd: &str, panel: &str| crate::menus::MenuEntry { label: "x".into(), command: cmd.into(), params: json!({"panel": panel}), shortcut: None };
assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "composition")), "Composition: Main");
assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "timeline")), "Timeline: Main");
assert!(crate::menus::entry_label(&s, &entry("window.panel", "layer")).starts_with("Layer: "));
assert_eq!(crate::menus::entry_label(&s, &entry("window.unavailablePanel", "footage")), "Footage: (none)");
// Other open comps are listed as viewers.
let other = s.execute("comp.new", json!({"name": "Second"})).unwrap()["comp"].as_u64().unwrap();
let (e, _) = crate::menus::dynamic(&s, "openViewers", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Composition: Main"]);
assert_eq!(e[0].command, "comp.open");
let _ = other;
// Every tree entry of the new menus resolves.
for (path, e) in crate::menus::entries() {
if ["History", "VR", "Recent Animation Presets", "Import Recent Footage"].iter().any(|p| path.contains(&p.to_string())) {
assert!(crate::commands::find(&e.command).is_some());
}
}
let labels: Vec<String> = crate::menus::entries().iter().map(|(_, e)| e.label.clone()).collect();
for l in [
"Save a Copy As XML...",
"With Layered Comp",
"Create VR Environment...",
"Extract Cubemap...",
"System Compatibility Report...",
"Enable Logging",
"Reveal Logging File",
] {
assert!(labels.iter().any(|x| x == l), "{l}");
}
}

View File

@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
for id in ["keys.audioToKeyframes", "layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] {
for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}

View File

@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
}
// What remains a stub is still disabled.
for id in ["layer.autoTrace", "keys.audioToKeyframes"] {
for id in ["layer.autoTrace", "layer.sceneEditDetection"] {
assert!(!s.is_enabled(id), "{id}");
}
}

View File

@ -0,0 +1,265 @@
//! File ▸ Save a Copy As XML…: the project as an XML document (`.ecprojx`), the counterpart of
//! After Effects' `.aepx`. It is a lossless, element-per-value encoding of the `.ecproj` JSON, so
//! File ▸ Open reads it back (and saving to a `.ecprojx` path writes XML again):
//!
//! ```xml
//! <?xml version="1.0" encoding="UTF-8"?>
//! <EffectCraftProject encoding="json" version="1">
//! <obj>
//! <m n="version"><num>3</num></m>
//! <m n="items"><arr>…</arr></m>
//! </obj>
//! </EffectCraftProject>
//! ```
//!
//! `obj`/`m n="key"` (object members), `arr`, `str`, `num`, `bool`, `null`.
use serde_json::{Map, Value};
const ROOT: &str = "EffectCraftProject";
/// Whether `text` looks like an XML project.
pub fn is_xml(text: &str) -> bool {
let t = text.trim_start_matches('\u{feff}').trim_start();
t.starts_with("<?xml") || t.starts_with(&format!("<{ROOT}"))
}
/// Whether a path names an XML project copy.
pub fn is_xml_path(path: &str) -> bool {
let p = path.to_ascii_lowercase();
p.ends_with(".ecprojx") || p.ends_with(".xml")
}
fn esc(s: &str, out: &mut String) {
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\r' => out.push_str("&#13;"),
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => out.push_str(&format!("&#{};", c as u32)),
c => out.push(c),
}
}
}
fn write(v: &Value, depth: usize, out: &mut String) {
let pad = " ".repeat(depth);
out.push_str(&pad);
match v {
Value::Null => out.push_str("<null/>"),
Value::Bool(b) => out.push_str(&format!("<bool>{b}</bool>")),
Value::Number(n) => out.push_str(&format!("<num>{n}</num>")),
Value::String(s) => {
out.push_str("<str>");
esc(s, out);
out.push_str("</str>");
}
Value::Array(a) if a.is_empty() => out.push_str("<arr/>"),
Value::Array(a) => {
out.push_str("<arr>\n");
for x in a {
write(x, depth + 1, out);
}
out.push_str(&pad);
out.push_str("</arr>");
}
Value::Object(m) if m.is_empty() => out.push_str("<obj/>"),
Value::Object(m) => {
out.push_str("<obj>\n");
for (k, x) in m {
out.push_str(&" ".repeat(depth + 1));
out.push_str("<m n=\"");
esc(k, out);
out.push_str("\">\n");
write(x, depth + 2, out);
out.push_str(&" ".repeat(depth + 1));
out.push_str("</m>\n");
}
out.push_str(&pad);
out.push_str("</obj>");
}
}
out.push('\n');
}
/// The project JSON as an XML document.
pub fn to_xml(json: &str) -> Result<String, String> {
let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
let mut out = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<{ROOT} encoding=\"json\" version=\"1\">\n");
write(&v, 1, &mut out);
out.push_str(&format!("</{ROOT}>\n"));
Ok(out)
}
struct P<'a> {
s: &'a str,
i: usize,
}
impl<'a> P<'a> {
fn err<T>(&self, m: &str) -> Result<T, String> {
Err(format!("XML project: {m} at byte {}", self.i))
}
fn rest(&self) -> &'a str {
&self.s[self.i..]
}
/// Skip whitespace, comments and processing instructions.
fn skip(&mut self) {
loop {
let t = self.rest();
let trimmed = t.trim_start();
self.i += t.len() - trimmed.len();
if trimmed.starts_with("<?") {
self.i += trimmed.find("?>").map_or(trimmed.len(), |e| e + 2);
} else if trimmed.starts_with("<!--") {
self.i += trimmed.find("-->").map_or(trimmed.len(), |e| e + 3);
} else {
return;
}
}
}
/// An opening tag: (name, attribute `n`, self-closing).
fn open(&mut self) -> Result<(String, Option<String>, bool), String> {
self.skip();
if !self.rest().starts_with('<') || self.rest().starts_with("</") {
return self.err("expected an element");
}
let end = self.rest().find('>').ok_or("XML project: unterminated tag")?;
let inner = &self.rest()[1..end];
self.i += end + 1;
let (inner, closed) = match inner.strip_suffix('/') {
Some(x) => (x, true),
None => (inner, false),
};
let mut parts = inner.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or_default().to_string();
let attrs = parts.next().unwrap_or("");
let n = attrs.find("n=\"").map(|a| {
let v = &attrs[a + 3..];
unesc(&v[..v.find('"').unwrap_or(v.len())])
});
Ok((name, n, closed))
}
fn close(&mut self, name: &str) -> Result<(), String> {
self.skip();
let want = format!("</{name}>");
if self.rest().starts_with(&want) {
self.i += want.len();
Ok(())
} else {
self.err(&format!("expected {want}"))
}
}
fn text(&mut self) -> String {
let end = self.rest().find('<').unwrap_or(self.rest().len());
let t = unesc(&self.rest()[..end]);
self.i += end;
t
}
fn value(&mut self) -> Result<Value, String> {
let (name, _, closed) = self.open()?;
let v = match (name.as_str(), closed) {
("null", _) => Value::Null,
("arr", true) => Value::Array(vec![]),
("obj", true) => Value::Object(Map::new()),
("str", true) => Value::String(String::new()),
("bool", false) => Value::Bool(self.text().trim() == "true"),
("num", false) => serde_json::from_str(self.text().trim()).map_err(|e| format!("XML project: bad number: {e}"))?,
("str", false) => Value::String(self.text()),
("arr", false) => {
let mut a = vec![];
loop {
self.skip();
if self.rest().starts_with("</") {
break;
}
a.push(self.value()?);
}
Value::Array(a)
}
("obj", false) => {
let mut m = Map::new();
loop {
self.skip();
if self.rest().starts_with("</") {
break;
}
let (tag, key, _) = self.open()?;
if tag != "m" {
return self.err("expected <m n=\"…\">");
}
let v = self.value()?;
self.close("m")?;
m.insert(key.unwrap_or_default(), v);
}
Value::Object(m)
}
(other, _) => return self.err(&format!("unexpected <{other}>")),
};
if !closed && name != "null" {
self.close(&name)?;
}
Ok(v)
}
}
fn unesc(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(a) = rest.find('&') {
out.push_str(&rest[..a]);
rest = &rest[a..];
let Some(e) = rest.find(';') else { break };
let ent = &rest[1..e];
match ent {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
n if n.starts_with("#x") => out.extend(u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32)),
n if n.starts_with('#') => out.extend(n[1..].parse::<u32>().ok().and_then(char::from_u32)),
other => {
out.push('&');
out.push_str(other);
out.push(';');
}
}
rest = &rest[e + 1..];
}
out.push_str(rest);
out
}
/// The project JSON of an XML project document.
pub fn from_xml(text: &str) -> Result<String, String> {
let mut p = P { s: text.trim_start_matches('\u{feff}'), i: 0 };
let (root, _, _) = p.open()?;
if root != ROOT {
return Err(format!("not an EffectCraft XML project (root <{root}>)"));
}
let v = p.value()?;
p.close(ROOT)?;
serde_json::to_string(&v).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_round_trips_through_xml() {
let j = serde_json::json!({"a": [1, 2.5, -3e-7, true, null, "x<y & \"z\"\n\tq"], "b": {}, "c": [], "d": "", "é": {"n": "ünï"}});
let xml = to_xml(&j.to_string()).unwrap();
assert!(is_xml(&xml));
assert!(xml.contains("<m n=\"a\">"));
let back: Value = serde_json::from_str(&from_xml(&xml).unwrap()).unwrap();
assert_eq!(back, j);
assert!(from_xml("<Other/>").is_err());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -163,7 +163,7 @@ pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState {
/// 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
@ -366,9 +366,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);
}
}
@ -387,7 +387,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;
@ -682,7 +682,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;
}
@ -711,7 +711,7 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3
order: it.order,
})
.collect();
let casters = planes.iter().enumerate().filter(|(_, p)| p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect();
let casters = planes.iter().enumerate().filter(|(_, p)| r.opts.shadows() && p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect();
Some(Run3d { planes, casters, lights: g.lights, seed: run[0].id.0 as u32 })
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -779,6 +779,10 @@ pub fn draw_group_chrome(
}
} else {
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
if t.gradients {
// Settings ▸ Appearance ▸ Use Gradients.
crate::theme::gradient_rect(&painter, strip.shrink2(vec2(t.radius, 0.0)), t.grad_top(t.panel_bg), t.panel_bg);
}
let mut x = strip.min.x + 12.0;
let text_y = strip.min.y + 16.0;
for (i, p) in g.panels.iter().enumerate() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ pub mod effect_controls;
pub mod effects_presets;
pub mod essential;
pub mod expr_bar;
pub mod expr_editor;
pub mod flowchart;
pub mod forms;
pub mod fx_widgets;

View File

@ -98,9 +98,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
widgets::open_popup(ui, pop);
}
app.auto.add("character.font", fr, "Font family");
let fams: Vec<String> = effectcraft_engine::text_families();
if let Some(i) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)) {
actions.push(json!({"font": fams[i]}));
if let Some(f) = font_popup(app, ui, pop, fr.left_bottom(), &doc.font) {
actions.push(json!({"font": f}));
}
y += 28.0;
let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
@ -365,20 +364,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 {
@ -406,6 +414,56 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// The font menu: recent fonts first, names in English or the fonts' own language, and a
/// "Sample" preview in each font (Settings ▸ Type).
fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option<String> {
if !ui.data(|d| d.get_temp::<bool>(id.with("open")).unwrap_or(false)) {
return None;
}
let rows = effectcraft_engine::font_menu(&app.session.prefs);
let preview = app.session.prefs.type_.font_preview;
let t = app.tokens;
let mut chosen = None;
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(if preview { 300.0 } else { 180.0 });
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
for r in &rows {
if r.family.is_empty() {
ui.separator();
continue;
}
let resp = ui.selectable_label(r.family == current, &r.display);
if preview && ui.is_rect_visible(resp.rect) {
let key = egui::Id::new(("font-preview", &r.family));
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
Some(l) => l,
None => {
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
ui.data_mut(|d| d.insert_temp(key, l.clone()));
l
}
};
let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0);
for poly in lines.iter() {
let pts: Vec<egui::Pos2> = poly.iter().map(|p| o + vec2(p[0], p[1])).collect();
ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
}
}
if resp.clicked() {
chosen = Some(r.family.clone());
}
}
});
});
});
let outside = ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !area.response.hovered();
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(id.with("open"), false));
}
chosen
}
/// Align panel: align selected layers to the composition (or to the selection).
pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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