M14.2: preferences

A real Settings model in the engine (`engine::prefs`): serde, versioned, dotted keys
(`general.undoLevels`, `labels.3.name`), migration of older layouts, unknown keys kept,
persisted through a `ConfigStore` trait (platform config dir on the desktop; MemoryConfig
for tests; the web app can back it with localStorage).

- After Effects 2026 pages (General … Scripting & Expressions) as a schema shared by the
  dialog, `prefs.pages` and docs/preferences.md (rows not wired yet are listed there and
  kept in step by a test).
- Commands: prefs.get, prefs.set {key, value | values}, prefs.reset {page?}, prefs.open
  {page?}, prefs.pages.
- Wired settings: undo levels, path point size, tool tips, layers at comp start / CTI,
  default spatial interpolation linear, Home screen on launch, new project template,
  rendering progress in Info, adaptive resolution limit, cache when idle, fast previews,
  theme + UI brightness, label names/colours everywhere (Label menu, timeline, project,
  render queue, viewer), label colour for handles, grid colour/spacing/subdivisions, guide
  colour, safe margins, still footage duration, sequence fps, audio output device and
  output mapping, layer / footage / preview cache budgets, default 3D renderer.
- Settings dialog: AE's left page list + right content, OK / Cancel (restores) / Previous /
  Next, every widget with an automation id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 09:14:45 -04:00
parent 5273f1ba96
commit 47f7712364
34 changed files with 2327 additions and 158 deletions

View File

@ -1,4 +1,5 @@
//! Audio preview output through cpal (the default output device).
//! Audio preview output through cpal (the device and channel mapping chosen in Settings ▸ Audio;
//! the default output device when none is set or it is gone).
//!
//! cpal streams are not `Send` on every platform, so each preview runs its stream on a small
//! dedicated thread that owns it until stopped. The callback pulls interleaved stereo from the
@ -12,21 +13,41 @@ use std::thread::JoinHandle;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, SizedSample};
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput};
pub struct CpalOut {
out: AudioOutput,
rate: u32,
latency: Arc<AtomicU64>,
stop: Option<mpsc::Sender<()>>,
thread: Option<JoinHandle<()>>,
}
/// Open the default output device (`None` without one).
pub fn open() -> Option<Box<dyn AudioDevice>> {
/// Names of the output devices (Settings ▸ Audio).
pub fn devices() -> Vec<String> {
let host = cpal::default_host();
let dev = host.default_output_device()?;
#[allow(deprecated)]
host.output_devices().map(|d| d.filter_map(|d| d.name().ok()).collect()).unwrap_or_default()
}
/// The named output device, or the default one.
fn device(name: &str) -> Option<cpal::Device> {
let host = cpal::default_host();
if !name.is_empty() {
#[allow(deprecated)]
let found = host.output_devices().ok().and_then(|mut d| d.find(|d| d.name().ok().as_deref() == Some(name)));
if found.is_some() {
return found;
}
}
host.default_output_device()
}
/// Open the output device (`None` without one).
pub fn open(out: &AudioOutput) -> Option<Box<dyn AudioDevice>> {
let dev = device(&out.device)?;
let cfg = dev.default_output_config().ok()?;
Some(Box::new(CpalOut { rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
Some(Box::new(CpalOut { out: out.clone(), rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
}
fn run<T: SizedSample + FromSample<f32>>(
@ -34,8 +55,11 @@ fn run<T: SizedSample + FromSample<f32>>(
cfg: &cpal::StreamConfig,
feed: Arc<AudioFeed>,
latency: Arc<AtomicU64>,
map: (usize, usize),
) -> Result<cpal::Stream, String> {
let ch = cfg.channels as usize;
// Output mapping: device channels (0-based) for left and right; out of range → 1 and 2.
let (left, right) = if map.0 < ch && map.1 < ch { map } else { (0, 1) };
let rate = cfg.sample_rate.0 as f64;
let mut stereo: Vec<f32> = Vec::new();
dev.build_output_stream(
@ -46,11 +70,15 @@ fn run<T: SizedSample + FromSample<f32>>(
feed.pull(&mut stereo);
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) {
for (c, o) in f.iter_mut().enumerate() {
let v = match (ch, c) {
(1, _) => (s[0] + s[1]) * 0.5,
(_, 0) => s[0],
(_, 1) => s[1],
_ => 0.0,
// Mono devices, or left and right mapped to the same channel, get the mix.
let v = if ch == 1 || (c == left && c == right) {
(s[0] + s[1]) * 0.5
} else if c == left {
s[0]
} else if c == right {
s[1]
} else {
0.0
};
*o = T::from_sample(v.clamp(-1.0, 1.0));
}
@ -77,11 +105,12 @@ impl AudioDevice for CpalOut {
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
let latency = self.latency.clone();
let rate = self.rate;
let name = self.out.device.clone();
let map = ((self.out.left.max(1) - 1) as usize, (self.out.right.max(1) - 1) as usize);
let thread = std::thread::Builder::new()
.name("ec-audio-out".into())
.spawn(move || {
let host = cpal::default_host();
let Some(dev) = host.default_output_device() else {
let Some(dev) = device(&name) else {
let _ = ready_tx.send(Err("no audio output device".into()));
return;
};
@ -95,10 +124,10 @@ impl AudioDevice for CpalOut {
let mut cfg: cpal::StreamConfig = sup.config();
cfg.sample_rate = cpal::SampleRate(rate);
let stream = match sup.sample_format() {
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency),
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency),
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency),
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency),
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency, map),
f => Err(format!("unsupported sample format {f:?}")),
};
let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) {

View File

@ -15,14 +15,14 @@ fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
let mut home = false;
let mut home: Option<bool> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => demo = true,
"--empty" => demo = false,
"--home" => home = true,
"--home" => home = Some(true),
"--version" => {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
@ -46,6 +46,11 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut session = effectcraft_host::session();
// Settings live in the platform config directory.
if let Some(dir) = config_dir() {
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
}
session.load_settings();
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
@ -60,8 +65,9 @@ fn main() -> eframe::Result {
{
eprintln!("effectcraft: {e}");
}
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = home;
app.ui.start_screen = show_home;
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}));
@ -71,6 +77,8 @@ fn main() -> eframe::Result {
app.hooks.pick_open_project =
Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
app.hooks.audio_device = Some(Box::new(audio_out::open));
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
if let Some(port) = control_port {
disable_app_nap();
let rx = control_server::start(port, cc.egui_ctx.clone());
@ -81,6 +89,24 @@ fn main() -> eframe::Result {
)
}
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
fn config_dir() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
return Some(PathBuf::from(d));
}
let home = || std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
}
if cfg!(target_os = "windows") {
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
}
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
}
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {

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

View File

@ -366,7 +366,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, _| {

View File

@ -12,6 +12,14 @@ 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);
return Ok(json!({"template": tpl}));
}
s.replace_project(Project::default(), None);
Ok(Value::Null)
}
@ -79,9 +87,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}")),
}
}

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

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;

View File

@ -19,6 +19,7 @@ mod mask;
mod prop;
mod query;
mod render_queue;
mod settings;
mod stubs;
mod styles;
mod text_anim;
@ -102,6 +103,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(file_more::specs());
v.extend(comp_more::specs());
v.extend(frontend::specs());
v.extend(settings::specs());
v.extend(stubs::specs());
v
})

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

View File

@ -0,0 +1,76 @@
//! Settings (`prefs.*`).
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{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())
}
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),
]
}

104
crates/engine/src/config.rs Normal file
View File

@ -0,0 +1,104 @@
//! Persistent configuration storage (Settings). 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`).
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)
}

View File

@ -9,10 +9,13 @@
//! (compositions are `Arc`s, so untouched comps are shared).
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;
use std::sync::Arc;
@ -208,6 +211,12 @@ 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 are stored (`None` = nothing persists).
pub config: Option<Arc<dyn config::ConfigStore>>,
}
impl Default for Session {
@ -229,6 +238,9 @@ impl Default for Session {
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
prefs: prefs::Prefs::default(),
prefs_revision: 0,
config: None,
}
}
}
@ -281,8 +293,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);
@ -456,6 +470,8 @@ mod tests_3d;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_styles;
#[cfg(test)]
mod tests_text;

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

951
crates/engine/src/prefs.rs Normal file
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)
}

View File

@ -0,0 +1,39 @@
//! Session side of Settings.
use crate::Session;
use crate::prefs::{PREFS_FILE, Prefs};
impl Session {
/// Load settings from the config store and apply them.
pub fn load_settings(&mut self) {
if let Some(c) = &self.config
&& let Some(t) = c.read(PREFS_FILE)
{
self.prefs = Prefs::from_json(&t);
}
self.prefs_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}");
}
}
}

View File

@ -0,0 +1,235 @@
//! Settings.
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use crate::Session;
use crate::config::{ConfigStore, MemoryConfig};
use crate::prefs::{PREFS_FILE, Prefs};
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);
}

View File

@ -451,6 +451,12 @@ impl Inner {
}
impl FootageSource for MediaPool {
fn set_cache_budget(&self, bytes: usize) {
self.set_budget(bytes);
}
fn cache_budget(&self) -> Option<usize> {
Some(self.budget())
}
fn frame(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
if footage.missing {
return None;

View File

@ -112,6 +112,29 @@ impl LayerCache {
}
}
/// The memory budget in bytes.
pub fn budget(&self) -> usize {
self.inner.lock().map(|g| g.budget).unwrap_or(0)
}
/// Change the memory budget (evicts least-recently used entries when shrinking).
pub fn set_budget(&self, budget: usize) {
let Ok(mut g) = self.inner.lock() else { return };
g.budget = budget;
if g.bytes > budget {
let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect();
by_age.sort_unstable();
for (_, k) in by_age {
if g.bytes <= budget {
break;
}
if let Some(e) = g.map.remove(&k) {
g.bytes -= e.bytes;
}
}
}
}
pub fn clear(&self) {
if let Ok(mut g) = self.inner.lock() {
g.map.clear();

View File

@ -37,6 +37,13 @@ pub trait FootageSource: Send + Sync {
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
None
}
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
/// a cache ignore it.
fn set_cache_budget(&self, _bytes: usize) {}
/// The decoded-frame cache budget in bytes, if the source has a cache.
fn cache_budget(&self) -> Option<usize> {
None
}
}
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]).

View File

@ -38,7 +38,22 @@ pub trait AudioDevice: Send {
}
/// Opens the default output device (`None` when there is none).
pub type AudioDeviceFactory = Box<dyn Fn() -> Option<Box<dyn AudioDevice>>>;
pub type AudioDeviceFactory = Box<dyn Fn(&AudioOutput) -> Option<Box<dyn AudioDevice>>>;
/// Which output to open (Settings ▸ Audio): device name (empty = the system default) and the
/// 1-based device channels for left and right.
#[derive(Clone, Debug, PartialEq)]
pub struct AudioOutput {
pub device: String,
pub left: u32,
pub right: u32,
}
impl AudioOutput {
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> AudioOutput {
AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right }
}
}
/// Lock-protected sample queue between the feeder thread and the device callback.
#[derive(Default)]

View File

@ -129,6 +129,24 @@ impl Frames {
v
}
/// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest
/// frames when it shrinks.
pub fn set_budget(&self, bytes: usize) {
if let Ok(mut c) = self.cache.lock() {
c.budget = bytes;
while c.bytes > c.budget {
let Some(old) = c.order.pop_front() else { break };
if let Some(i) = c.map.remove(&old) {
c.bytes -= i.pixels.len() * 4;
}
}
}
}
pub fn budget(&self) -> usize {
self.cache.lock().map(|c| c.budget).unwrap_or(0)
}
pub fn clear(&self) {
if let Ok(mut c) = self.cache.lock() {
c.map.clear();

View File

@ -67,6 +67,10 @@ pub struct Hooks {
pub pick_open_project: Option<Box<dyn Fn() -> Option<String>>>,
/// Opens the audio output for preview playback (the desktop app uses cpal).
pub audio_device: Option<audio::AudioDeviceFactory>,
/// Lists audio output devices (Settings ▸ Audio).
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
/// Picks a folder (Settings paths).
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
}
#[derive(Default)]
@ -122,6 +126,8 @@ pub struct EffectcraftApp {
pub(crate) waveforms: panels::waveform::Cache,
/// Last reveal shortcut and when (double-press shortcuts such as LL).
pub(crate) last_reveal: Option<(String, f64)>,
/// Settings revision applied to the theme, tooltips and caches.
applied_prefs: Option<u64>,
}
impl EffectcraftApp {
@ -158,18 +164,48 @@ impl EffectcraftApp {
meter: Default::default(),
waveforms: Default::default(),
last_reveal: None,
applied_prefs: None,
}
}
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
if self.applied_prefs == Some(self.session.prefs_revision) {
return;
}
self.applied_prefs = Some(self.session.prefs_revision);
let p = &self.session.prefs;
self.ui.theme = theme::ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
self.tokens = Tokens::from_prefs(p);
theme::apply_visuals(ctx, &self.tokens);
let tips = p.general.show_tool_tips;
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
self.ui.cache_when_idle = p.previews.cache_frames_when_idle;
self.ui.viewer.fast_preview = p.previews.fast_previews;
self.frames.set_budget(p.preview_cache_bytes());
}
/// Change settings from the UI (applied next frame and saved).
pub fn set_pref(&mut self, key: &str, value: serde_json::Value) -> Result<(), String> {
self.session.prefs.set(key, value)?;
self.session.prefs_changed();
self.session.save_prefs();
Ok(())
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
}
pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) {
self.ui.theme = k;
self.tokens = Tokens::for_kind(k);
theme::apply_visuals(ctx, &self.tokens);
let name = match k {
theme::ThemeKind::Dark => "dark",
theme::ThemeKind::Darker => "darker",
theme::ThemeKind::Light => "light",
};
let _ = self.set_pref("appearance.theme", json!(name));
self.apply_prefs(ctx);
}
pub fn set_workspace(&mut self, name: &str) {
@ -203,8 +239,10 @@ impl EffectcraftApp {
/// The render scale used by the viewer right now.
pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 {
if self.playback.playing && self.ui.viewer.res == state::Resolution::Auto {
// Keep previews snappy: never more than the displayed size.
return self.ui.viewer.res.scale(zoom, ppp).min(0.5);
// Keep previews snappy: half the displayed size, but no lower than Settings ▸
// Previews ▸ Adaptive Resolution Limit.
let full = self.ui.viewer.res.scale(zoom, ppp);
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
}
self.ui.viewer.res.scale(zoom, ppp)
}
@ -285,7 +323,8 @@ impl EffectcraftApp {
if !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) {
return;
}
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f()) else { return };
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop) {
Ok(a) => self.audio = Some(a),
Err(e) => log::warn!("audio preview: {e}"),
@ -536,6 +575,7 @@ impl EffectcraftApp {
self.session.poll_render();
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.apply_prefs(&ctx);
self.handle_events(&ctx);
if let Some(rx) = self.command_inbox.take() {
while let Ok(id) = rx.try_recv() {

View File

@ -193,7 +193,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
}
"view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins,
"view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid,
"view.fastPreviews" => app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview,
"view.fastPreviews" => {
let on = !app.ui.viewer.fast_preview;
app.set_pref("previews.fastPreviews", json!(on))?;
app.ui.viewer.fast_preview = on;
}
"timeline.zoomIn" | "timeline.zoomOut" => {
let k = if id == "timeline.zoomIn" { 1.5 } else { 1.0 / 1.5 };
crate::panels::timeline::zoom(app, ctx, k);
@ -310,8 +314,21 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
Value::Null
}
"app.settings" => {
app.dialog_state.settings_page = p.get("page").and_then(Value::as_str).unwrap_or("general").to_string();
app.dialog = Some(crate::Dialog::Settings);
let page = p.get("page").and_then(Value::as_str).unwrap_or("general");
crate::panels::settings::open(app, effectcraft_engine::prefs::page_id(page).unwrap_or("general"));
Value::Null
}
"app.gpuInfo" => {
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
crate::panels::dialogs::info(
app,
"GPU Information",
&format!(
"Compositing: CPU, {threads} threads (pure Rust, rayon).\nDisplay: egui on wgpu.\nLayer cache: {} MB • Preview cache: {} MB.",
app.session.layer_cache.budget() >> 20,
app.frames.budget() >> 20
),
);
Value::Null
}
"app.hide" => {
@ -349,7 +366,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.toggle_play(now);
json!({"playing": app.playback.playing})
}
"playback.cacheWhenIdle" => toggle(&mut app.ui.cache_when_idle, &p),
"playback.cacheWhenIdle" => {
let r = toggle(&mut app.ui.cache_when_idle, &p);
app.set_pref("previews.cacheFramesWhenIdle", r.clone())?;
r
}
"playback.audio" => {
let r = toggle(&mut app.ui.preview_audio, &p);
if !app.ui.preview_audio {
@ -559,7 +580,7 @@ fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
match e.command.as_str() {
"edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()),
"edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()),
_ => e.label.clone(),
_ => effectcraft_engine::menus::entry_label(&app.session, e),
}
}
@ -771,6 +792,13 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
for (key, mods) in events {
// Escape closes dialogs.
if key == egui::Key::Escape && app.dialog.is_some() {
// Escape in Settings is Cancel: restore the settings from when it opened.
if app.dialog == Some(crate::Dialog::Settings)
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
{
app.session.prefs = p;
app.session.prefs_changed();
}
app.dialog = None;
continue;
}
@ -888,6 +916,15 @@ mod tests {
}
}
#[test]
fn renamed_label_shows_in_the_label_menu() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.set_pref("labels.1.name", json!("Sunflower")).unwrap();
let labels: Vec<String> = menu_items(&app).into_iter().filter(|i| i.id == "edit.label").map(|i| i.label).collect();
assert!(labels.contains(&"Sunflower".to_string()), "{labels:?}");
assert!(!labels.contains(&"Yellow".to_string()));
}
#[test]
fn menu_items_cover_the_tree() {
assert_eq!(menus().last(), Some(&"Help"));

View File

@ -22,6 +22,8 @@ pub struct DialogState {
pub palette_sel: usize,
/// Settings dialog page id (`general`, `appearance`…).
pub settings_page: String,
/// Settings when the dialog opened (Cancel restores them).
pub prefs_snapshot: Option<effectcraft_engine::prefs::Prefs>,
/// The open parameter form.
pub form: super::forms::Form,
/// Message box (title, body).
@ -98,7 +100,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings),
Dialog::SolidSettings => solid(app, ctx, &t),
Dialog::CommandPalette => palette(app, ctx, &t),
Dialog::Settings => super::forms::show_settings(app, ctx, &t),
Dialog::Settings => super::settings::show(app, ctx, &t),
Dialog::Shortcuts => super::forms::show_shortcuts(app, ctx, &t),
Dialog::Form => super::forms::show_form(app, ctx, &t),
Dialog::Info => super::forms::show_info(app, ctx, &t),

View File

@ -1,7 +1,7 @@
//! Menu dialogs: a generic parameter form (numeric Transform dialogs, Mask Feather/Opacity/
//! Expansion, Auto-Orient, Go to Time, Add Guide, Sequence Layers, Interpret Footage, Project
//! Settings, placeholders…), Settings (Preferences), Keyboard Shortcuts, View Options and simple
//! message boxes. Every form runs an engine command with the collected parameters, so whatever a
//! Settings, placeholders…), Keyboard Shortcuts, View Options and simple message boxes
//! (Settings: `settings`). Every form runs an engine command with the collected parameters, so whatever a
//! dialog does an agent can do with one `engine.execute`.
use egui::{Color32, vec2};
@ -359,98 +359,6 @@ pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
}
pub const SETTINGS_PAGES: [(&str, &str); 17] = [
("general", "General"),
("startup", "Startup & Repair"),
("project", "Project"),
("composition", "Composition"),
("previews", "Previews"),
("appearance", "Appearance"),
("grids", "Grids & Guides"),
("labels", "Labels"),
("type", "Type"),
("import", "Import"),
("export", "Export"),
("audio", "Audio"),
("disk", "Disk"),
("memory", "Memory & CPU"),
("video", "Video"),
("3d", "3D"),
("scripting", "Scripting & Expressions"),
];
pub fn show_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut page = app.dialog_state.settings_page.clone();
let mut close = false;
let mut theme = None;
super::dialogs::modal(ctx, "Settings", vec2(640.0, 440.0), t, |ui| {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(170.0);
for (id, label) in SETTINGS_PAGES {
if ui.selectable_label(page == id, label).clicked() {
page = id.to_string();
}
}
});
ui.separator();
ui.vertical(|ui| {
let title = SETTINGS_PAGES.iter().find(|p| p.0 == page).map(|p| p.1).unwrap_or("General");
ui.label(egui::RichText::new(title).font(Tokens::semibold(13.0)));
ui.add_space(8.0);
match page.as_str() {
"general" => {
ui.checkbox(&mut app.ui.show_menu_bar, "Show the in-window menu bar");
ui.checkbox(&mut app.ui.snapping, "Snapping");
}
"appearance" => {
for (k, name) in [("dark", "Dark"), ("darker", "Darker"), ("light", "Light")] {
if ui.button(name).clicked() {
theme = crate::theme::ThemeKind::from_name(k);
}
}
}
"previews" => {
ui.checkbox(&mut app.ui.cache_when_idle, "Cache frames when idle");
ui.checkbox(&mut app.ui.viewer.fast_preview, "Fast previews");
}
"grids" => {
ui.checkbox(&mut app.ui.viewer.grid, "Show grid");
ui.checkbox(&mut app.ui.viewer.guides, "Show guides");
ui.checkbox(&mut app.ui.viewer.safe_margins, "Show title/action safe");
}
"labels" => {
for l in effectcraft_engine::color::Label::ALL.into_iter().skip(1) {
ui.horizontal(|ui| {
let [r, g, b] = l.rgb();
let (rect, _) = ui.allocate_exact_size(vec2(26.0, 14.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(r, g, b));
ui.label(l.name());
});
}
}
_ => {
ui.label(egui::RichText::new("No options on this page yet.").color(t.text_dim));
}
}
});
});
ui.add_space(10.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
close = true;
}
});
});
app.dialog_state.settings_page = page;
if let Some(k) = theme {
app.set_theme(ctx, k);
}
if close {
app.dialog = None;
}
}
pub fn show_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut q = app.dialog_state.palette_query.clone();
let mut close = false;

View File

@ -83,8 +83,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ly += 18.0;
}
}
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps", app.fps), Tokens::ui(11.5), t.text_faint);
// Settings ▸ Composition ▸ Show Rendering Progress in Info Panel.
if app.session.prefs.composition.show_rendering_progress {
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
let rq = app.session.render_job.as_ref().map(|_| " • Rendering queue…").unwrap_or("");
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps{rq}", app.fps), Tokens::ui(11.5), t.text_faint);
}
if !app.session.state.selected_keys.is_empty() {
p.text(
pos2(x0, ly + 18.0),

View File

@ -13,6 +13,7 @@ pub mod misc;
pub mod project;
pub mod properties;
pub mod render_queue;
pub mod settings;
pub mod text_panels;
pub mod timeline;
pub mod viewer;

View File

@ -217,7 +217,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tokens::ui(12.0),
if selected { Color32::WHITE } else { t.text },
);
lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, Tokens::label(it.label));
lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, t.label(it.label));
lp.text(pos2(col_type, r.center().y), Align2::LEFT_CENTER, it.type_name(), Tokens::ui(11.5), t.text_dim);
if let Some(b) = file_size(&it) {
lp.text(pos2(col_size, r.center().y), Align2::LEFT_CENTER, fmt_size(b), Tokens::ui(11.5), t.text_dim);

View File

@ -313,7 +313,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
let comp_item = app.session.project.item(it.comp);
let label_col = comp_item.map(|i| Tokens::label(i.label)).unwrap_or(t.text_faint);
let label_col = comp_item.map(|i| t.label(i.label)).unwrap_or(t.text_faint);
lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col);
lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim);
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());

View File

@ -0,0 +1,346 @@
//! The Settings dialog (After Effects' Preferences): the page list on the left, the page on the
//! right, OK / Cancel / Previous / Next. Pages are built from the engine's schema
//! (`effectcraft_engine::prefs::pages`), so the dialog, `prefs.pages` and the docs agree. Edits
//! apply live; Cancel restores the settings from when the dialog opened, OK saves them.
//!
//! Automation ids: `settings.page.<id>`, `settings.<key>` (e.g. `settings.general.undoLevels`),
//! `settings.labels.<n>.name` / `.color`, `settings.button.<label>`, `settings.ok`,
//! `settings.cancel`, `settings.previous`, `settings.next`.
use effectcraft_engine::prefs::{Item, Kind, Page, pages};
use egui::{Color32, RichText, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Open the dialog on a page, remembering the settings for Cancel.
pub fn open(app: &mut EffectcraftApp, page: &str) {
if app.dialog != Some(Dialog::Settings) {
app.dialog_state.prefs_snapshot = Some(app.session.prefs.clone());
}
app.dialog_state.settings_page = page.to_string();
app.dialog = Some(Dialog::Settings);
}
enum Act {
Set(String, Value),
Run(String, Value),
PickFolder(String),
PickProject(String),
}
fn hex(c: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
fn reg(app: &mut EffectcraftApp, id: &str, r: &egui::Response, label: &str) {
app.auto.add(id, r.rect, label);
}
/// One page's rows.
fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value, t: &Tokens, acts: &mut Vec<Act>) {
let get = |key: &str| cur.pointer(&format!("/{}", key.replace('.', "/"))).cloned().unwrap_or(Value::Null);
let label_w = 250.0;
for item in &page.items {
match *item {
Item::Section(title) => {
ui.add_space(6.0);
ui.label(RichText::new(title).font(Tokens::semibold(12.5)).color(t.tab_text_active));
ui.separator();
}
Item::Note(text) => {
ui.label(RichText::new(text).color(t.text_dim));
}
Item::Button { label, command, params } => {
let r = ui.button(label);
reg(app, &format!("settings.button.{}", label.trim_end_matches('.')), &r, label);
if r.clicked() {
acts.push(Act::Run(command.into(), serde_json::from_str(params).unwrap_or(json!({}))));
}
}
Item::Labels => labels_ui(app, ui, cur, acts),
Item::AudioDevices { key } => {
let devices: Vec<String> = app.hooks.audio_devices.as_ref().map(|f| f()).unwrap_or_default();
let sel = get(key).as_str().unwrap_or("").to_string();
ui.horizontal(|ui| {
ui.add_sized(vec2(label_w, 18.0), egui::Label::new("Default Output:"));
let shown = if sel.is_empty() { "System Default".to_string() } else { sel.clone() };
let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| {
if ui.selectable_label(sel.is_empty(), "System Default").clicked() {
acts.push(Act::Set(key.into(), json!("")));
}
for d in &devices {
if ui.selectable_label(*d == sel, d).clicked() {
acts.push(Act::Set(key.into(), json!(d)));
}
}
});
reg(app, &format!("settings.{key}"), &r.response, "Default Output");
});
if devices.is_empty() {
ui.label(RichText::new("No device list from this host; the system default output is used.").color(t.text_dim));
}
}
Item::Setting { key, label, kind, live } => {
let v = get(key);
let id = format!("settings.{key}");
let tip = (!live).then_some("Stored, but not used by EffectCraft yet");
let mut resp: Option<egui::Response> = None;
match kind {
Kind::Bool => {
let mut b = v.as_bool().unwrap_or(false);
let r = ui.checkbox(&mut b, label);
if r.changed() {
acts.push(Act::Set(key.into(), json!(b)));
}
resp = Some(r);
}
_ => {
ui.horizontal(|ui| {
ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{label}:")).truncate());
let r = match kind {
Kind::Int(lo, hi, unit) => {
let mut x = v.as_i64().unwrap_or(0);
let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25));
if !unit.is_empty() {
ui.label(RichText::new(unit).color(t.text_dim));
}
if r.changed() {
acts.push(Act::Set(key.into(), json!(x)));
}
r
}
Kind::Float(lo, hi, unit) => {
let mut x = v.as_f64().unwrap_or(0.0);
let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3));
if !unit.is_empty() {
ui.label(RichText::new(unit).color(t.text_dim));
}
if r.changed() {
acts.push(Act::Set(key.into(), json!(x)));
}
r
}
Kind::Slider(lo, hi) => {
let mut x = v.as_f64().unwrap_or(0.0);
ui.label(RichText::new("Darker").color(t.text_dim));
let r = ui.add(egui::Slider::new(&mut x, lo..=hi).show_value(false));
ui.label(RichText::new("Lighter").color(t.text_dim));
if ui.small_button("Default").clicked() {
acts.push(Act::Set(key.into(), json!(0.0)));
}
if r.changed() {
acts.push(Act::Set(key.into(), json!(x)));
}
r
}
Kind::Choice(opts) => {
let curv = v.as_str().unwrap_or("").to_string();
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
egui::ComboBox::from_id_salt(("settings", key))
.selected_text(shown)
.width(220.0)
.show_ui(ui, |ui| {
for (l, val) in opts {
if ui.selectable_label(*val == curv, *l).clicked() {
acts.push(Act::Set(key.into(), json!(val)));
}
}
})
.response
}
Kind::Text | Kind::Path => {
let mut s = v.as_str().unwrap_or("").to_string();
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if kind == Kind::Path { 230.0 } else { 260.0 }));
if r.changed() {
acts.push(Act::Set(key.into(), json!(s)));
}
if kind == Kind::Path {
let b = ui.button("Choose...");
reg(app, &format!("{id}.choose"), &b, "Choose...");
if b.clicked() {
acts.push(if key.ends_with("Path") { Act::PickProject(key.into()) } else { Act::PickFolder(key.into()) });
}
}
r
}
Kind::Color => {
let c = crate::panels::viewer::hex_rgb(v.as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
let mut rgb = c;
let r = ui.color_edit_button_srgb(&mut rgb);
ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim));
if rgb != c {
acts.push(Act::Set(key.into(), json!(hex(rgb))));
}
r
}
Kind::Bool => unreachable!(),
};
resp = Some(r);
});
}
}
if let Some(r) = resp {
let r = match tip {
Some(tip) => r.on_hover_text(tip),
None => r,
};
reg(app, &id, &r, label);
}
}
}
}
}
fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mut Vec<Act>) {
ui.label("Label Colors and Names:");
ui.add_space(4.0);
let labels = cur.get("labels").and_then(Value::as_array).cloned().unwrap_or_default();
egui::Grid::new("settings-labels").num_columns(4).spacing([10.0, 4.0]).show(ui, |ui| {
for (i, l) in labels.iter().enumerate() {
let c = crate::panels::viewer::hex_rgb(l["color"].as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
let mut rgb = c;
let r = ui.color_edit_button_srgb(&mut rgb);
app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour");
if rgb != c {
acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb))));
}
let mut name = l["name"].as_str().unwrap_or("").to_string();
let r = ui.add_sized(vec2(160.0, 20.0), egui::TextEdit::singleline(&mut name));
app.auto.add(&format!("settings.labels.{i}.name"), r.rect, "label name");
if r.changed() {
acts.push(Act::Set(format!("labels.{i}.name"), json!(name)));
}
if i % 2 == 1 {
ui.end_row();
}
}
});
ui.add_space(8.0);
let r = ui.button("Reset Labels");
app.auto.add("settings.button.Reset Labels", r.rect, "Reset Labels");
if r.clicked() {
acts.push(Act::Run("prefs.reset".into(), json!({"page": "labels"})));
}
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let all = pages();
let mut page = app.dialog_state.settings_page.clone();
if !all.iter().any(|p| p.id == page) {
page = "general".into();
}
let idx = all.iter().position(|p| p.id == page).unwrap_or(0);
let cur = serde_json::to_value(&app.session.prefs).unwrap_or_default();
let mut acts: Vec<Act> = vec![];
let (mut ok, mut cancel) = (false, false);
super::dialogs::modal(ctx, "Settings", vec2(820.0, 600.0), t, |ui| {
let body_h = 470.0;
ui.allocate_ui_with_layout(vec2(ui.available_width(), body_h), egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.set_height(body_h);
// Page list (left, like After Effects).
ui.vertical(|ui| {
ui.set_width(180.0);
for p in &all {
let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click());
let sel = page == p.id;
if sel {
ui.painter().rect_filled(r, 3.0, t.accent);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
ui.painter().text(
r.left_center() + vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
p.title,
Tokens::ui(12.5),
if sel { Color32::WHITE } else { t.text },
);
app.auto.add(&format!("settings.page.{}", p.id), r, p.title);
if resp.clicked() {
page = p.id.to_string();
}
}
});
ui.separator();
ui.vertical(|ui| {
ui.set_width(590.0);
let p = &all[idx];
ui.label(RichText::new(p.title).font(Tokens::semibold(14.0)).color(t.tab_text_active));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height(body_h - 30.0).auto_shrink([false, false]).show(ui, |ui| {
page_ui(app, ui, p, &cur, t, &mut acts);
});
});
});
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
let r = ui.add_enabled(idx > 0, egui::Button::new("Previous"));
app.auto.add("settings.previous", r.rect, "Previous");
if r.clicked() {
page = all[idx - 1].id.to_string();
}
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new("Next"));
app.auto.add("settings.next", r.rect, "Next");
if r.clicked() {
page = all[idx + 1].id.to_string();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
app.auto.add("settings.ok", r.rect, "OK");
ok |= r.clicked();
let r = ui.button("Cancel");
app.auto.add("settings.cancel", r.rect, "Cancel");
cancel |= r.clicked();
});
});
});
app.dialog_state.settings_page = page;
for a in acts {
match a {
Act::Set(k, v) => {
if let Err(e) = app.session.prefs.set(&k, v) {
app.ui.status = e;
}
app.session.prefs_changed();
}
Act::Run(cmd, params) => {
if let Err(e) = crate::menus::invoke(app, ctx, &cmd, params) {
app.ui.status = e;
}
// Buttons such as Reset Settings change the baseline Cancel returns to.
if cmd.starts_with("prefs.") {
app.dialog_state.prefs_snapshot = Some(app.session.prefs.clone());
}
if app.dialog != Some(Dialog::Settings) && app.dialog != Some(Dialog::Info) {
app.dialog = Some(Dialog::Settings);
}
}
Act::PickFolder(k) => {
if let Some(f) = app.hooks.pick_folder.as_ref().and_then(|f| f()) {
let _ = app.session.prefs.set(&k, json!(f));
app.session.prefs_changed();
}
}
Act::PickProject(k) => {
if let Some(f) = app.hooks.pick_open_project.as_ref().and_then(|f| f()) {
let _ = app.session.prefs.set(&k, json!(f));
app.session.prefs_changed();
}
}
}
}
if cancel {
if let Some(p) = app.dialog_state.prefs_snapshot.take() {
app.session.prefs = p;
app.session.prefs_changed();
}
app.dialog = None;
} else if ok {
app.dialog_state.prefs_snapshot = None;
app.session.save_prefs();
app.dialog = None;
}
}

View File

@ -635,7 +635,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let x = tm.x(m.time.seconds());
pr.add(egui::Shape::convex_polygon(
vec![pos2(x - 4.0, ruler.max.y - 10.0), pos2(x + 4.0, ruler.max.y - 10.0), pos2(x, ruler.max.y - 4.0)],
Tokens::label(m.label),
t.label(m.label),
Stroke::NONE,
));
}
@ -763,11 +763,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Label swatch.
let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
lp.rect_filled(lr, 2.0, Tokens::label(layer.label));
lp.rect_filled(lr, 2.0, t.label(layer.label));
let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click());
lresp.context_menu(|ui| {
for lab in effectcraft_engine::color::Label::ALL {
if ui.button(lab.name()).clicked() {
let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) };
if ui.button(name).clicked() {
actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()})));
ui.close();
}
@ -936,7 +937,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let x_in = tm.x(layer.in_point.seconds());
let x_out = tm.x(layer.out_point.seconds());
let bar = Rect::from_min_max(pos2(x_in, r.min.y + 3.0), pos2(x_out, r.max.y - 3.0));
let lc = Tokens::label(layer.label);
let lc = t.label(layer.label);
let fill = if is_sel { lc.gamma_multiply(0.85) } else { lc.gamma_multiply(0.55) };
gp.rect_filled(bar, 2.0, fill);
if is_sel {

View File

@ -319,7 +319,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Guides.
if app.ui.viewer.safe_margins {
for (k, a) in [(0.9, 120u8), (0.8, 160)] {
let g = &app.session.prefs.grids;
let (act, title) = (1.0 - g.action_safe as f32 / 100.0, 1.0 - g.title_safe as f32 / 100.0);
for (k, a) in [(act, 120u8), (title, 160)] {
let r = Rect::from_center_size(comp_rect.center(), comp_rect.size() * k);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_white_alpha(a)), StrokeKind::Middle);
}
@ -328,22 +330,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.line_segment([c - vec2(0.0, 10.0), c + vec2(0.0, 10.0)], Stroke::new(1.0, Color32::from_white_alpha(140)));
}
if app.ui.viewer.grid {
let step = 100.0 * zoom;
// Settings ▸ Grids & Guides: gridline spacing, subdivisions and colour.
let g = &app.session.prefs.grids;
let [r, gg, b] = hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]);
let major = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 90));
let minor = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 35));
let step = (g.grid_spacing as f32 * zoom).max(2.0);
let sub = g.grid_subdivisions.max(1) as f32;
let sstep = step / sub;
let draw_minor = sstep >= 4.0;
let mut i = 0u32;
let mut gx = comp_rect.min.x;
while gx <= comp_rect.max.x {
painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 160, 255, 50)));
gx += step;
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
if is_major || draw_minor {
painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor });
}
gx += sstep;
i += 1;
}
let mut i = 0u32;
let mut gy = comp_rect.min.y;
while gy <= comp_rect.max.y {
painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 160, 255, 50)));
gy += step;
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
if is_major || draw_minor {
painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor });
}
gy += sstep;
i += 1;
}
}
// Comp guides (View ▸ Show Guides) and the region of interest.
if app.ui.viewer.guides {
let stroke = Stroke::new(1.0, Color32::from_rgb(0x3c, 0xc8, 0xf0));
let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b));
for g in &comp.guides {
if g.vertical {
let x = comp_rect.min.x + g.position as f32 * zoom;
@ -372,8 +393,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id()));
}
if app.ui.viewer.show_layer_controls {
// Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for
// Layer Handles and Paths.
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
let label_handles = app.session.prefs.appearance.use_label_color_for_handles;
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
let col = Tokens::label(l.label);
let col = if label_handles { t.label(l.label) } else { t.accent };
// Motion path of animated position.
if let Some(pos) = l.transform().and_then(|tr| tr.get("position"))
&& pos.keys.len() > 1
@ -438,10 +464,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.circle_filled(h, 3.0, mc);
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m });
}
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, mc);
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
} else {
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Color32::from_black_alpha(160));
painter.rect_stroke(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
}
app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name);
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m });
@ -467,7 +493,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.add(egui::Shape::closed_line(sq.clone(), Stroke::new(1.0, col)));
for i in 0..8 {
let hp = if i < 4 { sq[i] } else { sq[i - 4] + (sq[(i - 3) % 4] - sq[i - 4]) * 0.5 };
let hr = Rect::from_center_size(hp, vec2(HANDLE, HANDLE));
let hr = Rect::from_center_size(hp, vec2(hs, hs));
painter.rect_filled(hr, 0.0, col);
painter.rect_stroke(hr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), StrokeKind::Outside);
handle_hits.push((l.id, i, hp));
@ -930,7 +956,7 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap,
};
for l in ectx.comp.layers.iter().filter(|l| (l.is_camera() || l.is_light()) && l.is_active_at(ectx.time)) {
let sel = selected.contains(&l.id);
let col = Tokens::label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 });
let col = app.tokens.label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 });
let stroke = Stroke::new(if sel { 1.5 } else { 1.0 }, col);
let (eye, fwd, down) = three_d::camera::layer_frame(ectx, l);
let poi = |l: &Layer| -> Option<Vec3> {
@ -1283,3 +1309,13 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
}
/// `#rrggbb` → sRGB bytes.
pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
return None;
}
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
Some([p(0)?, p(2)?, p(4)?])
}

View File

@ -66,6 +66,8 @@ pub struct Tokens {
pub timecode: Color32,
pub danger: Color32,
pub warning: Color32,
/// Label colours (`Label::ALL` order), from Settings ▸ Labels.
pub labels: [Color32; 17],
pub radius: f32,
pub radius_sm: f32,
pub gap: f32,
@ -112,6 +114,7 @@ impl Tokens {
timecode: Color32::from_rgb(0x3d, 0x8f, 0xf5),
danger: Color32::from_rgb(0xe0, 0x4a, 0x3c),
warning: Color32::from_rgb(0xe8, 0x9a, 0x2c),
labels: default_labels(),
radius: 6.0,
radius_sm: 3.0,
gap: 4.0,
@ -172,11 +175,65 @@ impl Tokens {
pub fn medium(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("medium".into()))
}
/// sRGB colour of an item/layer label.
pub fn label(l: effectcraft_color::Label) -> Color32 {
/// sRGB colour of an item/layer label (Settings ▸ Labels).
pub fn label(&self, l: effectcraft_color::Label) -> Color32 {
let i = effectcraft_color::Label::ALL.iter().position(|x| *x == l).unwrap_or(0);
self.labels[i]
}
/// Theme tokens for the current settings: theme, UI brightness and label colours.
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> Tokens {
let kind = ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
let mut t = Tokens::for_kind(kind).with_brightness(p.appearance.brightness as f32);
for (i, l) in effectcraft_color::Label::ALL.iter().enumerate() {
let [r, g, b] = p.label_rgb(*l);
t.labels[i] = Color32::from_rgb(r, g, b);
}
t
}
/// Lighten (b > 0) or darken (b < 0) the interface greys, like After Effects' brightness
/// slider. Accent colours, labels and keyframe colours are kept.
pub fn with_brightness(mut self, b: f32) -> Tokens {
let b = b.clamp(-1.0, 1.0);
if b.abs() < 1e-3 {
return self;
}
let adj = |c: &mut Color32| {
let f = |v: u8| -> u8 {
let v = v as f32;
(if b > 0.0 { v + (110.0 - v).max(0.0) * b * 0.8 + 10.0 * b } else { v * (1.0 + b * 0.6) }).round().clamp(0.0, 255.0) as u8
};
*c = Color32::from_rgb(f(c.r()), f(c.g()), f(c.b()));
};
for c in [
&mut self.app_bg,
&mut self.header_bg,
&mut self.panel_bg,
&mut self.hover,
&mut self.pressed,
&mut self.field_bg,
&mut self.field_border,
&mut self.separator,
&mut self.row,
&mut self.row_alt,
&mut self.row_selected,
&mut self.tl_bg,
&mut self.tl_ruler_bg,
&mut self.pasteboard,
&mut self.work_area,
] {
adj(c);
}
self
}
}
fn default_labels() -> [Color32; 17] {
effectcraft_color::Label::ALL.map(|l| {
let [r, g, b] = l.rgb();
Color32::from_rgb(r, g, b)
}
})
}
static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");

137
docs/preferences.md Normal file
View File

@ -0,0 +1,137 @@
# Settings
## Settings (Preferences)
EffectCraft ▸ Settings (macOS) or Edit ▸ Preferences (Windows/Linux), `Cmd+Alt+;`. The dialog has
After Effects 2026's pages (General, Startup & Repair, Project, Composition, Previews, Appearance,
Grids & Guides, Labels, Type, Import, Export, Audio, Disk, Memory & CPU, Video, 3D, Scripting &
Expressions) with OK, Cancel, Previous and Next. Older page names still open the right page
(`prefs.open {"page": "Auto-Save"}` opens Project, `Media & Disk Cache` opens Disk, `Memory &
Performance` opens Memory & CPU).
The model is `effectcraft_engine::prefs::Prefs`: serde, versioned (`version`), addressed by dotted
keys (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`). It is stored as
`prefs.json` through the session's `ConfigStore` (the desktop app uses the platform config
directory: `~/Library/Application Support/EffectCraft` on macOS, `%APPDATA%\EffectCraft` on
Windows, `$XDG_CONFIG_HOME/effectcraft` on Linux; the web app can back the same trait with
`localStorage`). Loading migrates older layouts, keeps unknown keys (from newer versions) and
falls back to defaults for values that don't parse.
Commands (CLI, MCP, control channel):
| Command | Parameters |
|---|---|
| `prefs.get` | `{key?}` (omit for everything) |
| `prefs.set` | `{key, value}` or `{values: {key: value}}` |
| `prefs.reset` | `{page?}` (omit for everything) |
| `prefs.open` | `{page?}` opens the Settings dialog |
| `prefs.pages` | the page schema: every row, its key, type, range and whether it is wired |
### Settings that change behaviour
- `general.undoLevels`: Levels of Undo
- `general.pathPointSize`: Path Point and Handle Size
- `general.recentItems`: Recent Projects Shown
- `general.showToolTips`: Show Tool Tips
- `general.createLayersAtCompStart`: Create Layers at Composition Start Time
- `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear
- `startup.showHomeOnLaunch`: Show Home Screen When Launching
- `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash
- `project.useTemplate`: New Project Loads Template
- `project.templatePath`: Template Project
- `autoSave.enabled`: Automatically Save Projects
- `autoSave.intervalMinutes`: Save Every
- `autoSave.maxVersions`: Maximum Project Versions
- `autoSave.location`: Auto-Save Location
- `autoSave.folder`: Custom Location
- `autoSave.saveOnRenderStart`: Save When Starting Render Queue
- `composition.showRenderingProgress`: Show Rendering Progress in Info Panel and Flowchart
- `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit
- `previews.cacheFramesWhenIdle`: Cache Frames When Idle
- `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects)
- `appearance.theme`: Theme
- `appearance.brightness`: Brightness
- `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths
- `grids.gridColor`: Color
- `grids.gridSpacing`: Gridline Every
- `grids.gridSubdivisions`: Subdivisions
- `grids.guideColor`: Color
- `grids.actionSafe`: Action-safe
- `grids.titleSafe`: Title-safe
- `import.stillFootage`: Still Footage
- `import.stillSeconds`: Still Duration
- `import.sequenceFps`: Sequence Footage
- `audio.outputDevice`: Default Output (device list from the desktop host)
- `audio.outputLeft`: Left (Audio Output Mapping)
- `audio.outputRight`: Right (Audio Output Mapping)
- `memory.layerCacheMb`: Layer Cache
- `memory.mediaCacheMb`: Footage Frame Cache
- `memory.previewCacheMb`: Preview (RAM) Cache
- `threeD.defaultRenderer`: Default 3D Renderer
- `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu,
the timeline, project panel, render queue and viewer handles
### Not wired yet (TODO)
These rows are in the dialog for After Effects parity and are stored, but nothing reads them yet.
`prefs.pages` reports them with `"live": false`. A test keeps this list in step with the schema.
- `general.switchesAffectNestedComps`: Switches Affect Nested Comps
- `general.preserveConstantVertexCount`: Preserve Constant Vertex and Feather Point Count when Editing Masks
- `general.syncTimeRelatedItems`: Synchronize Time of All Related Items
- `general.expressionPickWhipCompact`: Expression Pick Whip Writes Compact English
- `general.createSplitLayersAbove`: Create Split Layers Above Original Layer
- `general.useSystemColorPicker`: Use System Color Picker
- `startup.showHomeOnOpenProject`: Show Home Screen When Opening a Project
- `composition.motionPath`: Motion Path
- `composition.motionPathSeconds`: Seconds
- `composition.motionPathKeyframes`: Keyframes
- `composition.hardwareAcceleratePanels`: Hardware Accelerate Composition, Layer and Footage Panels
- `previews.showInternalWireframes`: Show Internal Wireframes
- `previews.zoomQuality`: Viewer Zoom Quality
- `appearance.useLabelColorForTabs`: Use Label Color for Related Tabs
- `appearance.cycleMaskColors`: Cycle Mask Colors
- `appearance.useGradients`: Use Gradients
- `grids.gridStyle`: Style
- `grids.proportionalHorizontal`: Horizontal
- `grids.proportionalVertical`: Vertical
- `grids.guideStyle`: Style
- `type.textEngine`: Text Engine
- `type.fontPreview`: Show Font Preview
- `type.recentFonts`: Number of Recent Fonts to Display
- `type.fontNamesInEnglish`: Show Font Names in English
- `import.reportMissingFrames`: Report Missing Frames
- `import.unlabeledAlpha`: Interpret Unlabeled Alpha As
- `import.dragImportAs`: Default Drag Import As
- `export.defaultOutputFolder`: Default Output Folder
- `export.segmentSequences`: Segment Sequences
- `export.segmentSequenceFiles`: Files per Segment
- `export.segmentMovies`: Segment Movie Files
- `export.segmentMovieMb`: Segment Size
- `export.appendBitsToName`: Append Bit Depth to File Name
- `audio.previewSampleRate`: Preview Sample Rate
- `disk.diskCacheEnabled`: Enable Disk Cache
- `disk.diskCacheMaxGb`: Maximum Disk Cache Size
- `disk.diskCacheFolder`: Disk Cache Folder
- `disk.mediaCacheFolder`: Database and Cache Folder
- `disk.conformedMediaFolder`: Conformed Audio Folder
- `memory.ramReservedGb`: RAM Reserved for Other Applications
- `memory.reduceCacheWhenLow`: Reduce Cache Size When System Is Low on Memory
- `video.enableOutput`: Enable Video Preview Output
- `video.device`: Video Device
- `video.outputDuringPlayback`: Video Output During Playback
- `video.mirrorOnMonitor`: Mirror on Computer Monitor
- `video.disableWhenBackground`: Disable Video Output When in Background
- `threeD.showReferenceAxes`: Show 3D Reference Axes
- `threeD.extendedViewer`: Extended Viewer
- `threeD.realtimeShadows`: Realtime Shadows in Draft
- `scripting.allowScriptsWriteFiles`: Allow Scripts to Write Files and Access Network
- `scripting.warnExecutingFiles`: Warn User When Executing Files
- `scripting.enableJsDebugger`: Enable JavaScript Debugger
- `scripting.editorFontSize`: Font Size
- `scripting.syntaxHighlighting`: Syntax Highlighting
- `scripting.lineNumbers`: Line Numbers
- `scripting.autoComplete`: Auto-complete
- `scripting.bracketMatching`: Bracket Matching
- `scripting.wordWrap`: Word Wrap
- `scripting.errorBanner`: Show Expression Error Banner