From 47f771236479e037afdb33da9956abb73d14540b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 09:14:45 -0400 Subject: [PATCH] M14.2: preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/effectcraft/src/audio_out.rs | 63 +- apps/effectcraft/src/main.rs | 32 +- crates/engine/src/commands/comp.rs | 4 + crates/engine/src/commands/edit.rs | 2 +- crates/engine/src/commands/file.rs | 20 +- crates/engine/src/commands/frontend.rs | 1 + crates/engine/src/commands/layer.rs | 11 +- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/prop.rs | 14 +- crates/engine/src/commands/settings.rs | 76 ++ crates/engine/src/config.rs | 104 +++ crates/engine/src/lib.rs | 20 +- crates/engine/src/menus.rs | 11 + crates/engine/src/prefs.rs | 951 ++++++++++++++++++++++ crates/engine/src/session_settings.rs | 39 + crates/engine/src/tests_settings.rs | 235 ++++++ crates/media/src/pool.rs | 6 + crates/render/src/cache.rs | 23 + crates/render/src/lib.rs | 7 + crates/ui-egui/src/audio.rs | 17 +- crates/ui-egui/src/frames.rs | 18 + crates/ui-egui/src/lib.rs | 52 +- crates/ui-egui/src/menus.rs | 47 +- crates/ui-egui/src/panels/dialogs.rs | 4 +- crates/ui-egui/src/panels/forms.rs | 96 +-- crates/ui-egui/src/panels/info.rs | 8 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/project.rs | 2 +- crates/ui-egui/src/panels/render_queue.rs | 2 +- crates/ui-egui/src/panels/settings.rs | 346 ++++++++ crates/ui-egui/src/panels/timeline.rs | 9 +- crates/ui-egui/src/panels/viewer.rs | 62 +- crates/ui-egui/src/theme.rs | 63 +- docs/preferences.md | 137 ++++ 34 files changed, 2327 insertions(+), 158 deletions(-) create mode 100644 crates/engine/src/commands/settings.rs create mode 100644 crates/engine/src/config.rs create mode 100644 crates/engine/src/prefs.rs create mode 100644 crates/engine/src/session_settings.rs create mode 100644 crates/engine/src/tests_settings.rs create mode 100644 crates/ui-egui/src/panels/settings.rs create mode 100644 docs/preferences.md diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index bef9ff2e..eea7ffaa 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -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, stop: Option>, thread: Option>, } -/// Open the default output device (`None` without one). -pub fn open() -> Option> { +/// Names of the output devices (Settings ▸ Audio). +pub fn devices() -> Vec { 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 { + 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> { + 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>( @@ -34,8 +55,11 @@ fn run>( cfg: &cpal::StreamConfig, feed: Arc, latency: Arc, + map: (usize, usize), ) -> Result { 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 = Vec::new(); dev.build_output_stream( @@ -46,11 +70,15 @@ fn run>( 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::>(); 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::(&dev, &cfg, feed, latency), - cpal::SampleFormat::I16 => run::(&dev, &cfg, feed, latency), - cpal::SampleFormat::U16 => run::(&dev, &cfg, feed, latency), - cpal::SampleFormat::I32 => run::(&dev, &cfg, feed, latency), + cpal::SampleFormat::F32 => run::(&dev, &cfg, feed, latency, map), + cpal::SampleFormat::I16 => run::(&dev, &cfg, feed, latency, map), + cpal::SampleFormat::U16 => run::(&dev, &cfg, feed, latency, map), + cpal::SampleFormat::I32 => run::(&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())) { diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 2c6ca716..56f18096 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -15,14 +15,14 @@ fn main() -> eframe::Result { let mut control_port: Option = 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 = 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 { + 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 { diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 996df5df..5ebd491e 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -93,6 +93,10 @@ fn new_comp(s: &mut Session, p: &Value) -> Result { 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(); diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index df7a9e42..e52f34b4 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -366,7 +366,7 @@ fn split(s: &mut Session, p: &Value) -> Result { fn label(s: &mut Session, p: &Value) -> Result { 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, _| { diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 343ca611..84b48279 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -12,6 +12,14 @@ fn has_path(s: &Session) -> std::result::Result<(), String> { } fn new_project(s: &mut Session, _: &Value) -> Result { + // 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 { 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}")), } } diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index b344c2a0..2a02356f 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -24,6 +24,7 @@ pub fn specs() -> Vec { "{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), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 94605428..09a50660 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -252,12 +252,19 @@ fn add_item(s: &mut Session, p: &Value) -> Result { 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; diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 6301e440..4e82ab42 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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 }) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 3164274c..05dc37fa 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -30,11 +30,23 @@ fn with_prop( f: impl FnOnce(&mut Property, Tick) -> Result, ) -> Result { 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 = 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) }) } diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs new file mode 100644 index 00000000..b719e4e3 --- /dev/null +++ b/crates/engine/src/commands/settings.rs @@ -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 { + 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 { + 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 = 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 { + 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 { + 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 { + Ok(crate::prefs::pages_json()) +} + +pub fn specs() -> Vec { + 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), + ] +} diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs new file mode 100644 index 00000000..dd9c5805 --- /dev/null +++ b/crates/engine/src/config.rs @@ -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; + 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 { + None + } +} + +/// In-memory store (tests, headless sessions). +#[derive(Default)] +pub struct MemoryConfig { + pub files: Mutex>, +} + +impl ConfigStore for MemoryConfig { + fn read(&self, name: &str) -> Option { + 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) -> DirConfig { + DirConfig { dir: dir.into() } + } +} + +impl ConfigStore for DirConfig { + fn read(&self, name: &str) -> Option { + 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 { + 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) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5195fe72..2a244bfc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + /// 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>, } 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; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 8965da25..450df85d 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -93,6 +93,17 @@ pub fn entries() -> Vec<(Vec, &'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 { diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs new file mode 100644 index 00000000..ec06b3f0 --- /dev/null +++ b/crates/engine/src/prefs.rs @@ -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; + +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 { + 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, + #[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, + /// 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| { + 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::(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 { + 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