Merge branch 'worktree-agent-a798505697e9a8db3'

# Conflicts:
#	crates/engine/src/commands/mod.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-02 09:23:25 -04:00
41 changed files with 4390 additions and 252 deletions
+193
View File
@@ -0,0 +1,193 @@
//! Auto-save and crash recovery (PRJ-7).
//!
//! - **Auto-save**: every *n* minutes a dirty project is written to an "EffectCraft Auto-Save"
//! folder next to it (or a custom folder) as `<name> auto-save N.ecproj`, the After Effects
//! naming. Slots rotate through 1…max versions, overwriting the oldest. Writes are atomic
//! (temporary file + rename), so a crash mid-write never damages the previous auto-save.
//! - **Crash recovery**: while the app runs, a sentinel (`session.lock` in the config store)
//! records the open project and its latest auto-save. A clean exit removes it; finding one at
//! launch means the last session ended unexpectedly, and the frontend offers to open the
//! latest auto-save.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::ConfigStore;
use crate::prefs::Prefs;
/// Folder created next to a project for its auto-saves.
pub const AUTOSAVE_FOLDER: &str = "EffectCraft Auto-Save";
/// Crash-recovery sentinel in the config store.
pub const SENTINEL: &str = "session.lock";
const EXT: &str = "ecproj";
/// Auto-save bookkeeping of a session.
#[derive(Clone, Debug, Default)]
pub struct AutoSaveState {
/// Wall-clock seconds of the last auto-save (or of the first tick).
pub last: Option<f64>,
/// Slot written last in this session.
pub last_slot: Option<u32>,
/// Revision written last (an unchanged project isn't saved again).
pub saved_revision: Option<u64>,
/// Path of the latest auto-save.
pub last_path: Option<String>,
/// This session owns the crash-recovery sentinel ([`crate::Session::begin_recovery`]).
pub sentinel: bool,
}
/// File-name stem of a project (`/a/Intro.ecproj` → `Intro`; untitled → `Untitled Project`).
pub fn project_stem(path: Option<&str>) -> String {
path.and_then(|p| Path::new(p).file_stem()).map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled Project".into())
}
/// `<stem> auto-save N.ecproj`.
pub fn file_name(stem: &str, n: u32) -> String {
format!("{stem} auto-save {n}.{EXT}")
}
/// The slot number of an auto-save file name for `stem`.
pub fn slot_of(stem: &str, name: &str) -> Option<u32> {
let rest = name.strip_prefix(stem)?.strip_prefix(" auto-save ")?;
rest.strip_suffix(&format!(".{EXT}"))?.parse().ok()
}
/// Where a project's auto-saves go: next to the project, or the custom folder; untitled projects
/// use the custom folder or `fallback` (the app's own Auto-Save folder).
pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
let a = &prefs.auto_save;
if a.location == "custom" && !a.folder.trim().is_empty() {
return Some(PathBuf::from(a.folder.trim()));
}
match project.and_then(|p| Path::new(p).parent()) {
Some(dir) => Some(dir.join(AUTOSAVE_FOLDER)),
None => fallback.map(|f| f.join(AUTOSAVE_FOLDER)),
}
}
/// Existing auto-saves of `stem` in `dir`: (slot, path, modified).
pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut v: Vec<_> = rd
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let n = slot_of(stem, &name)?;
let m = e.metadata().ok()?.modified().ok()?;
Some((n, e.path(), m))
})
.collect();
v.sort_by_key(|x| x.0);
v
}
/// The slot to write next: after `last` (this session), else after the newest file on disk,
/// wrapping at `max`.
pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Option<u32>, max: u32) -> u32 {
let max = max.max(1);
let after = last.or_else(|| existing.iter().filter(|e| e.0 <= max).max_by_key(|e| (e.2, e.0)).map(|e| e.0));
match after {
Some(n) => n % max + 1,
None => 1,
}
}
/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path.
pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option<u32>, json: &str) -> std::io::Result<(PathBuf, u32)> {
let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?;
std::fs::create_dir_all(&dir)?;
let stem = project_stem(project);
let max = prefs.auto_save.max_versions.max(1);
let have = existing(&dir, &stem);
let slot = next_slot(&have, last_slot, max);
let path = dir.join(file_name(&stem, slot));
crate::config::atomic_write(&path, json.as_bytes())?;
// Max versions lowered: drop slots above it.
for (n, p, _) in have {
if n > max {
let _ = std::fs::remove_file(p);
}
}
Ok((path, slot))
}
/// The newest auto-save of a project, if any.
pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
let dir = folder(prefs, project, fallback)?;
existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
}
/// What the crash-recovery sentinel records.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Sentinel {
pub pid: u32,
/// The open project (None = untitled).
pub project: Option<String>,
/// Latest auto-save of this run.
pub autosave: Option<String>,
/// Whether the project had unsaved changes.
pub dirty: bool,
}
/// A previous run that didn't exit cleanly.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Recovery {
pub project: Option<String>,
/// The auto-save to offer (exists on disk).
pub autosave: Option<String>,
pub dirty: bool,
}
/// At launch: report an unclean previous exit (a leftover sentinel), then start this run's
/// sentinel.
pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option<Recovery> {
let found = store.read(SENTINEL).map(|t| serde_json::from_str::<Sentinel>(&t).unwrap_or_default());
let _ = store.write(SENTINEL, &serde_json::to_string(&Sentinel { pid: pid(), ..Default::default() }).unwrap_or_default());
let s = found?;
let fallback = store.dir();
let autosave = s
.autosave
.clone()
.filter(|p| Path::new(p).is_file())
.or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
Some(Recovery { project: s.project, autosave, dirty: s.dirty })
}
/// This process id (0 where processes don't exist, e.g. the browser).
pub fn pid() -> u32 {
#[cfg(not(target_arch = "wasm32"))]
return std::process::id();
#[cfg(target_arch = "wasm32")]
0
}
/// Record the current project / latest auto-save in the sentinel.
pub fn update(store: &dyn ConfigStore, s: &Sentinel) {
let _ = store.write(SENTINEL, &serde_json::to_string(s).unwrap_or_default());
}
/// Clean exit: remove the sentinel.
pub fn end(store: &dyn ConfigStore) {
let _ = store.remove(SENTINEL);
}
/// After Effects' Increment and Save name: `Intro.ecproj` → `Intro 2.ecproj`, `Intro 2.ecproj`
/// → `Intro 3.ecproj` (skipping names that exist).
pub fn increment_path(path: &str, exists: impl Fn(&str) -> bool) -> String {
let p = Path::new(path);
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let (base, mut n) = match stem.rsplit_once(' ') {
Some((b, n)) if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => (b.to_string(), n.parse::<u32>().unwrap_or(1) + 1),
_ => (stem.clone(), 2),
};
loop {
let cand = p.with_file_name(format!("{base} {n}.{EXT}")).to_string_lossy().to_string();
if !exists(&cand) {
return cand;
}
n += 1;
}
}
+4
View File
@@ -93,6 +93,10 @@ fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").unwrap_or("Comp 1").to_string();
let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97);
let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0)));
// Settings ▸ 3D ▸ Default 3D Renderer.
if s.prefs.three_d.default_renderer == "advanced" {
c.renderer = effectcraft_project::Renderer::Advanced3D;
}
apply_settings(&mut c, p);
let id = s.edit("New Composition", None, |proj, st| {
let mut name = name.clone();
+1 -1
View File
@@ -390,7 +390,7 @@ fn split(s: &mut Session, p: &Value) -> Result<Value> {
fn label(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "label").unwrap_or("Red");
let lab = Label::from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?;
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)?;
let items = s.state.project_selection.clone();
s.edit("Label", None, |proj, _| {
+27 -8
View File
@@ -12,7 +12,17 @@ fn has_path(s: &Session) -> std::result::Result<(), String> {
}
fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
// Settings ▸ Project ▸ New Project Loads Template: open the template as an untitled project.
let tpl = s.prefs.project.template_path.trim().to_string();
if s.prefs.project.use_template && !tpl.is_empty() {
let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?;
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?;
s.replace_project(Project::from_json(&text)?, None);
s.update_sentinel();
return Ok(json!({"template": tpl}));
}
s.replace_project(Project::default(), None);
s.update_sentinel();
Ok(Value::Null)
}
@@ -26,12 +36,13 @@ fn demo(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"comp": s.state.active_comp.map(|c| c.0)}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
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()))?;
let proj = Project::from_json(&text)?;
s.replace_project(proj, Some(path.to_string()));
s.note_project_path(path);
Ok(json!({"path": path}))
}
@@ -40,6 +51,7 @@ fn save_to(s: &mut Session, path: &str) -> Result<Value> {
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;
s.note_project_path(path);
s.toast(format!("Saved {path}"));
Ok(json!({"path": path, "bytes": json.len()}))
}
@@ -56,12 +68,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
let stem = cur.trim_end_matches(".ecproj");
let (base, n) = match stem.rsplit_once('_') {
Some((b, n)) if n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty() => (b.to_string(), n.parse::<u32>().unwrap_or(0) + 1),
_ => (stem.to_string(), 2),
};
save_to(s, &format!("{base}_{n}.ecproj"))
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
save_to(s, &next)
}
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
@@ -79,9 +88,19 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let mut ids = vec![];
let mut errors = vec![];
let mut probed = vec![];
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
for path in &paths {
match importer.probe(path) {
Ok(f) => probed.push((path.clone(), f)),
Ok(mut f) => {
// Settings ▸ Import ▸ Sequence Footage frames per second.
if f.kind == FootageKind::Sequence {
let r = seq_rate;
let frames = f.frame_rate.frame_at(f.duration);
f.frame_rate = r;
f.duration = r.tick_of(frames.max(1));
}
probed.push((path.clone(), f))
}
Err(e) => errors.push(format!("{path}: {e}")),
}
}
+1
View File
@@ -24,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{page?: general|startup|project|composition|previews|appearance|grids|labels|type|import|export|audio|disk|memory|video|3d|scripting}",
always
),
fe!("app.gpuInfo", "GPU Information...", [], None, "{}", always),
fe!("app.hide", "Hide EffectCraft", [], None, "{}", always),
fe!("app.quit", "Quit EffectCraft", [], Some("Cmd+Q"), "{}", always),
fe!("app.commandPalette", "Quick Apply...", ["Edit"], Some("Cmd+Shift+Space"), "{query?}", always),
+9 -2
View File
@@ -252,12 +252,19 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone();
let (src, size, dur) = match &it.kind {
ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)),
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), (f.kind != effectcraft_project::FootageKind::Still).then_some(f.duration)),
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => {
// Settings ▸ Import ▸ Still Footage: length of the composition or a duration.
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
(LayerSource::Footage { item }, (f.width, f.height), d)
}
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
};
let fr = comp.frame_rate;
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(default_start));
let id = s.edit("Add Footage to Comp", None, |proj, st| {
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
l.start_time = start;
+2
View File
@@ -21,6 +21,7 @@ mod prop;
pub mod puppet;
mod query;
mod render_queue;
mod settings;
mod stubs;
mod styles;
mod text_anim;
@@ -108,6 +109,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(track::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(settings::specs());
v.extend(stubs::specs());
v
})
+13 -1
View File
@@ -30,11 +30,23 @@ fn with_prop<T>(
f: impl FnOnce(&mut Property, Tick) -> Result<T>,
) -> Result<T> {
let t = s.time();
let linear = s.prefs.general.default_spatial_linear;
s.edit(label, merge, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
f(pr, lt)
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
let r = f(pr, lt)?;
// Settings ▸ General ▸ Default Spatial Interpolation to Linear: new keys of spatial
// properties get straight motion paths instead of Auto Bezier.
if linear && pr.spatial {
for k in pr.keys.iter_mut().filter(|k| !before.contains(&k.time)) {
k.spatial_auto = false;
k.spatial_in = [0.0; 3];
k.spatial_out = [0.0; 3];
}
}
Ok(r)
})
}
@@ -344,6 +344,14 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
fn render(s: &mut Session, p: &Value) -> Result<Value> {
let wait = b_p(p, "wait").unwrap_or(true);
// Settings ▸ Project ▸ Auto-Save ▸ Save When Starting Render Queue.
if s.prefs.auto_save.enabled
&& s.prefs.auto_save.save_on_render_start
&& s.is_dirty()
&& let Err(e) = s.autosave_now()
{
log::warn!("{e}");
}
let ids = s.start_render(wait).map_err(EngineError::Other)?;
let items: Vec<Value> = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect();
Ok(json!({"rendering": s.is_rendering(), "items": items}))
+278
View File
@@ -0,0 +1,278 @@
//! Settings (`prefs.*`), keyboard shortcut presets (`shortcuts.*`), File ▸ Open Recent and
//! auto-save commands.
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::shortcuts::{DEFAULT_PRESET, binding_key, normalize};
use crate::{EngineError, Result, Session, cmd, query};
// ---------------------------------------------------------------- prefs
fn prefs_get(s: &mut Session, p: &Value) -> Result<Value> {
let key = str_p(p, "key").unwrap_or("");
s.prefs.get(key).ok_or_else(|| bad("prefs.get", format!("unknown setting `{key}`")))
}
fn prefs_set(s: &mut Session, p: &Value) -> Result<Value> {
let mut pairs: Vec<(String, Value)> = vec![];
if let Some(Value::Object(m)) = p.get("values") {
pairs.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
}
if let Some(k) = str_p(p, "key") {
let v = p.get("value").cloned().ok_or_else(|| bad("prefs.set", "missing `value`"))?;
pairs.push((k.to_string(), v));
}
if pairs.is_empty() {
return Err(bad("prefs.set", "give `key` and `value` (or `values: {key: value}`)"));
}
let mut next = s.prefs.clone();
for (k, v) in &pairs {
next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?;
}
s.prefs = next;
s.prefs_changed();
s.save_prefs();
let out: serde_json::Map<String, Value> = pairs.iter().map(|(k, _)| (k.clone(), s.prefs.get(k).unwrap_or(Value::Null))).collect();
Ok(Value::Object(out))
}
fn prefs_reset(s: &mut Session, p: &Value) -> Result<Value> {
let page = str_p(p, "page");
s.prefs.reset(page).map_err(|e| bad("prefs.reset", e))?;
s.prefs_changed();
s.save_prefs();
s.toast(match page {
Some(pg) => format!("Reset {pg} settings"),
None => "Reset all settings".into(),
});
Ok(json!({"reset": page.unwrap_or("all")}))
}
fn prefs_open(s: &mut Session, p: &Value) -> Result<Value> {
let page = str_p(p, "page").unwrap_or("general");
let id = crate::prefs::page_id(page).ok_or_else(|| bad("prefs.open", format!("unknown settings page `{page}`")))?;
super::frontend(s, "app.settings", &json!({"page": id}))
}
fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {
Ok(crate::prefs::pages_json())
}
// ---------------------------------------------------------------- shortcuts
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`).
fn bindable(s: &Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
let t = s.shortcuts();
let key = binding_key(c, &params);
t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
}
fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
Ok(match p.get("keys") {
None | Some(Value::Null) => vec![],
Some(Value::String(k)) if k.trim().is_empty() => vec![],
Some(Value::String(k)) => vec![k.clone()],
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(v) => return Err(bad(cmd, format!("`keys` must be a string or a list, not {v}"))),
})
}
fn sc_list(s: &mut Session, p: &Value) -> Result<Value> {
let q = str_p(p, "query").unwrap_or("").to_lowercase();
let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false);
let keys_q = str_p(p, "keys").and_then(normalize);
let t = s.shortcuts();
let rows: Vec<Value> = t
.bindables
.iter()
.filter_map(|b| {
let keys = t.keys.get(&b.key).cloned().unwrap_or_default();
if assigned && keys.is_empty() {
return None;
}
if let Some(k) = &keys_q
&& !keys.contains(k)
{
return None;
}
let hay = format!("{} {} {} {}", b.label, b.command, b.path.join(" "), keys.join(" ")).to_lowercase();
if !q.split_whitespace().all(|w| hay.contains(w)) {
return None;
}
let mut o = serde_json::to_value(b).unwrap_or_default();
o["keys"] = json!(keys);
Some(o)
})
.collect();
Ok(json!({"preset": s.keymaps.active, "presets": s.keymaps.names(), "count": rows.len(), "commands": rows}))
}
fn sc_set(s: &mut Session, p: &Value) -> Result<Value> {
let b = bindable(s, p, "shortcuts.set")?;
let keys = keys_param(p, "shortcuts.set")?;
let conflicts: Vec<_> = keys.iter().flat_map(|k| s.shortcuts().conflicts(&b, k)).collect();
let created = s.keymaps.set(&b, &keys).map_err(|e| bad("shortcuts.set", e))?;
s.shortcuts_changed();
let now = s.shortcuts().keys.get(&b.key).cloned().unwrap_or_default();
if let Some(n) = &created {
s.toast(format!("The default shortcuts can't be changed: created the preset \"{n}\""));
}
if !conflicts.is_empty() {
let names: Vec<String> = conflicts.iter().map(|c| format!("{} ({})", c.label, c.keys)).collect();
s.toast(format!("Shortcut conflict: also used by {}", names.join(", ")));
}
Ok(json!({"command": b.key, "keys": now, "preset": s.keymaps.active, "created": created, "conflicts": conflicts}))
}
fn sc_reset(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(c) = str_p(p, "command") {
let b = bindable(s, p, "shortcuts.reset")?;
let defaults = b.defaults.clone();
s.keymaps.set(&b, &defaults).map_err(|e| bad("shortcuts.reset", e))?;
s.shortcuts_changed();
return Ok(json!({"command": c, "keys": defaults}));
}
s.keymaps.reset_active();
s.shortcuts_changed();
Ok(json!({"preset": s.keymaps.active}))
}
fn sc_preset(s: &mut Session, p: &Value) -> Result<Value> {
let op = str_p(p, "op").unwrap_or("select");
let name = str_p(p, "name");
let e = |m: String| bad("shortcuts.preset", m);
let k = &mut s.keymaps;
match op {
"select" => k.select(name.unwrap_or(DEFAULT_PRESET)).map_err(e)?,
"new" => {
k.duplicate(DEFAULT_PRESET, Some(name.unwrap_or("Custom"))).map_err(e)?;
}
"duplicate" => {
let from = str_p(p, "from").map(str::to_string).unwrap_or_else(|| k.active.clone());
k.duplicate(&from, name).map_err(e)?;
}
"delete" => {
let n = name.map(str::to_string).unwrap_or_else(|| k.active.clone());
k.delete(&n).map_err(e)?;
}
"rename" => {
let to = str_p(p, "newName").ok_or_else(|| bad("shortcuts.preset", "missing `newName`"))?;
let from = name.map(str::to_string).unwrap_or_else(|| k.active.clone());
k.rename(&from, to).map_err(e)?;
}
_ => return Err(bad("shortcuts.preset", "op must be select, new, duplicate, delete or rename")),
}
s.shortcuts_changed();
Ok(json!({"preset": s.keymaps.active, "presets": s.keymaps.names()}))
}
fn sc_export(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.keymaps.export(str_p(p, "preset")).map_err(|e| bad("shortcuts.export", e))?;
if let Some(path) = str_p(p, "path") {
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
s.services.write_file(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
return Ok(json!({"path": path, "preset": doc["name"]}));
}
Ok(doc)
}
fn sc_import(s: &mut Session, p: &Value) -> Result<Value> {
let doc = match (str_p(p, "path"), p.get("preset")) {
(Some(path), _) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
serde_json::from_slice(&bytes).map_err(|e| bad("shortcuts.import", format!("{path}: {e}")))?
}
(None, Some(v)) if v.is_object() => v.clone(),
_ => return Err(bad("shortcuts.import", "give `path` or `preset` (an exported document)")),
};
let name = s.keymaps.import(&doc, str_p(p, "name")).map_err(|e| bad("shortcuts.import", e))?;
s.shortcuts_changed();
Ok(json!({"preset": name}))
}
fn sc_conflicts(s: &mut Session, _: &Value) -> Result<Value> {
let c: Vec<Value> =
s.shortcuts().all_conflicts().into_iter().map(|(a, c)| json!({"command": a, "keys": c.keys, "with": c.key, "scope": c.scope})).collect();
Ok(json!(c))
}
// ---------------------------------------------------------------- recent / auto-save
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) {
(Some(path), _) => path.to_string(),
(None, Some(i)) => s.prefs.recent_projects.get(i as usize).cloned().ok_or_else(|| bad("file.openRecent", format!("no recent project #{i}")))?,
_ => s.prefs.recent_projects.first().cloned().ok_or_else(|| bad("file.openRecent", "no recent projects"))?,
};
match super::file::open(s, &json!({"path": path})) {
Ok(v) => Ok(v),
Err(e) => {
// A project that's gone drops out of the list.
if !std::path::Path::new(&path).exists() {
s.prefs.recent_projects.retain(|x| *x != path);
s.save_prefs();
}
Err(e)
}
}
}
fn clear_recent(s: &mut Session, _: &Value) -> Result<Value> {
s.prefs.recent_projects.clear();
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
fn has_recent(s: &Session) -> std::result::Result<(), String> {
if s.prefs.recent_projects.is_empty() { Err("no recent projects".into()) } else { Ok(()) }
}
fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
let path = s.autosave_now()?;
s.toast(format!("Auto-saved to {path}"));
Ok(json!({"path": path}))
}
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
let root = s.default_autosave_root();
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
Ok(json!({
"lastAutoSave": s.autosave.last_path,
"latestOnDisk": latest,
"folder": crate::autosave::folder(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()),
"recentProjects": s.prefs.recent_projects,
}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("prefs.get", "Get Setting", "{key?: e.g. general.undoLevels (omit for all settings)}", prefs_get),
cmd!("prefs.set", "Change Setting", [], None, "{key, value, values?: {key: value}}", always, prefs_set),
cmd!("prefs.reset", "Reset Settings", [], None, "{page?: general|labels|project|…}", always, prefs_reset),
cmd!(
"prefs.open",
"Open Settings",
[],
None,
"{page?: general|startup|project|composition|previews|appearance|grids|labels|type|import|export|audio|disk|memory|video|3d|scripting}",
always,
prefs_open
),
query!("prefs.pages", "Settings Pages", "{}", prefs_pages),
query!("shortcuts.list", "List Keyboard Shortcuts", "{query?, keys?, assigned?: bool}", sc_list),
cmd!("shortcuts.set", "Set Keyboard Shortcut", [], None, "{command, params?, keys: \"Cmd+Shift+K\" | [keys] | null}", always, sc_set),
cmd!("shortcuts.reset", "Reset Keyboard Shortcuts", [], None, "{command?, params?}", always, sc_reset),
cmd!("shortcuts.preset", "Keyboard Shortcut Preset", [], None, "{op: select|new|duplicate|delete|rename, name?, from?, newName?}", always, sc_preset),
cmd!("shortcuts.export", "Export Keyboard Shortcuts", [], None, "{preset?, path?}", always, sc_export),
cmd!("shortcuts.import", "Import Keyboard Shortcuts", [], None, "{path? | preset?: exported document, name?}", always, sc_import),
query!("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "{}", sc_conflicts),
cmd!("file.openRecent", "Open Recent", [], None, "{index? | path?}", has_recent, open_recent),
cmd!("file.clearRecent", "Clear Recent Projects", ["File", "Open Recent"], None, "{}", has_recent, clear_recent),
cmd!("file.autoSave", "Auto-Save Now", [], None, "{}", always, auto_save),
query!("file.recoveryInfo", "Auto-Save Status", "{}", recovery_info),
]
}
+104
View File
@@ -0,0 +1,104 @@
//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery
//! sentinel). The engine only sees the [`ConfigStore`] trait; frontends choose where it lives:
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app can back it
//! with `localStorage`, tests and headless runs use [`MemoryConfig`] (or none at all).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// Named text blobs (`prefs.json`, `shortcuts.json`, `session.lock`).
pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
fn remove(&self, name: &str) -> std::io::Result<()>;
/// A directory on disk next to the configuration (default Auto-Save folder for untitled
/// projects); `None` for stores that aren't on the file system.
fn dir(&self) -> Option<PathBuf> {
None
}
}
/// In-memory store (tests, headless sessions).
#[derive(Default)]
pub struct MemoryConfig {
pub files: Mutex<BTreeMap<String, String>>,
}
impl ConfigStore for MemoryConfig {
fn read(&self, name: &str) -> Option<String> {
self.files.lock().ok()?.get(name).cloned()
}
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
if let Ok(mut m) = self.files.lock() {
m.insert(name.to_string(), data.to_string());
}
Ok(())
}
fn remove(&self, name: &str) -> std::io::Result<()> {
if let Ok(mut m) = self.files.lock() {
m.remove(name);
}
Ok(())
}
}
/// Files in one directory, written atomically.
pub struct DirConfig {
pub dir: PathBuf,
}
impl DirConfig {
pub fn new(dir: impl Into<PathBuf>) -> DirConfig {
DirConfig { dir: dir.into() }
}
}
impl ConfigStore for DirConfig {
fn read(&self, name: &str) -> Option<String> {
std::fs::read_to_string(self.dir.join(name)).ok()
}
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
atomic_write(&self.dir.join(name), data.as_bytes())
}
fn remove(&self, name: &str) -> std::io::Result<()> {
match std::fs::remove_file(self.dir.join(name)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
}
fn dir(&self) -> Option<PathBuf> {
Some(self.dir.clone())
}
}
/// The temporary file an atomic write of `path` goes through.
pub fn temp_path(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
name.push(".tmp");
path.with_file_name(name)
}
/// Write `data` to `path` atomically: write a temporary file next to it, flush it to disk, then
/// rename it over `path`. A crash at any point leaves either the old file or the new one, never a
/// torn file.
pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
atomic_write_with(path, data, |tmp, data| {
use std::io::Write;
let mut f = std::fs::File::create(tmp)?;
f.write_all(data)?;
f.sync_all()
})
}
/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A
/// failed write removes the temporary file and leaves `path` untouched.
pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> {
let tmp = temp_path(path);
if let Err(e) = write(&tmp, data) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::fs::rename(&tmp, path)
}
+34 -6
View File
@@ -8,11 +8,16 @@
//! The project is an `Arc<Project>` edited copy-on-write; undo keeps whole-project snapshots
//! (compositions are `Arc`s, so untouched comps are shared).
pub mod autosave;
pub mod commands;
pub mod config;
pub mod demo;
pub mod links;
pub mod menus;
pub mod prefs;
pub mod render_queue;
mod session_settings;
pub mod shortcuts;
pub mod tracking;
use std::sync::Arc;
@@ -65,10 +70,7 @@ impl Services for FsServices {
std::fs::read(path)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
let p = std::path::Path::new(path);
let tmp = p.with_extension("ecproj.tmp");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, p)
config::atomic_write(std::path::Path::new(path), data)
}
}
@@ -224,6 +226,21 @@ pub struct Session {
pub journal: Vec<(String, Value)>,
/// Processed-layer pixels reused across frames and edits (content-keyed, never stale).
pub layer_cache: Arc<LayerCache>,
/// Settings (Preferences).
pub prefs: prefs::Prefs,
/// Bumped whenever settings change (frontends re-apply theme, labels…).
pub prefs_revision: u64,
/// Where settings, shortcut presets and the crash-recovery sentinel are stored (`None` =
/// nothing persists).
pub config: Option<Arc<dyn config::ConfigStore>>,
/// Keyboard shortcut presets.
pub keymaps: shortcuts::Keymaps,
/// Frontend-only commands offered for binding.
pub ui_commands: Vec<shortcuts::UiCommand>,
/// Cache of the resolved active preset (read it with [`Session::shortcuts`]).
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
/// Auto-save bookkeeping.
pub autosave: autosave::AutoSaveState,
}
impl Default for Session {
@@ -246,6 +263,13 @@ impl Default for Session {
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
prefs: prefs::Prefs::default(),
prefs_revision: 0,
config: None,
keymaps: shortcuts::Keymaps::default(),
ui_commands: vec![],
shortcut_table: std::sync::OnceLock::new(),
autosave: autosave::AutoSaveState::default(),
}
}
}
@@ -298,8 +322,10 @@ impl Session {
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
self.history.undo.push((label.to_string(), before));
if self.history.undo.len() > 500 {
self.history.undo.remove(0);
let levels = self.prefs.general.undo_levels.max(1) as usize;
if self.history.undo.len() > levels {
let extra = self.history.undo.len() - levels;
self.history.undo.drain(..extra);
}
}
self.history.merge_key = merge.map(str::to_string);
@@ -483,6 +509,8 @@ mod tests_effects;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_styles;
#[cfg(test)]
mod tests_text;
+13
View File
@@ -93,6 +93,17 @@ pub fn entries() -> Vec<(Vec<String>, &'static MenuEntry)> {
out
}
/// Display label of an entry: label names come from Settings ▸ Labels.
pub fn entry_label(s: &Session, e: &MenuEntry) -> String {
match e.command.as_str() {
"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(),
},
_ => e.label.clone(),
}
}
/// 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> {
@@ -292,6 +303,8 @@ File
New Project | file.newProject
New Folder | project.newFolder
Open Project... | file.open
Open Recent
Clear Recent Projects | file.clearRecent
---
Close | file.close
Close Project | file.closeProject
+951
View File
@@ -0,0 +1,951 @@
//! Settings (After Effects' Preferences): a versioned serde model, addressed by dotted keys
//! (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`), plus the page schema the
//! Settings dialog and agents (`prefs.pages`) are built from.
//!
//! Settings are stored as JSON (`prefs.json`) through the session's [`crate::config::ConfigStore`].
//! Loading is forgiving: missing keys take their defaults, unknown keys (written by a newer
//! version) are kept and written back, and older layouts are migrated ([`migrate`]).
//!
//! Every setting in [`pages`] says whether it already changes behaviour (`live`). Settings that
//! don't yet are still shown (After Effects parity of the dialog) and are listed in
//! `docs/preferences.md`; a test keeps that list in step with the schema.
use std::collections::BTreeMap;
use effectcraft_color::Label;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
/// Current settings layout version.
pub const PREFS_VERSION: u32 = 2;
/// Settings file name in the config store.
pub const PREFS_FILE: &str = "prefs.json";
/// Unknown keys inside a page, kept so a newer version's settings survive a round trip.
type Extra = BTreeMap<String, Value>;
macro_rules! page {
($(#[$m:meta])* $name:ident { $($(#[$fm:meta])* $f:ident : $t:ty = $d:expr),* $(,)? }) => {
$(#[$m])*
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct $name {
$($(#[$fm])* pub $f: $t,)*
#[serde(flatten)]
pub extra: Extra,
}
impl Default for $name {
fn default() -> Self {
$name { $($f: $d,)* extra: Extra::new() }
}
}
};
}
page!(General {
/// Levels of Undo (1–99).
undo_levels: u32 = 32,
/// Path Point and Handle Size (px).
path_point_size: u32 = 5,
show_tool_tips: bool = true,
/// Create New Layers at Composition Start Time (off: at the current time).
create_layers_at_comp_start: bool = true,
switches_affect_nested_comps: bool = true,
/// Default Spatial Interpolation to Linear.
default_spatial_linear: bool = false,
preserve_constant_vertex_count: bool = true,
/// Synchronize Time of All Related Items.
sync_time_related_items: bool = true,
expression_pick_whip_compact: bool = true,
create_split_layers_above: bool = false,
use_system_color_picker: bool = false,
/// Number of projects in File ▸ Open Recent.
recent_items: u32 = 10,
});
page!(Startup {
show_home_on_launch: bool = true,
show_home_on_open_project: bool = false,
/// After a crash, offer to open the most recent auto-save.
offer_crash_recovery: bool = true,
});
page!(ProjectPrefs {
/// Open a template project for File ▸ New ▸ New Project.
use_template: bool = false,
template_path: String = String::new(),
});
page!(AutoSave {
enabled: bool = true,
interval_minutes: u32 = 20,
max_versions: u32 = 5,
/// `nextToProject` (an "EffectCraft Auto-Save" folder beside the project) or `custom`.
location: String = "nextToProject".into(),
folder: String = String::new(),
save_on_render_start: bool = false,
});
page!(CompositionPrefs {
/// Motion path: `all`, `none`, `seconds`, `keyframes`.
motion_path: String = "keyframes".into(),
motion_path_seconds: f64 = 15.0,
motion_path_keyframes: u32 = 15,
show_rendering_progress: bool = true,
hardware_accelerate_panels: bool = true,
});
page!(Previews {
/// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`.
adaptive_resolution_limit: String = "1/8".into(),
show_internal_wireframes: bool = false,
cache_frames_when_idle: bool = false,
fast_previews: bool = false,
/// `faster` or `moreAccurate`.
zoom_quality: String = "moreAccurate".into(),
show_gpu_info: bool = false,
});
page!(Appearance {
/// `dark`, `darker` or `light`.
theme: String = "dark".into(),
/// User interface brightness, -1 (darker) … 1 (lighter).
brightness: f64 = 0.0,
use_label_color_for_handles: bool = true,
use_label_color_for_tabs: bool = true,
cycle_mask_colors: bool = true,
use_gradients: bool = true,
});
page!(Grids {
grid_color: String = "#7a9cff".into(),
grid_spacing: f64 = 100.0,
grid_subdivisions: u32 = 4,
/// `lines`, `dashed` or `dots`.
grid_style: String = "lines".into(),
proportional_horizontal: u32 = 4,
proportional_vertical: u32 = 4,
guide_color: String = "#3cc8f0".into(),
guide_style: String = "lines".into(),
action_safe: f64 = 10.0,
title_safe: f64 = 20.0,
});
page!(TypePrefs {
/// `latin` or `southAsian` (South Asian and Middle Eastern).
text_engine: String = "latin".into(),
font_preview: bool = true,
recent_fonts: u32 = 10,
font_names_in_english: bool = false,
});
page!(Import {
/// Still footage duration: `compLength` or `seconds`.
still_footage: String = "compLength".into(),
still_seconds: f64 = 5.0,
sequence_fps: f64 = 30.0,
report_missing_frames: bool = true,
/// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`.
unlabeled_alpha: String = "ask".into(),
/// Default drag import as: `footage`, `comp`, `compLayerSizes`.
drag_import_as: String = "footage".into(),
});
page!(Export {
default_output_folder: String = String::new(),
segment_sequences: bool = false,
segment_sequence_files: u32 = 700,
segment_movies: bool = false,
segment_movie_mb: u32 = 1024,
append_bits_to_name: bool = false,
});
page!(Audio {
/// Output device name (empty = the system default).
output_device: String = String::new(),
/// Output mapping: device channel for the left / right output (1-based).
output_left: u32 = 1,
output_right: u32 = 2,
preview_sample_rate: u32 = 48_000,
});
page!(Disk {
disk_cache_enabled: bool = true,
disk_cache_max_gb: u32 = 100,
disk_cache_folder: String = String::new(),
media_cache_folder: String = String::new(),
conformed_media_folder: String = String::new(),
});
page!(Memory {
ram_reserved_gb: u32 = 6,
/// Processed-layer cache budget (MB).
layer_cache_mb: u32 = 1024,
/// Decoded footage frame cache budget (MB).
media_cache_mb: u32 = 1024,
/// RAM preview (viewer frame) cache budget (MB).
preview_cache_mb: u32 = 3072,
reduce_cache_when_low: bool = true,
});
page!(Video {
enable_output: bool = false,
device: String = String::new(),
output_during_playback: bool = true,
mirror_on_monitor: bool = true,
disable_when_background: bool = false,
});
page!(ThreeD {
/// Default 3D renderer for new compositions: `classic` or `advanced`.
default_renderer: String = "classic".into(),
show_reference_axes: bool = true,
extended_viewer: bool = false,
realtime_shadows: bool = true,
});
page!(Scripting {
allow_scripts_write_files: bool = false,
warn_executing_files: bool = true,
enable_js_debugger: bool = false,
editor_font_size: u32 = 13,
syntax_highlighting: bool = true,
line_numbers: bool = true,
auto_complete: bool = true,
bracket_matching: bool = true,
word_wrap: bool = false,
error_banner: bool = true,
});
/// One editable label: name and `#rrggbb` colour.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LabelPref {
pub name: String,
pub color: String,
}
fn default_labels() -> Vec<LabelPref> {
Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect()
}
fn hex([r, g, b]: [u8; 3]) -> String {
format!("#{r:02x}{g:02x}{b:02x}")
}
fn parse_hex(s: &str) -> Option<[u8; 3]> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 {
return None;
}
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
Some([p(0)?, p(2)?, p(4)?])
}
/// All settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Prefs {
pub version: u32,
pub general: General,
pub startup: Startup,
pub project: ProjectPrefs,
pub auto_save: AutoSave,
pub composition: CompositionPrefs,
pub previews: Previews,
pub appearance: Appearance,
pub grids: Grids,
/// The 16 labels (Red … Dark Green), in order.
pub labels: Vec<LabelPref>,
#[serde(rename = "type")]
pub type_: TypePrefs,
pub import: Import,
pub export: Export,
pub audio: Audio,
pub disk: Disk,
pub memory: Memory,
pub video: Video,
#[serde(rename = "threeD")]
pub three_d: ThreeD,
pub scripting: Scripting,
/// File ▸ Open Recent, newest first.
pub recent_projects: Vec<String>,
/// Top-level keys this version doesn't know (kept for newer versions).
#[serde(flatten)]
pub extra: Extra,
}
impl Default for Prefs {
fn default() -> Self {
Prefs {
version: PREFS_VERSION,
general: General::default(),
startup: Startup::default(),
project: ProjectPrefs::default(),
auto_save: AutoSave::default(),
composition: CompositionPrefs::default(),
previews: Previews::default(),
appearance: Appearance::default(),
grids: Grids::default(),
labels: default_labels(),
type_: TypePrefs::default(),
import: Import::default(),
export: Export::default(),
audio: Audio::default(),
disk: Disk::default(),
memory: Memory::default(),
video: Video::default(),
three_d: ThreeD::default(),
scripting: Scripting::default(),
recent_projects: vec![],
extra: Extra::new(),
}
}
}
/// Bring an older settings document up to [`PREFS_VERSION`].
///
/// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`,
/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level.
pub fn migrate(mut v: Value) -> Value {
let Some(obj) = v.as_object_mut() else { return json!({}) };
let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0);
if version < 2 {
let moved = |from: &str, page: &str, key: &str, obj: &mut Map<String, Value>| {
if let Some(x) = obj.remove(from) {
let p = obj.entry(page.to_string()).or_insert_with(|| json!({}));
if let Some(p) = p.as_object_mut() {
p.entry(key.to_string()).or_insert(x);
}
}
};
moved("undoLevels", "general", "undoLevels", obj);
moved("autoSaveEnabled", "autoSave", "enabled", obj);
moved("autoSaveMinutes", "autoSave", "intervalMinutes", obj);
moved("autoSaveVersions", "autoSave", "maxVersions", obj);
moved("theme", "appearance", "theme", obj);
if let Some(Value::Array(names)) = obj.remove("labelNames") {
let mut labels = default_labels();
for (l, n) in labels.iter_mut().zip(names) {
if let Some(n) = n.as_str() {
l.name = n.to_string();
}
}
obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default());
}
}
obj.insert("version".into(), json!(PREFS_VERSION));
v
}
impl Prefs {
/// Parse a settings document (migrating older layouts; unknown keys are kept, bad values
/// fall back to defaults).
pub fn from_json(text: &str) -> Prefs {
let v: Value = serde_json::from_str(text).unwrap_or_else(|_| json!({}));
let v = migrate(v);
let mut p: Prefs = match serde_json::from_value(v.clone()) {
Ok(p) => p,
// One bad value shouldn't lose everything: take each page that parses.
Err(_) => {
let mut base = serde_json::to_value(Prefs::default()).unwrap_or_default();
if let (Some(b), Some(o)) = (base.as_object_mut(), v.as_object()) {
for (k, x) in o {
let mut trial = b.clone();
trial.insert(k.clone(), x.clone());
if serde_json::from_value::<Prefs>(Value::Object(trial)).is_ok() {
b.insert(k.clone(), x.clone());
}
}
}
serde_json::from_value(base).unwrap_or_default()
}
};
p.normalize();
p
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
/// Clamp values into their valid ranges and fill missing labels.
pub fn normalize(&mut self) {
self.version = PREFS_VERSION;
let g = &mut self.general;
g.undo_levels = g.undo_levels.clamp(1, 99);
g.path_point_size = g.path_point_size.clamp(3, 20);
g.recent_items = g.recent_items.clamp(1, 30);
let a = &mut self.auto_save;
a.interval_minutes = a.interval_minutes.clamp(1, 240);
a.max_versions = a.max_versions.clamp(1, 99);
self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0);
let defaults = default_labels();
self.labels.truncate(defaults.len());
for d in defaults.iter().skip(self.labels.len()) {
self.labels.push(d.clone());
}
for (l, d) in self.labels.iter_mut().zip(&defaults) {
if parse_hex(&l.color).is_none() {
l.color = d.color.clone();
}
}
let m = &mut self.memory;
m.layer_cache_mb = m.layer_cache_mb.clamp(64, 1 << 20);
m.media_cache_mb = m.media_cache_mb.clamp(64, 1 << 20);
m.preview_cache_mb = m.preview_cache_mb.clamp(64, 1 << 20);
self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0);
let n = self.general.recent_items as usize;
self.recent_projects.truncate(n);
}
/// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything.
pub fn get(&self, key: &str) -> Option<Value> {
let v = serde_json::to_value(self).ok()?;
if key.is_empty() {
return Some(v);
}
let ptr = format!("/{}", key.replace('.', "/"));
v.pointer(&ptr).cloned()
}
/// Set the value at a dotted key. The key must exist and the value must have its type.
pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> {
let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?;
let ptr = format!("/{}", key.replace('.', "/"));
let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?;
let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?;
*slot = value;
let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
next.normalize();
*self = next;
Ok(())
}
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is
/// kept.
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 };
return Ok(());
};
let keys: Vec<&str> = match page {
"project" => vec!["project", "autoSave"],
p => vec![section_key(p).ok_or_else(|| format!("unknown settings page `{p}`"))?],
};
let dv = serde_json::to_value(&d).map_err(|e| e.to_string())?;
for k in keys {
if let Some(x) = dv.get(k) {
self.set(k, x.clone())?;
}
}
Ok(())
}
/// Display name of a label (Labels settings).
pub fn label_name(&self, l: Label) -> String {
match label_index(l) {
Some(i) => self.labels.get(i).map(|p| p.name.clone()).unwrap_or_else(|| l.name().to_string()),
None => l.name().to_string(),
}
}
/// sRGB colour of a label (Labels settings).
pub fn label_rgb(&self, l: Label) -> [u8; 3] {
label_index(l).and_then(|i| self.labels.get(i)).and_then(|p| parse_hex(&p.color)).unwrap_or(l.rgb())
}
/// A label by its built-in name or its (renamed) display name.
pub fn label_from_name(&self, s: &str) -> Option<Label> {
Label::from_name(s).or_else(|| Label::ALL.iter().skip(1).copied().find(|l| self.label_name(*l).eq_ignore_ascii_case(s)))
}
/// Remember a project in File ▸ Open Recent.
pub fn push_recent(&mut self, path: &str) {
self.recent_projects.retain(|p| p != path);
self.recent_projects.insert(0, path.to_string());
self.recent_projects.truncate(self.general.recent_items as usize);
}
/// Layer / media / preview cache budgets in bytes.
pub fn layer_cache_bytes(&self) -> usize {
(self.memory.layer_cache_mb as usize) << 20
}
pub fn media_cache_bytes(&self) -> usize {
(self.memory.media_cache_mb as usize) << 20
}
pub fn preview_cache_bytes(&self) -> usize {
(self.memory.preview_cache_mb as usize) << 20
}
/// The adaptive resolution limit as a render-scale floor (1/2 → 0.5).
pub fn adaptive_limit(&self) -> f64 {
match self.previews.adaptive_resolution_limit.as_str() {
"1/2" => 0.5,
"1/4" => 0.25,
_ => 0.125,
}
}
}
fn label_index(l: Label) -> Option<usize> {
Label::ALL.iter().position(|x| *x == l).and_then(|i| i.checked_sub(1))
}
/// Settings key of a page id (`3d` → `threeD`).
pub fn section_key(page: &str) -> Option<&'static str> {
Some(match page {
"general" => "general",
"startup" => "startup",
"project" => "project",
"autoSave" | "autosave" => "autoSave",
"composition" | "display" => "composition",
"previews" => "previews",
"appearance" => "appearance",
"grids" => "grids",
"labels" => "labels",
"type" => "type",
"import" => "import",
"export" => "export",
"audio" => "audio",
"disk" => "disk",
"memory" => "memory",
"video" => "video",
"3d" | "threeD" => "threeD",
"scripting" => "scripting",
_ => return None,
})
}
/// Settings page id for a page name, including older After Effects page names
/// (Auto-Save → Project, Media & Disk Cache → Disk, Memory & Performance → Memory & CPU…).
pub fn page_id(name: &str) -> Option<&'static str> {
let n = name.to_ascii_lowercase().replace([' ', '&', '-', '_'], "");
let id = match n.as_str() {
"general" => "general",
"startup" | "startuprepair" => "startup",
"project" | "autosave" | "newproject" => "project",
"composition" | "display" => "composition",
"previews" | "preview" => "previews",
"appearance" => "appearance",
"grids" | "gridsguides" => "grids",
"labels" => "labels",
"type" => "type",
"import" => "import",
"export" | "output" => "export",
"audio" | "audiohardware" | "audiooutputmapping" => "audio",
"disk" | "mediadiskcache" | "mediacache" => "disk",
"memory" | "memorycpu" | "memoryperformance" => "memory",
"video" | "videopreview" => "video",
"3d" | "threed" => "3d",
"scripting" | "scriptingexpressions" => "scripting",
_ => return None,
};
Some(id)
}
fn kind_name(v: &Value) -> &'static str {
match v {
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "list",
Value::Object(_) => "object",
Value::Null => "value",
}
}
/// `value` converted to the type of `slot` (numbers from strings, booleans from 0/1…).
fn coerce(slot: &Value, value: Value) -> Option<Value> {
Some(match (slot, value) {
(Value::Bool(_), Value::Bool(b)) => json!(b),
(Value::Bool(_), Value::Number(n)) => json!(n.as_f64()? != 0.0),
(Value::Bool(_), Value::String(s)) => json!(matches!(s.as_str(), "true" | "on" | "1" | "yes")),
(Value::Number(cur), v) => {
let f = match &v {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse().ok()?,
_ => return None,
};
if cur.is_u64() || cur.is_i64() { json!(f.round().max(0.0) as u64) } else { json!(f) }
}
(Value::String(_), Value::String(s)) => json!(s),
(Value::String(_), Value::Number(n)) => json!(n.to_string()),
(Value::Array(_), v @ Value::Array(_)) => v,
(Value::Object(_), v @ Value::Object(_)) => v,
_ => return None,
})
}
// ---------------------------------------------------------------- schema (Settings dialog)
/// What a setting row edits.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
Bool,
/// Integer with range and unit.
Int(i64, i64, &'static str),
/// Number with range and unit.
Float(f64, f64, &'static str),
/// One of (label, value).
Choice(&'static [(&'static str, &'static str)]),
Text,
/// A folder or file path (text + Choose…).
Path,
/// `#rrggbb`.
Color,
/// UI brightness slider.
Slider(f64, f64),
}
/// One row of a settings page.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Item {
Section(&'static str),
Setting {
key: &'static str,
label: &'static str,
kind: Kind,
/// Changes behaviour today (false = shown for parity, listed in docs/preferences.md).
live: bool,
},
/// A button running a command (`params` is JSON).
Button {
label: &'static str,
command: &'static str,
params: &'static str,
},
/// The 16 label names and colours.
Labels,
/// Output device picker (device list from the host).
AudioDevices {
key: &'static str,
},
/// Read-only information line.
Note(&'static str),
}
/// One page of the Settings dialog.
#[derive(Clone, Debug)]
pub struct Page {
pub id: &'static str,
pub title: &'static str,
pub items: Vec<Item>,
}
const fn s(key: &'static str, label: &'static str, kind: Kind, live: bool) -> Item {
Item::Setting { key, label, kind, live }
}
const B: Kind = Kind::Bool;
const ON_OFF_RES: &[(&str, &str)] = &[("1/2", "1/2"), ("1/4", "1/4"), ("1/8", "1/8")];
const STYLES: &[(&str, &str)] = &[("Lines", "lines"), ("Dashed Lines", "dashed"), ("Dots", "dots")];
/// The Settings pages in After Effects 2026 order.
pub fn pages() -> Vec<Page> {
use Item::*;
vec![
Page {
id: "general",
title: "General",
items: vec![
s("general.undoLevels", "Levels of Undo", Kind::Int(1, 99, ""), true),
s("general.pathPointSize", "Path Point and Handle Size", Kind::Int(3, 20, "px"), true),
s("general.recentItems", "Recent Projects Shown", Kind::Int(1, 30, ""), true),
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.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.useSystemColorPicker", "Use System Color Picker", B, false),
],
},
Page {
id: "startup",
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.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true),
Section("Repair"),
Button { label: "Reset Settings", command: "prefs.reset", params: "{}" },
Button { label: "Reset Keyboard Shortcuts", command: "shortcuts.reset", params: "{}" },
Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# },
],
},
Page {
id: "project",
title: "Project",
items: vec![
Section("New Project"),
s("project.useTemplate", "New Project Loads Template", B, true),
s("project.templatePath", "Template Project", Kind::Path, true),
Section("Auto-Save"),
s("autoSave.enabled", "Automatically Save Projects", B, true),
s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true),
s("autoSave.maxVersions", "Maximum Project Versions", Kind::Int(1, 99, ""), true),
s("autoSave.location", "Auto-Save Location", Kind::Choice(&[("Next to Project", "nextToProject"), ("Custom Location", "custom")]), true),
s("autoSave.folder", "Custom Location", Kind::Path, true),
s("autoSave.saveOnRenderStart", "Save When Starting Render Queue", B, true),
Button { label: "Save Now", command: "file.autoSave", params: "{}" },
],
},
Page {
id: "composition",
title: "Composition",
items: vec![
Section("Motion Path"),
s(
"composition.motionPath",
"Motion Path",
Kind::Choice(&[
("All Keyframes", "all"),
("No Motion Path", "none"),
("No More Than (Seconds)", "seconds"),
("No More Than (Keyframes)", "keyframes"),
]),
false,
),
s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), false),
s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), false),
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),
],
},
Page {
id: "previews",
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.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),
Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" },
],
},
Page {
id: "appearance",
title: "Appearance",
items: vec![
s("appearance.theme", "Theme", Kind::Choice(&[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]), true),
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),
],
},
Page {
id: "grids",
title: "Grids & Guides",
items: vec![
Section("Grid"),
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),
Section("Proportional Grid"),
s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), false),
s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), false),
Section("Guides"),
s("grids.guideColor", "Color", Kind::Color, true),
s("grids.guideStyle", "Style", Kind::Choice(STYLES), false),
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),
],
},
Page { id: "labels", title: "Labels", items: vec![Labels] },
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),
],
},
Page {
id: "import",
title: "Import",
items: vec![
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.unlabeledAlpha",
"Interpret Unlabeled Alpha As",
Kind::Choice(&[
("Ask User", "ask"),
("Guess", "guess"),
("Ignore", "ignore"),
("Straight", "straight"),
("Premultiplied", "premultiplied"),
]),
false,
),
s(
"import.dragImportAs",
"Default Drag Import As",
Kind::Choice(&[("Footage", "footage"), ("Composition", "comp"), ("Composition - Retain Layer Sizes", "compLayerSizes")]),
false,
),
],
},
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),
],
},
Page {
id: "audio",
title: "Audio",
items: vec![
Section("Audio Hardware"),
AudioDevices { key: "audio.outputDevice" },
s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), false),
Section("Audio Output Mapping"),
s("audio.outputLeft", "Left", Kind::Int(1, 64, "channel"), true),
s("audio.outputRight", "Right", Kind::Int(1, 64, "channel"), true),
],
},
Page {
id: "disk",
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),
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),
],
},
Page {
id: "memory",
title: "Memory & CPU",
items: vec![
s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), false),
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),
],
},
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),
],
},
Page {
id: "3d",
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),
],
},
Page {
id: "scripting",
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.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."),
],
},
]
}
/// Keys of every setting that doesn't change behaviour yet (the TODO list in
/// `docs/preferences.md`).
pub fn todo_keys() -> Vec<&'static str> {
pages()
.into_iter()
.flat_map(|p| p.items)
.filter_map(|i| match i {
Item::Setting { key, live: false, .. } => Some(key),
_ => None,
})
.collect()
}
/// The schema as JSON (for `prefs.pages`).
pub fn pages_json() -> Value {
let kind = |k: &Kind| -> Value {
match k {
Kind::Bool => json!({"type": "bool"}),
Kind::Int(a, b, u) => json!({"type": "int", "min": a, "max": b, "unit": u}),
Kind::Float(a, b, u) => json!({"type": "number", "min": a, "max": b, "unit": u}),
Kind::Choice(o) => json!({"type": "choice", "options": o.iter().map(|(l, v)| json!({"label": l, "value": v})).collect::<Vec<_>>()}),
Kind::Text => json!({"type": "text"}),
Kind::Path => json!({"type": "path"}),
Kind::Color => json!({"type": "color"}),
Kind::Slider(a, b) => json!({"type": "slider", "min": a, "max": b}),
}
};
let pages: Vec<Value> = pages()
.iter()
.map(|p| {
let items: Vec<Value> = p
.items
.iter()
.map(|i| match i {
Item::Section(t) => json!({"section": t}),
Item::Setting { key, label, kind: k, live } => {
let mut o = kind(k);
o["key"] = json!(key);
o["label"] = json!(label);
o["live"] = json!(live);
o
}
Item::Button { label, command, params } => {
json!({"button": label, "command": command, "params": serde_json::from_str::<Value>(params).unwrap_or_default()})
}
Item::Labels => json!({"labels": "labels.N.name / labels.N.color (N = 0..15)"}),
Item::AudioDevices { key } => json!({"type": "device", "key": key, "label": "Default Output"}),
Item::Note(t) => json!({"note": t}),
})
.collect();
json!({"id": p.id, "title": p.title, "items": items})
})
.collect();
json!(pages)
}
+152
View File
@@ -0,0 +1,152 @@
//! Session side of Settings, keyboard shortcut presets, auto-save and crash recovery.
use std::path::Path;
use crate::autosave::{self, Sentinel};
use crate::prefs::{PREFS_FILE, Prefs};
use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, ShortcutTable, UiCommand};
use crate::{EngineError, Result, Session};
impl Session {
/// Load settings and shortcut presets from the config store and apply them.
pub fn load_settings(&mut self) {
if let Some(c) = &self.config {
if let Some(t) = c.read(PREFS_FILE) {
self.prefs = Prefs::from_json(&t);
}
if let Some(t) = c.read(SHORTCUTS_FILE) {
self.keymaps = Keymaps::from_json(&t);
}
}
self.prefs_changed();
self.shortcuts_changed();
}
/// 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());
// Fewer undo levels apply right away.
let levels = self.prefs.general.undo_levels.max(1) as usize;
if self.history.undo.len() > levels {
let extra = self.history.undo.len() - levels;
self.history.undo.drain(..extra);
}
self.prefs_revision += 1;
}
/// Write settings to the config store.
pub fn save_prefs(&self) {
if let Some(c) = &self.config
&& let Err(e) = c.write(PREFS_FILE, &self.prefs.to_json())
{
log::warn!("saving settings: {e}");
}
}
/// Shortcut presets changed: rebuild the table and save them.
pub fn shortcuts_changed(&mut self) {
self.shortcut_table = std::sync::OnceLock::new();
if let Some(c) = &self.config
&& let Err(e) = c.write(SHORTCUTS_FILE, &self.keymaps.to_json())
{
log::warn!("saving keyboard shortcuts: {e}");
}
}
/// The frontend's own bindable commands (tools, timeline navigation…).
pub fn set_ui_commands(&mut self, cmds: Vec<UiCommand>) {
self.ui_commands = cmds;
self.shortcut_table = std::sync::OnceLock::new();
}
/// The active shortcut preset resolved against every bindable command.
pub fn shortcuts(&self) -> &ShortcutTable {
self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands))
}
/// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel.
pub(crate) fn note_project_path(&mut self, path: &str) {
self.prefs.push_recent(path);
self.save_prefs();
self.autosave = autosave::AutoSaveState { last: self.autosave.last, sentinel: self.autosave.sentinel, ..Default::default() };
self.update_sentinel();
}
/// Refresh the crash-recovery sentinel (when one is active).
pub fn update_sentinel(&self) {
if let Some(c) = &self.config
&& self.autosave.sentinel
{
let s = Sentinel { pid: autosave::pid(), project: self.path.clone(), autosave: self.autosave.last_path.clone(), dirty: self.is_dirty() };
autosave::update(c.as_ref(), &s);
}
}
/// Fallback folder for auto-saves of untitled projects (next to the settings).
pub fn default_autosave_root(&self) -> Option<std::path::PathBuf> {
self.config.as_ref().and_then(|c| c.dir())
}
/// Write an auto-save now (whether or not the project is dirty). Returns its path.
pub fn autosave_now(&mut self) -> Result<String> {
let json = self.project.to_json();
let root = self.default_autosave_root();
let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
.map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
let p = path.to_string_lossy().to_string();
self.autosave.last_slot = Some(slot);
self.autosave.saved_revision = Some(self.revision);
self.autosave.last_path = Some(p.clone());
self.update_sentinel();
Ok(p)
}
/// Call periodically with wall-clock seconds: auto-saves a dirty project once the interval
/// has passed since the last auto-save (or since the first call). Returns the written path.
pub fn autosave_tick(&mut self, now: f64) -> Option<Result<String>> {
let a = &self.prefs.auto_save;
if !a.enabled {
self.autosave.last = Some(now);
return None;
}
let interval = a.interval_minutes.max(1) as f64 * 60.0;
let last = *self.autosave.last.get_or_insert(now);
if now - last < interval {
return None;
}
self.autosave.last = Some(now);
if !self.is_dirty() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() {
return None;
}
if self.path.is_none() && self.prefs.auto_save.location != "custom" && self.default_autosave_root().is_none() {
return None;
}
let r = self.autosave_now();
if let Ok(p) = &r {
self.toast(format!("Auto-saved {}", Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
}
Some(r)
}
/// At launch: start crash recovery. Returns the previous run's state when it didn't exit
/// cleanly (and the setting asks for it).
pub fn begin_recovery(&mut self) -> Option<autosave::Recovery> {
let c = self.config.clone()?;
let r = autosave::begin(c.as_ref(), &self.prefs);
self.autosave.sentinel = true;
self.update_sentinel();
r.filter(|_| self.prefs.startup.offer_crash_recovery)
}
/// Clean exit: remove the crash-recovery sentinel.
pub fn end_recovery(&mut self) {
if let Some(c) = &self.config
&& self.autosave.sentinel
{
autosave::end(c.as_ref());
self.autosave.sentinel = false;
}
}
}
+468
View File
@@ -0,0 +1,468 @@
//! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts).
//!
//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key
//! (the command id, plus its bound parameters for entries such as `app.settings
//! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in
//! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom
//! presets store only their differences from it. Editing the default preset first duplicates it
//! (as After Effects asks you to "Save As" a custom set). The shortcut dispatcher and the menus
//! read the active preset through [`ShortcutTable`].
//!
//! Shortcut text is `Ctrl+Cmd+Alt+Shift+Key` (`Cmd` is ⌘ on macOS and Ctrl elsewhere).
use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Preset file in the config store.
pub const SHORTCUTS_FILE: &str = "shortcuts.json";
/// The built-in (read-only) preset.
pub const DEFAULT_PRESET: &str = "EffectCraft Default";
/// Scope of application-wide shortcuts.
pub const APP_SCOPE: &str = "Application";
/// A frontend-only command offered for binding (tools, timeline navigation…).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UiCommand {
pub id: String,
pub label: String,
pub shortcut: Option<String>,
}
/// One thing a shortcut can run.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Bindable {
/// Binding key: `command` or `command {params}`.
pub key: String,
pub command: String,
#[serde(skip_serializing_if = "Value::is_null")]
pub params: Value,
pub label: String,
/// Menu path (`["Layer", "New"]`) or a category for commands outside the menus.
pub path: Vec<String>,
/// `Application` or the panel the shortcut works in (`Timeline`, `Composition`…).
pub scope: String,
/// After Effects default keys.
pub defaults: Vec<String>,
}
/// The binding key of a command with bound parameters.
pub fn binding_key(command: &str, params: &Value) -> String {
match params {
Value::Null => command.to_string(),
Value::Object(m) if m.is_empty() => command.to_string(),
p => format!("{command} {p}"),
}
}
/// Scope of a frontend command from its id.
fn ui_scope(id: &str) -> &'static str {
if id.starts_with("timeline.") {
"Timeline"
} else if id.starts_with("view.") {
"Composition"
} else {
APP_SCOPE
}
}
/// Every bindable command: menu entries, registry commands outside the menus, then the
/// frontend's own commands.
pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
let mut out: Vec<Bindable> = vec![];
let mut seen: HashMap<String, usize> = HashMap::new();
let mut push = |b: Bindable, out: &mut Vec<Bindable>| {
if let Some(&i) = seen.get(&b.key) {
// Same command in two menus: merge defaults.
for d in b.defaults {
if !out[i].defaults.contains(&d) {
out[i].defaults.push(d);
}
}
return;
}
seen.insert(b.key.clone(), out.len());
out.push(b);
};
let entries = crate::menus::entries();
for (path, e) in &entries {
let key = binding_key(&e.command, &e.params);
let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect();
push(
Bindable {
key,
command: e.command.clone(),
params: e.params.clone(),
label: e.label.clone(),
path: path.clone(),
scope: APP_SCOPE.into(),
defaults,
},
&mut out,
);
}
for c in crate::command_specs() {
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
continue;
}
let defaults = c.shortcut.and_then(normalize).into_iter().collect();
push(
Bindable {
key: c.id.to_string(),
command: c.id.to_string(),
params: Value::Null,
label: c.label.to_string(),
path: vec!["Commands".into()],
scope: APP_SCOPE.into(),
defaults,
},
&mut out,
);
}
for c in ui {
let scope = ui_scope(&c.id);
let cat = if scope == APP_SCOPE { "Tools & Panels" } else { scope };
push(
Bindable {
key: c.id.clone(),
command: c.id.clone(),
params: Value::Null,
label: c.label.clone(),
path: vec![cat.into()],
scope: scope.into(),
defaults: c.shortcut.as_deref().and_then(normalize).into_iter().collect(),
},
&mut out,
);
}
out
}
/// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it
/// has no key.
pub fn normalize(s: &str) -> Option<String> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (mut ctrl, mut cmd, mut alt, mut shift) = (false, false, false, false);
let mut key: Option<String> = None;
// `Cmd++` / `+` name the plus key.
let (head, plus) = match s.strip_suffix("++") {
Some(h) => (h, true),
None if s == "+" => ("", true),
None => (s, false),
};
let mut parts: Vec<String> = head.split('+').filter(|p| !p.is_empty()).map(str::to_string).collect();
// macOS glyph notation.
if parts.len() == 1 && parts[0].chars().any(|c| "⌃⌥⇧⌘".contains(c)) {
let p = parts.remove(0);
let mut rest = String::new();
for c in p.chars() {
match c {
'⌃' => ctrl = true,
'⌥' => alt = true,
'⇧' => shift = true,
'⌘' => cmd = true,
c => rest.push(c),
}
}
if !rest.is_empty() {
parts.push(rest);
}
}
for p in parts {
match p.to_ascii_lowercase().as_str() {
"cmd" | "command" | "meta" | "super" | "win" => cmd = true,
"ctrl" | "control" => ctrl = true,
"alt" | "opt" | "option" => alt = true,
"shift" => shift = true,
_ => key = Some(key_name(&p)),
}
}
if plus {
key = Some("+".into());
}
let key = key?;
let mut out = String::new();
for (on, m) in [(ctrl, "Ctrl+"), (cmd, "Cmd+"), (alt, "Alt+"), (shift, "Shift+")] {
if on {
out.push_str(m);
}
}
out.push_str(&key);
Some(out)
}
fn key_name(k: &str) -> String {
let l = k.to_ascii_lowercase();
match l.as_str() {
"space" | "spacebar" => "Space".into(),
"esc" | "escape" => "Escape".into(),
"enter" | "return" => "Enter".into(),
"tab" => "Tab".into(),
"backspace" => "Backspace".into(),
"delete" | "del" => "Delete".into(),
"home" => "Home".into(),
"end" => "End".into(),
"pageup" | "pgup" => "PageUp".into(),
"pagedown" | "pgdn" => "PageDown".into(),
"up" | "arrowup" => "ArrowUp".into(),
"down" | "arrowdown" => "ArrowDown".into(),
"left" | "arrowleft" => "ArrowLeft".into(),
"right" | "arrowright" => "ArrowRight".into(),
_ if l.starts_with("num") => format!("Num{}", &k[3..]),
_ if l.len() > 1 && l.starts_with('f') && l[1..].chars().all(|c| c.is_ascii_digit()) => l.to_ascii_uppercase(),
_ if k.chars().count() == 1 => k.to_uppercase(),
_ => k.to_string(),
}
}
/// A custom preset: differences from the default (`[]` removes a default shortcut).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Preset {
pub name: String,
#[serde(default)]
pub overrides: BTreeMap<String, Vec<String>>,
}
/// The saved presets and which one is active.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Keymaps {
pub active: String,
pub presets: Vec<Preset>,
}
impl Default for Keymaps {
fn default() -> Self {
Keymaps { active: DEFAULT_PRESET.into(), presets: vec![] }
}
}
/// A conflict: another command already using the keys in an overlapping scope.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Conflict {
pub keys: String,
pub key: String,
pub label: String,
pub scope: String,
}
fn scopes_overlap(a: &str, b: &str) -> bool {
a == b || a == APP_SCOPE || b == APP_SCOPE
}
impl Keymaps {
pub fn from_json(text: &str) -> Keymaps {
let mut k: Keymaps = serde_json::from_str(text).unwrap_or_default();
if !k.presets.iter().any(|p| p.name == k.active) {
k.active = DEFAULT_PRESET.into();
}
k
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
/// Preset names, the default first.
pub fn names(&self) -> Vec<String> {
std::iter::once(DEFAULT_PRESET.to_string()).chain(self.presets.iter().map(|p| p.name.clone())).collect()
}
pub fn active_preset(&self) -> Option<&Preset> {
self.presets.iter().find(|p| p.name == self.active)
}
/// Effective keys of a bindable in the active preset.
pub fn keys_of(&self, b: &Bindable) -> Vec<String> {
match self.active_preset().and_then(|p| p.overrides.get(&b.key)) {
Some(k) => k.clone(),
None => b.defaults.clone(),
}
}
fn unique_name(&self, base: &str) -> String {
if !self.names().iter().any(|n| n == base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !self.names().contains(n)).unwrap_or_else(|| base.to_string())
}
/// Make sure a custom preset is active (editing the default duplicates it as "Custom").
/// Returns the name of a preset created for the edit.
fn ensure_custom(&mut self) -> Option<String> {
if self.active_preset().is_some() {
return None;
}
let name = self.unique_name("Custom");
self.presets.push(Preset { name: name.clone(), overrides: BTreeMap::new() });
self.active = name.clone();
Some(name)
}
/// Set the keys of a bindable (normalized; empty = no shortcut). Returns a newly created
/// preset name when the default was active.
pub fn set(&mut self, b: &Bindable, keys: &[String]) -> Result<Option<String>, String> {
let mut norm = vec![];
for k in keys {
let n = normalize(k).ok_or_else(|| format!("`{k}` is not a shortcut"))?;
if !norm.contains(&n) {
norm.push(n);
}
}
let created = self.ensure_custom();
let p = self.presets.iter_mut().find(|p| p.name == self.active).ok_or("no active preset")?;
if norm == b.defaults {
p.overrides.remove(&b.key);
} else {
p.overrides.insert(b.key.clone(), norm);
}
Ok(created)
}
/// Clear the active preset's changes (its shortcuts become the defaults).
pub fn reset_active(&mut self) {
let active = self.active.clone();
if let Some(p) = self.presets.iter_mut().find(|p| p.name == active) {
p.overrides.clear();
}
}
pub fn select(&mut self, name: &str) -> Result<(), String> {
if !self.names().iter().any(|n| n == name) {
return Err(format!("no shortcut preset `{name}`"));
}
self.active = name.to_string();
Ok(())
}
/// Duplicate a preset (the default or a custom one) under a new name and activate it.
pub fn duplicate(&mut self, from: &str, name: Option<&str>) -> Result<String, String> {
let overrides = if from == DEFAULT_PRESET {
BTreeMap::new()
} else {
self.presets.iter().find(|p| p.name == from).ok_or_else(|| format!("no shortcut preset `{from}`"))?.overrides.clone()
};
let name = self.unique_name(name.unwrap_or(&format!("{from} copy")));
self.presets.push(Preset { name: name.clone(), overrides });
self.active = name.clone();
Ok(name)
}
pub fn delete(&mut self, name: &str) -> Result<(), String> {
if name == DEFAULT_PRESET {
return Err("the default preset can't be deleted".into());
}
let n = self.presets.len();
self.presets.retain(|p| p.name != name);
if self.presets.len() == n {
return Err(format!("no shortcut preset `{name}`"));
}
if self.active == name {
self.active = DEFAULT_PRESET.into();
}
Ok(())
}
pub fn rename(&mut self, from: &str, to: &str) -> Result<(), String> {
if from == DEFAULT_PRESET || to == DEFAULT_PRESET {
return Err("the default preset can't be renamed".into());
}
if self.names().iter().any(|n| n == to) {
return Err(format!("a preset named `{to}` exists"));
}
let p = self.presets.iter_mut().find(|p| p.name == from).ok_or_else(|| format!("no shortcut preset `{from}`"))?;
p.name = to.to_string();
if self.active == from {
self.active = to.to_string();
}
Ok(())
}
/// A preset as a portable JSON document.
pub fn export(&self, name: Option<&str>) -> Result<Value, String> {
let name = name.unwrap_or(&self.active);
let overrides = if name == DEFAULT_PRESET {
BTreeMap::new()
} else {
self.presets.iter().find(|p| p.name == name).ok_or_else(|| format!("no shortcut preset `{name}`"))?.overrides.clone()
};
Ok(json!({"format": "effectcraft-shortcuts", "version": 1, "name": name, "base": DEFAULT_PRESET, "overrides": overrides}))
}
/// Import an exported preset (renamed if the name is taken) and activate it.
pub fn import(&mut self, doc: &Value, name: Option<&str>) -> Result<String, String> {
let overrides: BTreeMap<String, Vec<String>> =
serde_json::from_value(doc.get("overrides").cloned().unwrap_or_else(|| json!({}))).map_err(|e| format!("bad shortcut preset: {e}"))?;
let overrides = overrides.into_iter().map(|(k, v)| (k, v.iter().filter_map(|s| normalize(s)).collect())).collect();
let base = name.or_else(|| doc.get("name").and_then(Value::as_str)).unwrap_or("Imported");
let base = if base == DEFAULT_PRESET { "Imported" } else { base };
let name = self.unique_name(base);
self.presets.push(Preset { name: name.clone(), overrides });
self.active = name.clone();
Ok(name)
}
}
/// The active preset resolved against every bindable command.
#[derive(Clone, Debug, Default)]
pub struct ShortcutTable {
pub bindables: Vec<Bindable>,
/// Effective keys per binding key.
pub keys: HashMap<String, Vec<String>>,
}
impl ShortcutTable {
pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable {
let bindables = bindables(ui);
let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect();
ShortcutTable { bindables, keys }
}
pub fn find(&self, key: &str) -> Option<&Bindable> {
self.bindables.iter().find(|b| b.key == key)
}
/// First shortcut of a command / menu entry (for menus).
pub fn shortcut_of(&self, command: &str, params: &Value) -> Option<&str> {
self.keys.get(&binding_key(command, params)).and_then(|k| k.first()).map(String::as_str)
}
/// Every active binding: (keys, bindable).
pub fn bindings(&self) -> Vec<(&str, &Bindable)> {
self.bindables.iter().flat_map(|b| self.keys.get(&b.key).into_iter().flatten().map(move |k| (k.as_str(), b))).collect()
}
/// Other bindables using `keys` in an overlapping scope.
pub fn conflicts(&self, b: &Bindable, keys: &str) -> Vec<Conflict> {
let Some(keys) = normalize(keys) else { return vec![] };
self.bindings()
.into_iter()
.filter(|(k, o)| *k == keys && o.key != b.key && scopes_overlap(&o.scope, &b.scope))
.map(|(k, o)| Conflict { keys: k.to_string(), key: o.key.clone(), label: o.label.clone(), scope: o.scope.clone() })
.collect()
}
/// Every pair of commands sharing keys in an overlapping scope.
pub fn all_conflicts(&self) -> Vec<(String, Conflict)> {
let mut by_keys: BTreeMap<&str, Vec<&Bindable>> = BTreeMap::new();
for (k, b) in self.bindings() {
if k != "Num*" {
by_keys.entry(k).or_default().push(b);
}
}
let mut out = vec![];
for (k, bs) in by_keys {
for (i, a) in bs.iter().enumerate() {
for b in &bs[i + 1..] {
if scopes_overlap(&a.scope, &b.scope) {
out.push((a.key.clone(), Conflict { keys: k.to_string(), key: b.key.clone(), label: b.label.clone(), scope: b.scope.clone() }));
}
}
}
}
out
}
}
+466
View File
@@ -0,0 +1,466 @@
//! Settings, keyboard shortcut presets, auto-save and crash recovery.
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path};
use crate::prefs::{PREFS_FILE, Prefs};
use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize};
use crate::{Session, autosave};
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-settings-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn session_with_comp() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 10})).unwrap();
s
}
// ---------------------------------------------------------------- prefs model
#[test]
fn prefs_round_trip_through_json() {
let mut p = Prefs::default();
p.set("general.undoLevels", json!(12)).unwrap();
p.set("labels.2.name", json!("Teal")).unwrap();
p.set("autoSave.location", json!("custom")).unwrap();
p.set("appearance.brightness", json!(0.25)).unwrap();
p.push_recent("/a/b.ecproj");
let back = Prefs::from_json(&p.to_json());
assert_eq!(back, p);
assert_eq!(Prefs::from_json(&Prefs::default().to_json()), Prefs::default());
}
#[test]
fn prefs_migrate_old_layout_and_keep_unknown_keys() {
let old = r#"{
"undoLevels": 50, "autoSaveMinutes": 7, "autoSaveVersions": 3, "theme": "light",
"labelNames": ["Hot", "Sun"],
"futureSetting": {"x": 1},
"general": {"showToolTips": false, "notYetKnown": true},
"memory": {"layerCacheMb": "not a number"}
}"#;
let p = Prefs::from_json(old);
assert_eq!(p.version, crate::prefs::PREFS_VERSION);
assert_eq!(p.general.undo_levels, 50);
assert!(!p.general.show_tool_tips);
assert_eq!(p.auto_save.interval_minutes, 7);
assert_eq!(p.auto_save.max_versions, 3);
assert_eq!(p.appearance.theme, "light");
assert_eq!(p.labels[0].name, "Hot");
assert_eq!(p.labels[1].name, "Sun");
assert_eq!(p.labels[2].name, "Aqua");
assert_eq!(p.labels.len(), 16);
// A bad value falls back to that page's defaults; other pages survive.
assert_eq!(p.memory.layer_cache_mb, 1024);
// Unknown keys (a newer version's) are kept and written back.
let again: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
assert_eq!(again["futureSetting"], json!({"x": 1}));
assert_eq!(again["general"]["notYetKnown"], json!(true));
assert!(again.get("undoLevels").is_none(), "migrated keys leave the top level");
// Garbage is defaults.
assert_eq!(Prefs::from_json("not json"), Prefs::default());
}
#[test]
fn prefs_set_validates_keys_types_and_ranges() {
let mut p = Prefs::default();
assert!(p.set("general.nope", json!(1)).is_err());
assert!(p.set("general.showToolTips", json!([1])).is_err());
p.set("general.undoLevels", json!(500)).unwrap();
assert_eq!(p.general.undo_levels, 99);
p.set("general.undoLevels", json!("7")).unwrap();
assert_eq!(p.general.undo_levels, 7);
p.set("labels.0.color", json!("zzz")).unwrap();
assert_eq!(p.labels[0].color, Prefs::default().labels[0].color);
p.reset(Some("general")).unwrap();
assert_eq!(p.general.undo_levels, 32);
assert!(p.reset(Some("bogus")).is_err());
}
#[test]
fn every_schema_key_exists_and_docs_list_the_todo_settings() {
let p = Prefs::default();
let mut ids = vec![];
for page in crate::prefs::pages() {
assert!(crate::prefs::page_id(page.id).is_some(), "page {}", page.id);
ids.push(page.id);
for item in page.items {
if let crate::prefs::Item::Setting { key, .. } | crate::prefs::Item::AudioDevices { key } = item {
assert!(p.get(key).is_some(), "schema key `{key}` is not a setting");
}
}
}
// The Settings menu and the dialog list the same pages.
for (path, e) in crate::menus::entries() {
if e.command == "app.settings" {
let page = e.params["page"].as_str().unwrap();
assert!(ids.contains(&page), "{path:?} opens unknown page {page}");
}
}
let docs = include_str!("../../../docs/preferences.md");
for k in crate::prefs::todo_keys() {
assert!(docs.contains(&format!("`{k}`")), "docs/preferences.md must list the not-yet-wired setting `{k}`");
}
}
// ---------------------------------------------------------------- prefs effects
#[test]
fn prefs_commands_get_set_reset_and_persist() {
let store = Arc::new(MemoryConfig::default());
let mut s = Session { config: Some(store.clone()), ..Default::default() };
s.execute_checked("prefs.set", json!({"key": "general.recentItems", "value": 4})).unwrap();
assert_eq!(s.execute("prefs.get", json!({"key": "general.recentItems"})).unwrap(), json!(4));
s.execute("prefs.set", json!({"values": {"autoSave.maxVersions": 9, "grids.gridSpacing": 50}})).unwrap();
assert!(s.execute("prefs.set", json!({"key": "nope.nope", "value": 1})).is_err());
let saved = store.read(PREFS_FILE).unwrap();
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
s2.load_settings();
assert_eq!(s2.prefs.general.recent_items, 4);
assert_eq!(s2.prefs.auto_save.max_versions, 9);
assert!(saved.contains("\"maxVersions\": 9"));
s2.execute("prefs.reset", json!({"page": "project"})).unwrap();
assert_eq!(s2.prefs.auto_save.max_versions, 5);
assert!(s2.execute("prefs.pages", json!({})).unwrap().as_array().unwrap().len() >= 17);
s2.execute("prefs.open", json!({"page": "Auto-Save"})).unwrap();
assert!(
s2.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, params } if command == "app.settings" && params["page"] == "project"))
);
}
#[test]
fn undo_levels_are_honoured() {
let mut s = session_with_comp();
s.execute("prefs.set", json!({"key": "general.undoLevels", "value": 3})).unwrap();
for i in 0..6 {
s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap();
}
assert_eq!(s.history.undo.len(), 3);
for _ in 0..3 {
assert!(s.undo());
}
assert!(!s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), 3);
// Lowering the setting trims the history right away.
s.execute("prefs.set", json!({"key": "general.undoLevels", "value": 1})).unwrap();
assert!(s.history.undo.len() <= 1);
}
#[test]
fn renamed_labels_show_in_the_label_menu_and_apply_by_name() {
let mut s = session_with_comp();
s.execute("layer.newNull", json!({})).unwrap();
s.execute("prefs.set", json!({"key": "labels.0.name", "value": "Hot Sauce"})).unwrap();
s.execute("prefs.set", json!({"key": "labels.0.color", "value": "#102030"})).unwrap();
let red = crate::menus::entries().into_iter().find(|(_, e)| e.command == "edit.label" && e.params["label"] == "Red").unwrap().1;
assert_eq!(crate::menus::entry_label(&s, red), "Hot Sauce");
assert_eq!(s.prefs.label_rgb(effectcraft_color::Label::Red), [0x10, 0x20, 0x30]);
s.execute("edit.label", json!({"label": "Yellow"})).unwrap();
s.execute("edit.label", json!({"label": "hot sauce"})).unwrap();
let l = &s.active_comp().unwrap().layers[0];
assert_eq!(l.label, effectcraft_color::Label::Red);
}
#[test]
fn cache_budgets_are_applied() {
let mut s = Session::default();
s.execute("prefs.set", json!({"key": "memory.layerCacheMb", "value": 128})).unwrap();
assert_eq!(s.layer_cache.budget(), 128 << 20);
struct Src(std::sync::Mutex<usize>);
impl effectcraft_render::FootageSource for Src {
fn frame(&self, _: effectcraft_project::ItemId, _: &effectcraft_project::Footage, _: effectcraft_time::Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn set_cache_budget(&self, b: usize) {
*self.0.lock().unwrap() = b;
}
}
let src = Arc::new(Src(Default::default()));
s.footage = src.clone();
s.execute("prefs.set", json!({"key": "memory.mediaCacheMb", "value": 256})).unwrap();
assert_eq!(*src.0.lock().unwrap(), 256 << 20);
}
#[test]
fn new_layers_start_at_the_current_time_when_asked() {
let mut s = session_with_comp();
let nested = s.execute("comp.new", json!({"name": "Nested", "open": false})).unwrap()["comp"].as_u64().unwrap();
s.set_time(effectcraft_time::Tick::from_seconds_f64(2.0));
s.execute("layer.addItem", json!({"item": nested})).unwrap();
assert_eq!(s.active_comp().unwrap().layers[0].start_time, effectcraft_time::Tick::ZERO);
s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap();
s.execute("layer.addItem", json!({"item": nested})).unwrap();
assert_eq!(s.active_comp().unwrap().layers[0].start_time.seconds().round(), 2.0);
}
#[test]
fn default_spatial_interpolation_linear() {
let mut s = session_with_comp();
s.execute("layer.newNull", json!({})).unwrap();
s.execute("prop.set", json!({"path": "transform/position", "value": [0, 0], "time": 0})).unwrap();
s.execute("prop.toggleAnimation", json!({"path": "transform/position", "value": true})).ok();
s.execute("prop.addKey", json!({"path": "transform/position", "time": 0})).unwrap();
let auto =
|s: &Session| s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().keys.iter().map(|k| k.spatial_auto).collect::<Vec<_>>();
assert!(auto(&s).iter().all(|a| *a));
s.execute("prefs.set", json!({"key": "general.defaultSpatialLinear", "value": true})).unwrap();
s.execute("prop.addKey", json!({"path": "transform/position", "time": 1, "value": [100, 50]})).unwrap();
assert_eq!(auto(&s), vec![true, false]);
}
#[test]
fn new_project_template_and_default_renderer() {
let d = tmp("template");
let tpl = d.join("tpl.ecproj");
let mut a = session_with_comp();
a.execute("file.saveAs", json!({"path": tpl.to_string_lossy()})).unwrap();
let mut s = Session::default();
s.execute("prefs.set", json!({"values": {"project.useTemplate": true, "project.templatePath": tpl.to_string_lossy()}})).unwrap();
s.execute("file.newProject", json!({})).unwrap();
assert_eq!(s.project.comps().count(), 1);
assert!(s.path.is_none(), "a template opens untitled");
s.execute("prefs.set", json!({"key": "threeD.defaultRenderer", "value": "advanced"})).unwrap();
s.execute("comp.new", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D);
let _ = std::fs::remove_dir_all(d);
}
// ---------------------------------------------------------------- shortcuts
#[test]
fn shortcut_text_normalizes() {
assert_eq!(normalize("shift+cmd+k").as_deref(), Some("Cmd+Shift+K"));
assert_eq!(normalize("Opt+Ctrl+f5").as_deref(), Some("Ctrl+Alt+F5"));
assert_eq!(normalize("⌘⇧S").as_deref(), Some("Cmd+Shift+S"));
assert_eq!(normalize("Cmd++").as_deref(), Some("Cmd++"));
assert_eq!(normalize("space").as_deref(), Some("Space"));
assert_eq!(normalize("Cmd+").as_deref(), None);
}
#[test]
fn defaults_have_no_conflicts_and_match_the_menus() {
let s = Session::default();
let t = s.shortcuts();
assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts());
assert_eq!(t.shortcut_of("file.save", &json!(null)), Some("Cmd+S"));
assert_eq!(t.shortcut_of("app.settings", &json!({"page": "general"})), Some("Cmd+Alt+;"));
assert!(t.bindables.len() > 500);
}
#[test]
fn shortcut_edits_detect_conflicts_and_create_a_custom_preset() {
let store = Arc::new(MemoryConfig::default());
let mut s = Session { config: Some(store.clone()), ..Default::default() };
let r = s.execute_checked("shortcuts.set", json!({"command": "layer.newNull", "keys": "cmd+s"})).unwrap();
assert_eq!(r["keys"], json!(["Cmd+S"]));
assert_eq!(r["created"], json!("Custom"));
assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["key"] == "file.save"), "{r}");
assert_eq!(s.keymaps.active, "Custom");
assert_eq!(s.shortcuts().shortcut_of("layer.newNull", &json!(null)), Some("Cmd+S"));
assert!(!s.execute("shortcuts.conflicts", json!({})).unwrap().as_array().unwrap().is_empty());
// The default preset is untouched.
s.execute("shortcuts.preset", json!({"op": "select", "name": DEFAULT_PRESET})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("layer.newNull", &json!(null)), Some("Cmd+Alt+Shift+Y"));
// Remove a shortcut; reset one.
s.execute("shortcuts.preset", json!({"op": "select", "name": "Custom"})).unwrap();
s.execute("shortcuts.set", json!({"command": "file.save", "keys": null})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("file.save", &json!(null)), None);
s.execute("shortcuts.reset", json!({"command": "file.save"})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("file.save", &json!(null)), Some("Cmd+S"));
// Bound-parameter entries are bindable too.
s.execute("shortcuts.set", json!({"command": "app.settings", "params": {"page": "labels"}, "keys": ["F9"]})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("app.settings", &json!({"page": "labels"})), Some("F9"));
// Persisted.
let k = Keymaps::from_json(&store.read(crate::shortcuts::SHORTCUTS_FILE).unwrap());
assert_eq!(k.active, "Custom");
assert!(s.execute("shortcuts.set", json!({"command": "no.such", "keys": "F1"})).is_err());
assert!(s.execute("shortcuts.set", json!({"command": "file.save", "keys": "Cmd+"})).is_err());
let list = s.execute("shortcuts.list", json!({"keys": "F9"})).unwrap();
assert!(list["commands"].as_array().unwrap().iter().any(|c| c["command"] == "app.settings" && c["params"]["page"] == "labels"), "{list}");
}
#[test]
fn shortcut_presets_duplicate_rename_delete_export_import() {
let d = tmp("presets");
let mut s = Session::default();
s.execute("shortcuts.preset", json!({"op": "new", "name": "Mine"})).unwrap();
s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": ["Cmd+Z", "F2"]})).unwrap();
s.execute("shortcuts.preset", json!({"op": "duplicate", "name": "Mine 2"})).unwrap();
assert_eq!(s.keymaps.names(), vec![DEFAULT_PRESET, "Mine", "Mine 2"]);
s.execute("shortcuts.preset", json!({"op": "rename", "name": "Mine 2", "newName": "Other"})).unwrap();
assert!(s.execute("shortcuts.preset", json!({"op": "delete", "name": DEFAULT_PRESET})).is_err());
let path = d.join("mine.json");
s.execute("shortcuts.export", json!({"preset": "Mine", "path": path.to_string_lossy()})).unwrap();
s.execute("shortcuts.preset", json!({"op": "delete", "name": "Mine"})).unwrap();
s.execute("shortcuts.preset", json!({"op": "delete", "name": "Other"})).unwrap();
assert_eq!(s.keymaps.active, DEFAULT_PRESET);
let r = s.execute("shortcuts.import", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(r["preset"], json!("Mine"));
assert_eq!(s.shortcuts().keys["edit.undo"], vec!["Cmd+Z".to_string(), "F2".into()]);
s.execute("shortcuts.reset", json!({})).unwrap();
assert_eq!(s.shortcuts().keys["edit.undo"], vec!["Cmd+Z".to_string()]);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn frontend_commands_are_bindable_with_panel_scope() {
let mut s = Session::default();
s.set_ui_commands(vec![
crate::shortcuts::UiCommand { id: "timeline.zoomIn".into(), label: "Zoom In Time".into(), shortcut: Some("=".into()) },
crate::shortcuts::UiCommand { id: "tool.hand".into(), label: "Hand Tool".into(), shortcut: Some("H".into()) },
]);
let b = s.shortcuts().find("timeline.zoomIn").unwrap().clone();
assert_eq!(b.scope, "Timeline");
assert_eq!(s.shortcuts().shortcut_of("tool.hand", &json!(null)), Some("H"));
// A panel shortcut conflicts with an application shortcut on the same keys.
let c = s.shortcuts().conflicts(&b, "H");
assert!(c.iter().any(|c| c.key == "tool.hand"));
}
// ---------------------------------------------------------------- auto-save / recovery
#[test]
fn atomic_write_survives_a_crash_mid_write() {
let d = tmp("atomic");
let f = d.join("p.ecproj");
atomic_write(&f, b"version 1").unwrap();
// The writer dies halfway through the temporary file.
let r = atomic_write_with(&f, b"version 2 is much longer", |tmp, data| {
std::fs::write(tmp, &data[..5])?;
Err(std::io::Error::other("power cut"))
});
assert!(r.is_err());
assert_eq!(std::fs::read(&f).unwrap(), b"version 1");
assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up");
// A temp file left by a hard kill doesn't count as an auto-save.
std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap();
assert!(autosave::existing(&d, "p").is_empty());
atomic_write(&f, b"version 3").unwrap();
assert_eq!(std::fs::read(&f).unwrap(), b"version 3");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn autosave_rotates_through_max_versions() {
let d = tmp("rotate");
let proj = d.join("Intro.ecproj");
let mut s = session_with_comp();
s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap();
s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 3})).unwrap();
let folder = d.join(autosave::AUTOSAVE_FOLDER);
let mut written = vec![];
for i in 0..5 {
s.execute("layer.newNull", json!({"name": format!("save{i}")})).unwrap();
written.push(s.autosave_now().unwrap());
}
let names: Vec<String> = written.iter().map(|p| PathBuf::from(p).file_name().unwrap().to_string_lossy().to_string()).collect();
assert_eq!(
names,
["Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj", "Intro auto-save 3.ecproj", "Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj"]
);
let have: Vec<u32> = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect();
assert_eq!(have, [1, 2, 3]);
let slot2 = std::fs::read_to_string(folder.join("Intro auto-save 2.ecproj")).unwrap();
assert!(slot2.contains("save4"), "slot 2 holds the newest save");
// The project file itself is untouched and still dirty.
assert!(s.is_dirty());
assert!(!std::fs::read_to_string(&proj).unwrap().contains("save0"));
// Lowering the maximum drops the extra slots.
s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 2})).unwrap();
s.autosave_now().unwrap();
let have: Vec<u32> = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect();
assert_eq!(have, [1, 2]);
// A fresh session continues after the newest file on disk.
let next = autosave::next_slot(&autosave::existing(&folder, "Intro"), None, 2);
let newest = autosave::existing(&folder, "Intro").into_iter().max_by_key(|e| e.2).unwrap().0;
assert_eq!(next, newest % 2 + 1);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn autosave_tick_waits_for_the_interval_and_changes() {
let d = tmp("tick");
let mut s = session_with_comp();
s.execute("file.saveAs", json!({"path": d.join("T.ecproj").to_string_lossy()})).unwrap();
s.execute(
"prefs.set",
json!({"values": {"autoSave.intervalMinutes": 1, "autoSave.location": "custom", "autoSave.folder": d.join("custom").to_string_lossy()}}),
)
.unwrap();
assert!(s.autosave_tick(1000.0).is_none(), "the first tick starts the clock");
s.execute("layer.newNull", json!({})).unwrap();
assert!(s.autosave_tick(1030.0).is_none(), "interval not reached");
let p = s.autosave_tick(1061.0).unwrap().unwrap();
assert!(p.contains("custom") && p.ends_with("T auto-save 1.ecproj"), "{p}");
assert!(s.autosave_tick(1200.0).is_none(), "nothing changed since");
s.execute("prefs.set", json!({"key": "autoSave.enabled", "value": false})).unwrap();
s.execute("layer.newNull", json!({})).unwrap();
assert!(s.autosave_tick(5000.0).is_none(), "disabled");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn crash_recovery_detects_an_unclean_exit() {
let d = tmp("recovery");
let store: Arc<dyn ConfigStore> = Arc::new(DirConfig::new(d.join("config")));
// First run: open a project, auto-save, then "crash" (no end_recovery).
let mut s = session_with_comp();
s.config = Some(store.clone());
assert!(s.begin_recovery().is_none(), "first launch is clean");
let proj = d.join("Crash.ecproj");
s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap();
s.execute("layer.newNull", json!({"name": "unsaved work"})).unwrap();
let auto = s.autosave_now().unwrap();
drop(s);
// Second run finds the sentinel and offers the auto-save.
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
let r = s2.begin_recovery().expect("unclean exit detected");
assert_eq!(r.project.as_deref(), Some(proj.to_string_lossy().as_ref()));
assert_eq!(r.autosave.as_deref(), Some(auto.as_str()));
s2.execute("file.open", json!({"path": r.autosave.unwrap()})).unwrap();
assert!(s2.active_comp().unwrap().layers.iter().any(|l| l.name == "unsaved work"));
// Clean exit: nothing to recover next time.
s2.end_recovery();
let mut s3 = Session { config: Some(store.clone()), ..Default::default() };
assert!(s3.begin_recovery().is_none());
// The setting can turn the offer off.
let mut s4 = Session { config: Some(store), ..Default::default() };
s4.prefs.startup.offer_crash_recovery = false;
assert!(s4.begin_recovery().is_none());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn open_recent_increment_and_revert() {
let d = tmp("recent");
let a = d.join("A.ecproj");
let mut s = session_with_comp();
s.execute("file.saveAs", json!({"path": a.to_string_lossy()})).unwrap();
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
assert!(r["path"].as_str().unwrap().ends_with("A 2.ecproj"));
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
assert!(r["path"].as_str().unwrap().ends_with("A 3.ecproj"));
assert!(s.prefs.recent_projects[0].ends_with("A 3.ecproj"));
assert!(s.prefs.recent_projects[2].ends_with("A.ecproj"));
s.execute("layer.newNull", json!({})).unwrap();
s.execute("file.revert", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 0);
s.execute("file.openRecent", json!({"index": 2})).unwrap();
assert!(s.path.as_deref().unwrap().ends_with("A.ecproj"));
assert!(s.prefs.recent_projects[0].ends_with("A.ecproj"));
s.execute("file.clearRecent", json!({})).unwrap();
assert!(!s.is_enabled("file.openRecent"));
assert_eq!(autosave::increment_path("/x/Intro 9.ecproj", |_| false), "/x/Intro 10.ecproj");
assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj");
let _ = std::fs::remove_dir_all(d);
}