//! 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 effectcraft_time::Tick; 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"; /// Settings ▸ General ▸ Language: (label, `general.language` value). pub const LANGUAGES: &[(&str, &str)] = &[ ("Match System", "system"), ("English", "en"), ("日本語", "ja"), ("简体中文", "zh-hans"), ("繁體中文", "zh-hant"), ("Українська", "uk"), ("Español", "es"), ("Português (Brasil)", "pt-br"), ]; /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")]; /// Settings ▸ Appearance ▸ Appearance Mode: (label, `appearance.appearanceMode` value). Auto /// follows the operating system's light or dark appearance. pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")]; /// The dark family of themes: (label, `appearance.darkTheme` value). pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")]; /// The light family of themes: (label, `appearance.lightTheme` value). pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")]; /// Every theme (the legacy single-theme `appearance.theme`). pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]; /// The settings whose value must be one of their choices. fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> { match key { "general.language" => Some(LANGUAGES), "startup.windowGraphics" => Some(WINDOW_GRAPHICS), "appearance.appearanceMode" => Some(APPEARANCE_MODES), "appearance.darkTheme" => Some(DARK_THEMES), "appearance.lightTheme" => Some(LIGHT_THEMES), "appearance.theme" => Some(THEMES), _ => None, } } fn is_choice(choices: &[(&str, &str)], v: &str) -> bool { choices.iter().any(|(_, c)| *c == v) } /// 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 { /// Interface language: `system` (the operating system's, where EffectCraft has it, else /// English), or a language code from `LANGUAGES`. language: String = "system".into(), /// 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, /// What the desktop window draws with, from the next launch: `auto` (the platform's best /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch that /// stops as its window opens switches it to `gl` (#243). window_graphics: String = "auto".into(), }); 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, /// The monitor's colour space for View ▸ Use Display Color Management: `srgb` or `p3`. display_profile: String = "srgb".into(), }); page!(Appearance { /// The single theme before appearance modes (`dark`, `darker` or `light`), kept for older /// settings files and automation: setting it picks its family's mode and theme. While the mode /// is Dark or Light it mirrors the theme in use. theme: String = "dark".into(), /// `auto` (follow the operating system), `dark` or `light`. New installs stay Dark. appearance_mode: String = "dark".into(), /// The theme used in dark appearance: `dark` or `darker`. dark_theme: String = "dark".into(), /// The theme used in light appearance: `light`. light_theme: String = "light".into(), /// User interface brightness, -1 (darker) … 1 (lighter). brightness: f64 = 0.0, /// The size of the whole interface in percent (75–200), on top of the display's own scale. ui_scale: u32 = 100, use_label_color_for_handles: bool = true, use_label_color_for_tabs: bool = true, cycle_mask_colors: bool = true, use_gradients: bool = true, /// macOS: draw the menu bar inside the window instead of the system menu bar. in_window_menu_bar_mac: bool = false, }); 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, /// Automatically Reload Footage whose file another app changed (size or modification time), /// checked every two seconds and when the window comes back to the front: `off`, /// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`. auto_reload_footage: String = "nonSequence".into(), /// 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!( /// Settings ▸ Roto Brush. RotoPrefs { /// The segmentation model Roto Brush 2.0 / 3.0 use: `classical` (built in) or a model id /// from `effectcraft_segment::MODELS` (once installed). model: String = "classical".into(), } ); page!( /// Settings ▸ Face Tracking. FacePrefs { /// The model Track Mask ▸ Face Tracking uses: `classical` (built in) or a model id from /// `effectcraft_segment::MODELS` (once installed). model: String = "classical".into(), } ); 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, }); impl Scripting { /// Whether `other` grants scripts the same as this: Application Scripting (file and network /// access, the warning before executing files, the debugger). Scripts can't change these /// ([`crate::Session::execute_for_script`]): a script could otherwise give itself the /// access the user withheld. pub fn same_permissions(&self, other: &Scripting) -> bool { self.allow_scripts_write_files == other.allow_scripts_write_files && self.warn_executing_files == other.warn_executing_files && self.enable_js_debugger == other.enable_js_debugger } /// Take `from`'s Application Scripting settings (see [`Scripting::same_permissions`]). pub fn keep_permissions(&mut self, from: &Scripting) { self.allow_scripts_write_files = from.allow_scripts_write_files; self.warn_executing_files = from.warn_executing_files; self.enable_js_debugger = from.enable_js_debugger; } } /// 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.get(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, pub roto: RotoPrefs, pub face: FacePrefs, /// File ▸ Open Recent, newest first. pub recent_projects: Vec, /// File ▸ Import Recent Footage, newest first. pub recent_footage: Vec, /// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest /// first. pub recent_presets: Vec, /// Character panel: recently used font families, newest first. pub recent_fonts: Vec, /// Preview panel: every shortcut's options. pub preview: crate::preview::PreviewSettings, /// View ▸ Simulate Output ▸ My Custom RGB. pub custom_rgb: crate::viewer::CustomRgb, /// 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(), roto: RotoPrefs::default(), face: FacePrefs::default(), recent_projects: vec![], recent_footage: vec![], recent_presets: vec![], recent_fonts: vec![], preview: crate::preview::PreviewSettings::default(), custom_rgb: crate::viewer::CustomRgb::default(), 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()); } } // Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or // Light mode, instead of switching established users to Auto. if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut) && !a.contains_key("appearanceMode") && let Some(theme) = a.get("theme").and_then(Value::as_str).map(str::to_string) && let Some((mode, slot)) = theme_family(&theme) { a.insert("appearanceMode".into(), json!(mode)); a.insert(slot.into(), json!(theme)); } obj.insert("version".into(), json!(PREFS_VERSION)); v } /// The appearance mode and the per-mode setting a theme belongs to (`darker` → (`dark`, /// `darkTheme`)); `None` for an unknown theme. pub fn theme_family(theme: &str) -> Option<(&'static str, &'static str)> { if is_choice(DARK_THEMES, theme) { Some(("dark", "darkTheme")) } else if is_choice(LIGHT_THEMES, theme) { Some(("light", "lightTheme")) } else { None } } impl Appearance { /// Show the legacy single `theme`: fix the mode to its family and remember it as that /// family's theme. An unknown theme changes nothing. pub fn select_theme(&mut self) { match theme_family(&self.theme) { Some((mode, "darkTheme")) => { self.appearance_mode = mode.into(); self.dark_theme = self.theme.clone(); } Some((mode, _)) => { self.appearance_mode = mode.into(); self.light_theme = self.theme.clone(); } None => {} } } /// The theme to show: the light or dark family's choice by the mode; Auto follows `system_light` /// (`None` = the system said nothing: dark). pub fn resolved_theme(&self, system_light: Option) -> &str { let light = match self.appearance_mode.as_str() { "light" => true, "auto" => system_light == Some(true), _ => false, }; if light { &self.light_theme } else { &self.dark_theme } } } 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; if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) { self.startup.window_graphics = Startup::default().window_graphics; } let g = &mut self.general; if !is_choice(LANGUAGES, &g.language) { g.language = General::default().language; } 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); let ap = &mut self.appearance; if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) { ap.appearance_mode = Appearance::default().appearance_mode; } if !is_choice(DARK_THEMES, &ap.dark_theme) { ap.dark_theme = Appearance::default().dark_theme; } if !is_choice(LIGHT_THEMES, &ap.light_theme) { ap.light_theme = Appearance::default().light_theme; } // The legacy single theme mirrors the theme a fixed mode shows (Auto depends on the // system, so it keeps the last one). match ap.appearance_mode.as_str() { "dark" => ap.theme = ap.dark_theme.clone(), "light" => ap.theme = ap.light_theme.clone(), _ if !is_choice(THEMES, &ap.theme) => ap.theme = ap.dark_theme.clone(), _ => {} } self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0); self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200); 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> { if let Some(c) = choices(key) && !value.as_str().is_some_and(|v| is_choice(c, v)) { let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect(); return Err(format!("`{key}` expects one of {}", all.join(", "))); } // Older clients set the single theme (by key, or inside the page object): it still picks // a visible theme, fixing the mode to its family. let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none()); 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}"))?; let (a, b) = (&self.appearance, &next.appearance); let only_theme_changed = a.theme != b.theme && a.appearance_mode == b.appearance_mode && a.dark_theme == b.dark_theme && a.light_theme == b.light_theme; if legacy_theme || only_theme_changed { next.appearance.select_theme(); } 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), recent_footage: std::mem::take(&mut self.recent_footage), recent_presets: std::mem::take(&mut self.recent_presets), recent_fonts: std::mem::take(&mut self.recent_fonts), preview: std::mem::take(&mut self.preview), ..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