effectcraft/crates/engine/src/prefs.rs

952 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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