M14.2: preferences
A real Settings model in the engine (`engine::prefs`): serde, versioned, dotted keys
(`general.undoLevels`, `labels.3.name`), migration of older layouts, unknown keys kept,
persisted through a `ConfigStore` trait (platform config dir on the desktop; MemoryConfig
for tests; the web app can back it with localStorage).
- After Effects 2026 pages (General … Scripting & Expressions) as a schema shared by the
dialog, `prefs.pages` and docs/preferences.md (rows not wired yet are listed there and
kept in step by a test).
- Commands: prefs.get, prefs.set {key, value | values}, prefs.reset {page?}, prefs.open
{page?}, prefs.pages.
- Wired settings: undo levels, path point size, tool tips, layers at comp start / CTI,
default spatial interpolation linear, Home screen on launch, new project template,
rendering progress in Info, adaptive resolution limit, cache when idle, fast previews,
theme + UI brightness, label names/colours everywhere (Label menu, timeline, project,
render queue, viewer), label colour for handles, grid colour/spacing/subdivisions, guide
colour, safe margins, still footage duration, sequence fps, audio output device and
output mapping, layer / footage / preview cache budgets, default 3D renderer.
- Settings dialog: AE's left page list + right content, OK / Cancel (restores) / Previous /
Next, every widget with an automation id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5273f1ba96
commit
47f7712364
|
|
@ -1,4 +1,5 @@
|
|||
//! Audio preview output through cpal (the default output device).
|
||||
//! Audio preview output through cpal (the device and channel mapping chosen in Settings ▸ Audio;
|
||||
//! the default output device when none is set or it is gone).
|
||||
//!
|
||||
//! cpal streams are not `Send` on every platform, so each preview runs its stream on a small
|
||||
//! dedicated thread that owns it until stopped. The callback pulls interleaved stereo from the
|
||||
|
|
@ -12,21 +13,41 @@ use std::thread::JoinHandle;
|
|||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, SizedSample};
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput};
|
||||
|
||||
pub struct CpalOut {
|
||||
out: AudioOutput,
|
||||
rate: u32,
|
||||
latency: Arc<AtomicU64>,
|
||||
stop: Option<mpsc::Sender<()>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// Open the default output device (`None` without one).
|
||||
pub fn open() -> Option<Box<dyn AudioDevice>> {
|
||||
/// Names of the output devices (Settings ▸ Audio).
|
||||
pub fn devices() -> Vec<String> {
|
||||
let host = cpal::default_host();
|
||||
let dev = host.default_output_device()?;
|
||||
#[allow(deprecated)]
|
||||
host.output_devices().map(|d| d.filter_map(|d| d.name().ok()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The named output device, or the default one.
|
||||
fn device(name: &str) -> Option<cpal::Device> {
|
||||
let host = cpal::default_host();
|
||||
if !name.is_empty() {
|
||||
#[allow(deprecated)]
|
||||
let found = host.output_devices().ok().and_then(|mut d| d.find(|d| d.name().ok().as_deref() == Some(name)));
|
||||
if found.is_some() {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
host.default_output_device()
|
||||
}
|
||||
|
||||
/// Open the output device (`None` without one).
|
||||
pub fn open(out: &AudioOutput) -> Option<Box<dyn AudioDevice>> {
|
||||
let dev = device(&out.device)?;
|
||||
let cfg = dev.default_output_config().ok()?;
|
||||
Some(Box::new(CpalOut { rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
|
||||
Some(Box::new(CpalOut { out: out.clone(), rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
|
||||
}
|
||||
|
||||
fn run<T: SizedSample + FromSample<f32>>(
|
||||
|
|
@ -34,8 +55,11 @@ fn run<T: SizedSample + FromSample<f32>>(
|
|||
cfg: &cpal::StreamConfig,
|
||||
feed: Arc<AudioFeed>,
|
||||
latency: Arc<AtomicU64>,
|
||||
map: (usize, usize),
|
||||
) -> Result<cpal::Stream, String> {
|
||||
let ch = cfg.channels as usize;
|
||||
// Output mapping: device channels (0-based) for left and right; out of range → 1 and 2.
|
||||
let (left, right) = if map.0 < ch && map.1 < ch { map } else { (0, 1) };
|
||||
let rate = cfg.sample_rate.0 as f64;
|
||||
let mut stereo: Vec<f32> = Vec::new();
|
||||
dev.build_output_stream(
|
||||
|
|
@ -46,11 +70,15 @@ fn run<T: SizedSample + FromSample<f32>>(
|
|||
feed.pull(&mut stereo);
|
||||
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) {
|
||||
for (c, o) in f.iter_mut().enumerate() {
|
||||
let v = match (ch, c) {
|
||||
(1, _) => (s[0] + s[1]) * 0.5,
|
||||
(_, 0) => s[0],
|
||||
(_, 1) => s[1],
|
||||
_ => 0.0,
|
||||
// Mono devices, or left and right mapped to the same channel, get the mix.
|
||||
let v = if ch == 1 || (c == left && c == right) {
|
||||
(s[0] + s[1]) * 0.5
|
||||
} else if c == left {
|
||||
s[0]
|
||||
} else if c == right {
|
||||
s[1]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*o = T::from_sample(v.clamp(-1.0, 1.0));
|
||||
}
|
||||
|
|
@ -77,11 +105,12 @@ impl AudioDevice for CpalOut {
|
|||
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
|
||||
let latency = self.latency.clone();
|
||||
let rate = self.rate;
|
||||
let name = self.out.device.clone();
|
||||
let map = ((self.out.left.max(1) - 1) as usize, (self.out.right.max(1) - 1) as usize);
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("ec-audio-out".into())
|
||||
.spawn(move || {
|
||||
let host = cpal::default_host();
|
||||
let Some(dev) = host.default_output_device() else {
|
||||
let Some(dev) = device(&name) else {
|
||||
let _ = ready_tx.send(Err("no audio output device".into()));
|
||||
return;
|
||||
};
|
||||
|
|
@ -95,10 +124,10 @@ impl AudioDevice for CpalOut {
|
|||
let mut cfg: cpal::StreamConfig = sup.config();
|
||||
cfg.sample_rate = cpal::SampleRate(rate);
|
||||
let stream = match sup.sample_format() {
|
||||
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency, map),
|
||||
f => Err(format!("unsupported sample format {f:?}")),
|
||||
};
|
||||
let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) {
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ fn main() -> eframe::Result {
|
|||
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut demo = true;
|
||||
let mut home = false;
|
||||
let mut home: Option<bool> = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--demo" => demo = true,
|
||||
"--empty" => demo = false,
|
||||
"--home" => home = true,
|
||||
"--home" => home = Some(true),
|
||||
"--version" => {
|
||||
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
|
|
@ -46,6 +46,11 @@ fn main() -> eframe::Result {
|
|||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut session = effectcraft_host::session();
|
||||
// Settings live in the platform config directory.
|
||||
if let Some(dir) = config_dir() {
|
||||
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
|
||||
}
|
||||
session.load_settings();
|
||||
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
|
|
@ -60,8 +65,9 @@ fn main() -> eframe::Result {
|
|||
{
|
||||
eprintln!("effectcraft: {e}");
|
||||
}
|
||||
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = home;
|
||||
app.ui.start_screen = show_home;
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}));
|
||||
|
|
@ -71,6 +77,8 @@ fn main() -> eframe::Result {
|
|||
app.hooks.pick_open_project =
|
||||
Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.audio_device = Some(Box::new(audio_out::open));
|
||||
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
if let Some(port) = control_port {
|
||||
disable_app_nap();
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
|
|
@ -81,6 +89,24 @@ fn main() -> eframe::Result {
|
|||
)
|
||||
}
|
||||
|
||||
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
|
||||
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
|
||||
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
use std::path::PathBuf;
|
||||
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
|
||||
return Some(PathBuf::from(d));
|
||||
}
|
||||
let home = || std::env::var_os("HOME").map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
|
||||
}
|
||||
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
|
||||
}
|
||||
|
||||
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
|
||||
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
|
||||
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = str_p(p, "name").unwrap_or("Comp 1").to_string();
|
||||
let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97);
|
||||
let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0)));
|
||||
// Settings ▸ 3D ▸ Default 3D Renderer.
|
||||
if s.prefs.three_d.default_renderer == "advanced" {
|
||||
c.renderer = effectcraft_project::Renderer::Advanced3D;
|
||||
}
|
||||
apply_settings(&mut c, p);
|
||||
let id = s.edit("New Composition", None, |proj, st| {
|
||||
let mut name = name.clone();
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn label(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "label").unwrap_or("Red");
|
||||
let lab = Label::from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?;
|
||||
let lab = s.prefs.label_from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?;
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let items = s.state.project_selection.clone();
|
||||
s.edit("Label", None, |proj, _| {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ fn has_path(s: &Session) -> std::result::Result<(), String> {
|
|||
}
|
||||
|
||||
fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// Settings ▸ Project ▸ New Project Loads Template: open the template as an untitled project.
|
||||
let tpl = s.prefs.project.template_path.trim().to_string();
|
||||
if s.prefs.project.use_template && !tpl.is_empty() {
|
||||
let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?;
|
||||
s.replace_project(Project::from_json(&text)?, None);
|
||||
return Ok(json!({"template": tpl}));
|
||||
}
|
||||
s.replace_project(Project::default(), None);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
|
@ -79,9 +87,19 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut ids = vec![];
|
||||
let mut errors = vec![];
|
||||
let mut probed = vec![];
|
||||
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
|
||||
for path in &paths {
|
||||
match importer.probe(path) {
|
||||
Ok(f) => probed.push((path.clone(), f)),
|
||||
Ok(mut f) => {
|
||||
// Settings ▸ Import ▸ Sequence Footage frames per second.
|
||||
if f.kind == FootageKind::Sequence {
|
||||
let r = seq_rate;
|
||||
let frames = f.frame_rate.frame_at(f.duration);
|
||||
f.frame_rate = r;
|
||||
f.duration = r.tick_of(frames.max(1));
|
||||
}
|
||||
probed.push((path.clone(), f))
|
||||
}
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"{page?: general|startup|project|composition|previews|appearance|grids|labels|type|import|export|audio|disk|memory|video|3d|scripting}",
|
||||
always
|
||||
),
|
||||
fe!("app.gpuInfo", "GPU Information...", [], None, "{}", always),
|
||||
fe!("app.hide", "Hide EffectCraft", [], None, "{}", always),
|
||||
fe!("app.quit", "Quit EffectCraft", [], Some("Cmd+Q"), "{}", always),
|
||||
fe!("app.commandPalette", "Quick Apply...", ["Edit"], Some("Cmd+Shift+Space"), "{query?}", always),
|
||||
|
|
|
|||
|
|
@ -252,12 +252,19 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone();
|
||||
let (src, size, dur) = match &it.kind {
|
||||
ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)),
|
||||
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), (f.kind != effectcraft_project::FootageKind::Still).then_some(f.duration)),
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => {
|
||||
// Settings ▸ Import ▸ Still Footage: length of the composition or a duration.
|
||||
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
|
||||
(LayerSource::Footage { item }, (f.width, f.height), d)
|
||||
}
|
||||
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
|
||||
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
|
||||
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
|
||||
};
|
||||
let fr = comp.frame_rate;
|
||||
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
|
||||
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
|
||||
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
|
||||
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(default_start));
|
||||
let id = s.edit("Add Footage to Comp", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
|
||||
l.start_time = start;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -30,11 +30,23 @@ fn with_prop<T>(
|
|||
f: impl FnOnce(&mut Property, Tick) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let t = s.time();
|
||||
let linear = s.prefs.general.default_spatial_linear;
|
||||
s.edit(label, merge, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
|
||||
f(pr, lt)
|
||||
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
|
||||
let r = f(pr, lt)?;
|
||||
// Settings ▸ General ▸ Default Spatial Interpolation to Linear: new keys of spatial
|
||||
// properties get straight motion paths instead of Auto Bezier.
|
||||
if linear && pr.spatial {
|
||||
for k in pr.keys.iter_mut().filter(|k| !before.contains(&k.time)) {
|
||||
k.spatial_auto = false;
|
||||
k.spatial_in = [0.0; 3];
|
||||
k.spatial_out = [0.0; 3];
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
//! Settings (`prefs.*`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
use crate::{Result, Session, cmd, query};
|
||||
|
||||
// ---------------------------------------------------------------- prefs
|
||||
|
||||
fn prefs_get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let key = str_p(p, "key").unwrap_or("");
|
||||
s.prefs.get(key).ok_or_else(|| bad("prefs.get", format!("unknown setting `{key}`")))
|
||||
}
|
||||
|
||||
fn prefs_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut pairs: Vec<(String, Value)> = vec![];
|
||||
if let Some(Value::Object(m)) = p.get("values") {
|
||||
pairs.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
if let Some(k) = str_p(p, "key") {
|
||||
let v = p.get("value").cloned().ok_or_else(|| bad("prefs.set", "missing `value`"))?;
|
||||
pairs.push((k.to_string(), v));
|
||||
}
|
||||
if pairs.is_empty() {
|
||||
return Err(bad("prefs.set", "give `key` and `value` (or `values: {key: value}`)"));
|
||||
}
|
||||
let mut next = s.prefs.clone();
|
||||
for (k, v) in &pairs {
|
||||
next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?;
|
||||
}
|
||||
s.prefs = next;
|
||||
s.prefs_changed();
|
||||
s.save_prefs();
|
||||
let out: serde_json::Map<String, Value> = pairs.iter().map(|(k, _)| (k.clone(), s.prefs.get(k).unwrap_or(Value::Null))).collect();
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
fn prefs_reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = str_p(p, "page");
|
||||
s.prefs.reset(page).map_err(|e| bad("prefs.reset", e))?;
|
||||
s.prefs_changed();
|
||||
s.save_prefs();
|
||||
s.toast(match page {
|
||||
Some(pg) => format!("Reset {pg} settings"),
|
||||
None => "Reset all settings".into(),
|
||||
});
|
||||
Ok(json!({"reset": page.unwrap_or("all")}))
|
||||
}
|
||||
|
||||
fn prefs_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = str_p(p, "page").unwrap_or("general");
|
||||
let id = crate::prefs::page_id(page).ok_or_else(|| bad("prefs.open", format!("unknown settings page `{page}`")))?;
|
||||
super::frontend(s, "app.settings", &json!({"page": id}))
|
||||
}
|
||||
|
||||
fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(crate::prefs::pages_json())
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("prefs.get", "Get Setting", "{key?: e.g. general.undoLevels (omit for all settings)}", prefs_get),
|
||||
cmd!("prefs.set", "Change Setting", [], None, "{key, value, values?: {key: value}}", always, prefs_set),
|
||||
cmd!("prefs.reset", "Reset Settings", [], None, "{page?: general|labels|project|…}", always, prefs_reset),
|
||||
cmd!(
|
||||
"prefs.open",
|
||||
"Open Settings",
|
||||
[],
|
||||
None,
|
||||
"{page?: general|startup|project|composition|previews|appearance|grids|labels|type|import|export|audio|disk|memory|video|3d|scripting}",
|
||||
always,
|
||||
prefs_open
|
||||
),
|
||||
query!("prefs.pages", "Settings Pages", "{}", prefs_pages),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
//! Persistent configuration storage (Settings). The engine only sees the [`ConfigStore`] trait;
|
||||
//! frontends choose where it lives: the desktop app uses a [`DirConfig`] in the platform config
|
||||
//! directory, the web app can back it with `localStorage`, tests and headless runs use
|
||||
//! [`MemoryConfig`] (or none at all).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Named text blobs (`prefs.json`).
|
||||
pub trait ConfigStore: Send + Sync {
|
||||
fn read(&self, name: &str) -> Option<String>;
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
|
||||
fn remove(&self, name: &str) -> std::io::Result<()>;
|
||||
/// A directory on disk next to the configuration (default Auto-Save folder for untitled
|
||||
/// projects); `None` for stores that aren't on the file system.
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store (tests, headless sessions).
|
||||
#[derive(Default)]
|
||||
pub struct MemoryConfig {
|
||||
pub files: Mutex<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
impl ConfigStore for MemoryConfig {
|
||||
fn read(&self, name: &str) -> Option<String> {
|
||||
self.files.lock().ok()?.get(name).cloned()
|
||||
}
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
|
||||
if let Ok(mut m) = self.files.lock() {
|
||||
m.insert(name.to_string(), data.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::io::Result<()> {
|
||||
if let Ok(mut m) = self.files.lock() {
|
||||
m.remove(name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Files in one directory, written atomically.
|
||||
pub struct DirConfig {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl DirConfig {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> DirConfig {
|
||||
DirConfig { dir: dir.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigStore for DirConfig {
|
||||
fn read(&self, name: &str) -> Option<String> {
|
||||
std::fs::read_to_string(self.dir.join(name)).ok()
|
||||
}
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
atomic_write(&self.dir.join(name), data.as_bytes())
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(self.dir.join(name)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
Some(self.dir.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// The temporary file an atomic write of `path` goes through.
|
||||
pub fn temp_path(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
|
||||
name.push(".tmp");
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Write `data` to `path` atomically: write a temporary file next to it, flush it to disk, then
|
||||
/// rename it over `path`. A crash at any point leaves either the old file or the new one, never a
|
||||
/// torn file.
|
||||
pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
atomic_write_with(path, data, |tmp, data| {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::File::create(tmp)?;
|
||||
f.write_all(data)?;
|
||||
f.sync_all()
|
||||
})
|
||||
}
|
||||
|
||||
/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A
|
||||
/// failed write removes the temporary file and leaves `path` untouched.
|
||||
pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
let tmp = temp_path(path);
|
||||
if let Err(e) = write(&tmp, data) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
|
@ -9,10 +9,13 @@
|
|||
//! (compositions are `Arc`s, so untouched comps are shared).
|
||||
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod demo;
|
||||
pub mod links;
|
||||
pub mod menus;
|
||||
pub mod prefs;
|
||||
pub mod render_queue;
|
||||
mod session_settings;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -208,6 +211,12 @@ pub struct Session {
|
|||
pub journal: Vec<(String, Value)>,
|
||||
/// Processed-layer pixels reused across frames and edits (content-keyed, never stale).
|
||||
pub layer_cache: Arc<LayerCache>,
|
||||
/// Settings (Preferences).
|
||||
pub prefs: prefs::Prefs,
|
||||
/// Bumped whenever settings change (frontends re-apply theme, labels…).
|
||||
pub prefs_revision: u64,
|
||||
/// Where settings are stored (`None` = nothing persists).
|
||||
pub config: Option<Arc<dyn config::ConfigStore>>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -229,6 +238,9 @@ impl Default for Session {
|
|||
events: vec![],
|
||||
journal: vec![],
|
||||
layer_cache: Arc::new(LayerCache::default()),
|
||||
prefs: prefs::Prefs::default(),
|
||||
prefs_revision: 0,
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -281,8 +293,10 @@ impl Session {
|
|||
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
|
||||
if !same {
|
||||
self.history.undo.push((label.to_string(), before));
|
||||
if self.history.undo.len() > 500 {
|
||||
self.history.undo.remove(0);
|
||||
let levels = self.prefs.general.undo_levels.max(1) as usize;
|
||||
if self.history.undo.len() > levels {
|
||||
let extra = self.history.undo.len() - levels;
|
||||
self.history.undo.drain(..extra);
|
||||
}
|
||||
}
|
||||
self.history.merge_key = merge.map(str::to_string);
|
||||
|
|
@ -456,6 +470,8 @@ mod tests_3d;
|
|||
#[cfg(test)]
|
||||
mod tests_menu_cmds;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
#[cfg(test)]
|
||||
mod tests_text;
|
||||
|
|
|
|||
|
|
@ -93,6 +93,17 @@ pub fn entries() -> Vec<(Vec<String>, &'static MenuEntry)> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Display label of an entry: label names come from Settings ▸ Labels.
|
||||
pub fn entry_label(s: &Session, e: &MenuEntry) -> String {
|
||||
match e.command.as_str() {
|
||||
"edit.label" => match e.params.get("label").and_then(Value::as_str).and_then(effectcraft_color::Label::from_name) {
|
||||
Some(l) if l != effectcraft_color::Label::None => s.prefs.label_name(l),
|
||||
_ => e.label.clone(),
|
||||
},
|
||||
_ => e.label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check-mark state of an entry (`None` = not a toggle / radio entry). Frontend-only toggles
|
||||
/// (rulers, grid…) are answered by the frontend.
|
||||
pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,951 @@
|
|||
//! Settings (After Effects' Preferences): a versioned serde model, addressed by dotted keys
|
||||
//! (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`), plus the page schema the
|
||||
//! Settings dialog and agents (`prefs.pages`) are built from.
|
||||
//!
|
||||
//! Settings are stored as JSON (`prefs.json`) through the session's [`crate::config::ConfigStore`].
|
||||
//! Loading is forgiving: missing keys take their defaults, unknown keys (written by a newer
|
||||
//! version) are kept and written back, and older layouts are migrated ([`migrate`]).
|
||||
//!
|
||||
//! Every setting in [`pages`] says whether it already changes behaviour (`live`). Settings that
|
||||
//! don't yet are still shown (After Effects parity of the dialog) and are listed in
|
||||
//! `docs/preferences.md`; a test keeps that list in step with the schema.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// Current settings layout version.
|
||||
pub const PREFS_VERSION: u32 = 2;
|
||||
/// Settings file name in the config store.
|
||||
pub const PREFS_FILE: &str = "prefs.json";
|
||||
|
||||
/// Unknown keys inside a page, kept so a newer version's settings survive a round trip.
|
||||
type Extra = BTreeMap<String, Value>;
|
||||
|
||||
macro_rules! page {
|
||||
($(#[$m:meta])* $name:ident { $($(#[$fm:meta])* $f:ident : $t:ty = $d:expr),* $(,)? }) => {
|
||||
$(#[$m])*
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct $name {
|
||||
$($(#[$fm])* pub $f: $t,)*
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
$name { $($f: $d,)* extra: Extra::new() }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
page!(General {
|
||||
/// Levels of Undo (1–99).
|
||||
undo_levels: u32 = 32,
|
||||
/// Path Point and Handle Size (px).
|
||||
path_point_size: u32 = 5,
|
||||
show_tool_tips: bool = true,
|
||||
/// Create New Layers at Composition Start Time (off: at the current time).
|
||||
create_layers_at_comp_start: bool = true,
|
||||
switches_affect_nested_comps: bool = true,
|
||||
/// Default Spatial Interpolation to Linear.
|
||||
default_spatial_linear: bool = false,
|
||||
preserve_constant_vertex_count: bool = true,
|
||||
/// Synchronize Time of All Related Items.
|
||||
sync_time_related_items: bool = true,
|
||||
expression_pick_whip_compact: bool = true,
|
||||
create_split_layers_above: bool = false,
|
||||
use_system_color_picker: bool = false,
|
||||
/// Number of projects in File ▸ Open Recent.
|
||||
recent_items: u32 = 10,
|
||||
});
|
||||
|
||||
page!(Startup {
|
||||
show_home_on_launch: bool = true,
|
||||
show_home_on_open_project: bool = false,
|
||||
/// After a crash, offer to open the most recent auto-save.
|
||||
offer_crash_recovery: bool = true,
|
||||
});
|
||||
|
||||
page!(ProjectPrefs {
|
||||
/// Open a template project for File ▸ New ▸ New Project.
|
||||
use_template: bool = false,
|
||||
template_path: String = String::new(),
|
||||
});
|
||||
|
||||
page!(AutoSave {
|
||||
enabled: bool = true,
|
||||
interval_minutes: u32 = 20,
|
||||
max_versions: u32 = 5,
|
||||
/// `nextToProject` (an "EffectCraft Auto-Save" folder beside the project) or `custom`.
|
||||
location: String = "nextToProject".into(),
|
||||
folder: String = String::new(),
|
||||
save_on_render_start: bool = false,
|
||||
});
|
||||
|
||||
page!(CompositionPrefs {
|
||||
/// Motion path: `all`, `none`, `seconds`, `keyframes`.
|
||||
motion_path: String = "keyframes".into(),
|
||||
motion_path_seconds: f64 = 15.0,
|
||||
motion_path_keyframes: u32 = 15,
|
||||
show_rendering_progress: bool = true,
|
||||
hardware_accelerate_panels: bool = true,
|
||||
});
|
||||
|
||||
page!(Previews {
|
||||
/// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`.
|
||||
adaptive_resolution_limit: String = "1/8".into(),
|
||||
show_internal_wireframes: bool = false,
|
||||
cache_frames_when_idle: bool = false,
|
||||
fast_previews: bool = false,
|
||||
/// `faster` or `moreAccurate`.
|
||||
zoom_quality: String = "moreAccurate".into(),
|
||||
show_gpu_info: bool = false,
|
||||
});
|
||||
|
||||
page!(Appearance {
|
||||
/// `dark`, `darker` or `light`.
|
||||
theme: String = "dark".into(),
|
||||
/// User interface brightness, -1 (darker) … 1 (lighter).
|
||||
brightness: f64 = 0.0,
|
||||
use_label_color_for_handles: bool = true,
|
||||
use_label_color_for_tabs: bool = true,
|
||||
cycle_mask_colors: bool = true,
|
||||
use_gradients: bool = true,
|
||||
});
|
||||
|
||||
page!(Grids {
|
||||
grid_color: String = "#7a9cff".into(),
|
||||
grid_spacing: f64 = 100.0,
|
||||
grid_subdivisions: u32 = 4,
|
||||
/// `lines`, `dashed` or `dots`.
|
||||
grid_style: String = "lines".into(),
|
||||
proportional_horizontal: u32 = 4,
|
||||
proportional_vertical: u32 = 4,
|
||||
guide_color: String = "#3cc8f0".into(),
|
||||
guide_style: String = "lines".into(),
|
||||
action_safe: f64 = 10.0,
|
||||
title_safe: f64 = 20.0,
|
||||
});
|
||||
|
||||
page!(TypePrefs {
|
||||
/// `latin` or `southAsian` (South Asian and Middle Eastern).
|
||||
text_engine: String = "latin".into(),
|
||||
font_preview: bool = true,
|
||||
recent_fonts: u32 = 10,
|
||||
font_names_in_english: bool = false,
|
||||
});
|
||||
|
||||
page!(Import {
|
||||
/// Still footage duration: `compLength` or `seconds`.
|
||||
still_footage: String = "compLength".into(),
|
||||
still_seconds: f64 = 5.0,
|
||||
sequence_fps: f64 = 30.0,
|
||||
report_missing_frames: bool = true,
|
||||
/// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`.
|
||||
unlabeled_alpha: String = "ask".into(),
|
||||
/// Default drag import as: `footage`, `comp`, `compLayerSizes`.
|
||||
drag_import_as: String = "footage".into(),
|
||||
});
|
||||
|
||||
page!(Export {
|
||||
default_output_folder: String = String::new(),
|
||||
segment_sequences: bool = false,
|
||||
segment_sequence_files: u32 = 700,
|
||||
segment_movies: bool = false,
|
||||
segment_movie_mb: u32 = 1024,
|
||||
append_bits_to_name: bool = false,
|
||||
});
|
||||
|
||||
page!(Audio {
|
||||
/// Output device name (empty = the system default).
|
||||
output_device: String = String::new(),
|
||||
/// Output mapping: device channel for the left / right output (1-based).
|
||||
output_left: u32 = 1,
|
||||
output_right: u32 = 2,
|
||||
preview_sample_rate: u32 = 48_000,
|
||||
});
|
||||
|
||||
page!(Disk {
|
||||
disk_cache_enabled: bool = true,
|
||||
disk_cache_max_gb: u32 = 100,
|
||||
disk_cache_folder: String = String::new(),
|
||||
media_cache_folder: String = String::new(),
|
||||
conformed_media_folder: String = String::new(),
|
||||
});
|
||||
|
||||
page!(Memory {
|
||||
ram_reserved_gb: u32 = 6,
|
||||
/// Processed-layer cache budget (MB).
|
||||
layer_cache_mb: u32 = 1024,
|
||||
/// Decoded footage frame cache budget (MB).
|
||||
media_cache_mb: u32 = 1024,
|
||||
/// RAM preview (viewer frame) cache budget (MB).
|
||||
preview_cache_mb: u32 = 3072,
|
||||
reduce_cache_when_low: bool = true,
|
||||
});
|
||||
|
||||
page!(Video {
|
||||
enable_output: bool = false,
|
||||
device: String = String::new(),
|
||||
output_during_playback: bool = true,
|
||||
mirror_on_monitor: bool = true,
|
||||
disable_when_background: bool = false,
|
||||
});
|
||||
|
||||
page!(ThreeD {
|
||||
/// Default 3D renderer for new compositions: `classic` or `advanced`.
|
||||
default_renderer: String = "classic".into(),
|
||||
show_reference_axes: bool = true,
|
||||
extended_viewer: bool = false,
|
||||
realtime_shadows: bool = true,
|
||||
});
|
||||
|
||||
page!(Scripting {
|
||||
allow_scripts_write_files: bool = false,
|
||||
warn_executing_files: bool = true,
|
||||
enable_js_debugger: bool = false,
|
||||
editor_font_size: u32 = 13,
|
||||
syntax_highlighting: bool = true,
|
||||
line_numbers: bool = true,
|
||||
auto_complete: bool = true,
|
||||
bracket_matching: bool = true,
|
||||
word_wrap: bool = false,
|
||||
error_banner: bool = true,
|
||||
});
|
||||
|
||||
/// One editable label: name and `#rrggbb` colour.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LabelPref {
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
fn default_labels() -> Vec<LabelPref> {
|
||||
Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect()
|
||||
}
|
||||
|
||||
fn hex([r, g, b]: [u8; 3]) -> String {
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> Option<[u8; 3]> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
if h.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
|
||||
Some([p(0)?, p(2)?, p(4)?])
|
||||
}
|
||||
|
||||
/// All settings.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Prefs {
|
||||
pub version: u32,
|
||||
pub general: General,
|
||||
pub startup: Startup,
|
||||
pub project: ProjectPrefs,
|
||||
pub auto_save: AutoSave,
|
||||
pub composition: CompositionPrefs,
|
||||
pub previews: Previews,
|
||||
pub appearance: Appearance,
|
||||
pub grids: Grids,
|
||||
/// The 16 labels (Red … Dark Green), in order.
|
||||
pub labels: Vec<LabelPref>,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: TypePrefs,
|
||||
pub import: Import,
|
||||
pub export: Export,
|
||||
pub audio: Audio,
|
||||
pub disk: Disk,
|
||||
pub memory: Memory,
|
||||
pub video: Video,
|
||||
#[serde(rename = "threeD")]
|
||||
pub three_d: ThreeD,
|
||||
pub scripting: Scripting,
|
||||
/// File ▸ Open Recent, newest first.
|
||||
pub recent_projects: Vec<String>,
|
||||
/// Top-level keys this version doesn't know (kept for newer versions).
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Prefs {
|
||||
version: PREFS_VERSION,
|
||||
general: General::default(),
|
||||
startup: Startup::default(),
|
||||
project: ProjectPrefs::default(),
|
||||
auto_save: AutoSave::default(),
|
||||
composition: CompositionPrefs::default(),
|
||||
previews: Previews::default(),
|
||||
appearance: Appearance::default(),
|
||||
grids: Grids::default(),
|
||||
labels: default_labels(),
|
||||
type_: TypePrefs::default(),
|
||||
import: Import::default(),
|
||||
export: Export::default(),
|
||||
audio: Audio::default(),
|
||||
disk: Disk::default(),
|
||||
memory: Memory::default(),
|
||||
video: Video::default(),
|
||||
three_d: ThreeD::default(),
|
||||
scripting: Scripting::default(),
|
||||
recent_projects: vec![],
|
||||
extra: Extra::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring an older settings document up to [`PREFS_VERSION`].
|
||||
///
|
||||
/// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`,
|
||||
/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level.
|
||||
pub fn migrate(mut v: Value) -> Value {
|
||||
let Some(obj) = v.as_object_mut() else { return json!({}) };
|
||||
let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0);
|
||||
if version < 2 {
|
||||
let moved = |from: &str, page: &str, key: &str, obj: &mut Map<String, Value>| {
|
||||
if let Some(x) = obj.remove(from) {
|
||||
let p = obj.entry(page.to_string()).or_insert_with(|| json!({}));
|
||||
if let Some(p) = p.as_object_mut() {
|
||||
p.entry(key.to_string()).or_insert(x);
|
||||
}
|
||||
}
|
||||
};
|
||||
moved("undoLevels", "general", "undoLevels", obj);
|
||||
moved("autoSaveEnabled", "autoSave", "enabled", obj);
|
||||
moved("autoSaveMinutes", "autoSave", "intervalMinutes", obj);
|
||||
moved("autoSaveVersions", "autoSave", "maxVersions", obj);
|
||||
moved("theme", "appearance", "theme", obj);
|
||||
if let Some(Value::Array(names)) = obj.remove("labelNames") {
|
||||
let mut labels = default_labels();
|
||||
for (l, n) in labels.iter_mut().zip(names) {
|
||||
if let Some(n) = n.as_str() {
|
||||
l.name = n.to_string();
|
||||
}
|
||||
}
|
||||
obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
obj.insert("version".into(), json!(PREFS_VERSION));
|
||||
v
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// Parse a settings document (migrating older layouts; unknown keys are kept, bad values
|
||||
/// fall back to defaults).
|
||||
pub fn from_json(text: &str) -> Prefs {
|
||||
let v: Value = serde_json::from_str(text).unwrap_or_else(|_| json!({}));
|
||||
let v = migrate(v);
|
||||
let mut p: Prefs = match serde_json::from_value(v.clone()) {
|
||||
Ok(p) => p,
|
||||
// One bad value shouldn't lose everything: take each page that parses.
|
||||
Err(_) => {
|
||||
let mut base = serde_json::to_value(Prefs::default()).unwrap_or_default();
|
||||
if let (Some(b), Some(o)) = (base.as_object_mut(), v.as_object()) {
|
||||
for (k, x) in o {
|
||||
let mut trial = b.clone();
|
||||
trial.insert(k.clone(), x.clone());
|
||||
if serde_json::from_value::<Prefs>(Value::Object(trial)).is_ok() {
|
||||
b.insert(k.clone(), x.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::from_value(base).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
p.normalize();
|
||||
p
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string_pretty(self).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Clamp values into their valid ranges and fill missing labels.
|
||||
pub fn normalize(&mut self) {
|
||||
self.version = PREFS_VERSION;
|
||||
let g = &mut self.general;
|
||||
g.undo_levels = g.undo_levels.clamp(1, 99);
|
||||
g.path_point_size = g.path_point_size.clamp(3, 20);
|
||||
g.recent_items = g.recent_items.clamp(1, 30);
|
||||
let a = &mut self.auto_save;
|
||||
a.interval_minutes = a.interval_minutes.clamp(1, 240);
|
||||
a.max_versions = a.max_versions.clamp(1, 99);
|
||||
self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0);
|
||||
let defaults = default_labels();
|
||||
self.labels.truncate(defaults.len());
|
||||
for d in defaults.iter().skip(self.labels.len()) {
|
||||
self.labels.push(d.clone());
|
||||
}
|
||||
for (l, d) in self.labels.iter_mut().zip(&defaults) {
|
||||
if parse_hex(&l.color).is_none() {
|
||||
l.color = d.color.clone();
|
||||
}
|
||||
}
|
||||
let m = &mut self.memory;
|
||||
m.layer_cache_mb = m.layer_cache_mb.clamp(64, 1 << 20);
|
||||
m.media_cache_mb = m.media_cache_mb.clamp(64, 1 << 20);
|
||||
m.preview_cache_mb = m.preview_cache_mb.clamp(64, 1 << 20);
|
||||
self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0);
|
||||
let n = self.general.recent_items as usize;
|
||||
self.recent_projects.truncate(n);
|
||||
}
|
||||
|
||||
/// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything.
|
||||
pub fn get(&self, key: &str) -> Option<Value> {
|
||||
let v = serde_json::to_value(self).ok()?;
|
||||
if key.is_empty() {
|
||||
return Some(v);
|
||||
}
|
||||
let ptr = format!("/{}", key.replace('.', "/"));
|
||||
v.pointer(&ptr).cloned()
|
||||
}
|
||||
|
||||
/// Set the value at a dotted key. The key must exist and the value must have its type.
|
||||
pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> {
|
||||
let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?;
|
||||
let ptr = format!("/{}", key.replace('.', "/"));
|
||||
let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?;
|
||||
let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?;
|
||||
*slot = value;
|
||||
let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
|
||||
next.normalize();
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is
|
||||
/// kept.
|
||||
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
|
||||
let d = Prefs::default();
|
||||
let Some(page) = page else {
|
||||
*self = Prefs { recent_projects: std::mem::take(&mut self.recent_projects), ..d };
|
||||
return Ok(());
|
||||
};
|
||||
let keys: Vec<&str> = match page {
|
||||
"project" => vec!["project", "autoSave"],
|
||||
p => vec![section_key(p).ok_or_else(|| format!("unknown settings page `{p}`"))?],
|
||||
};
|
||||
let dv = serde_json::to_value(&d).map_err(|e| e.to_string())?;
|
||||
for k in keys {
|
||||
if let Some(x) = dv.get(k) {
|
||||
self.set(k, x.clone())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Display name of a label (Labels settings).
|
||||
pub fn label_name(&self, l: Label) -> String {
|
||||
match label_index(l) {
|
||||
Some(i) => self.labels.get(i).map(|p| p.name.clone()).unwrap_or_else(|| l.name().to_string()),
|
||||
None => l.name().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB colour of a label (Labels settings).
|
||||
pub fn label_rgb(&self, l: Label) -> [u8; 3] {
|
||||
label_index(l).and_then(|i| self.labels.get(i)).and_then(|p| parse_hex(&p.color)).unwrap_or(l.rgb())
|
||||
}
|
||||
|
||||
/// A label by its built-in name or its (renamed) display name.
|
||||
pub fn label_from_name(&self, s: &str) -> Option<Label> {
|
||||
Label::from_name(s).or_else(|| Label::ALL.iter().skip(1).copied().find(|l| self.label_name(*l).eq_ignore_ascii_case(s)))
|
||||
}
|
||||
|
||||
/// Remember a project in File ▸ Open Recent.
|
||||
pub fn push_recent(&mut self, path: &str) {
|
||||
self.recent_projects.retain(|p| p != path);
|
||||
self.recent_projects.insert(0, path.to_string());
|
||||
self.recent_projects.truncate(self.general.recent_items as usize);
|
||||
}
|
||||
|
||||
/// Layer / media / preview cache budgets in bytes.
|
||||
pub fn layer_cache_bytes(&self) -> usize {
|
||||
(self.memory.layer_cache_mb as usize) << 20
|
||||
}
|
||||
pub fn media_cache_bytes(&self) -> usize {
|
||||
(self.memory.media_cache_mb as usize) << 20
|
||||
}
|
||||
pub fn preview_cache_bytes(&self) -> usize {
|
||||
(self.memory.preview_cache_mb as usize) << 20
|
||||
}
|
||||
|
||||
/// The adaptive resolution limit as a render-scale floor (1/2 → 0.5).
|
||||
pub fn adaptive_limit(&self) -> f64 {
|
||||
match self.previews.adaptive_resolution_limit.as_str() {
|
||||
"1/2" => 0.5,
|
||||
"1/4" => 0.25,
|
||||
_ => 0.125,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn label_index(l: Label) -> Option<usize> {
|
||||
Label::ALL.iter().position(|x| *x == l).and_then(|i| i.checked_sub(1))
|
||||
}
|
||||
|
||||
/// Settings key of a page id (`3d` → `threeD`).
|
||||
pub fn section_key(page: &str) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
"general" => "general",
|
||||
"startup" => "startup",
|
||||
"project" => "project",
|
||||
"autoSave" | "autosave" => "autoSave",
|
||||
"composition" | "display" => "composition",
|
||||
"previews" => "previews",
|
||||
"appearance" => "appearance",
|
||||
"grids" => "grids",
|
||||
"labels" => "labels",
|
||||
"type" => "type",
|
||||
"import" => "import",
|
||||
"export" => "export",
|
||||
"audio" => "audio",
|
||||
"disk" => "disk",
|
||||
"memory" => "memory",
|
||||
"video" => "video",
|
||||
"3d" | "threeD" => "threeD",
|
||||
"scripting" => "scripting",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Settings page id for a page name, including older After Effects page names
|
||||
/// (Auto-Save → Project, Media & Disk Cache → Disk, Memory & Performance → Memory & CPU…).
|
||||
pub fn page_id(name: &str) -> Option<&'static str> {
|
||||
let n = name.to_ascii_lowercase().replace([' ', '&', '-', '_'], "");
|
||||
let id = match n.as_str() {
|
||||
"general" => "general",
|
||||
"startup" | "startuprepair" => "startup",
|
||||
"project" | "autosave" | "newproject" => "project",
|
||||
"composition" | "display" => "composition",
|
||||
"previews" | "preview" => "previews",
|
||||
"appearance" => "appearance",
|
||||
"grids" | "gridsguides" => "grids",
|
||||
"labels" => "labels",
|
||||
"type" => "type",
|
||||
"import" => "import",
|
||||
"export" | "output" => "export",
|
||||
"audio" | "audiohardware" | "audiooutputmapping" => "audio",
|
||||
"disk" | "mediadiskcache" | "mediacache" => "disk",
|
||||
"memory" | "memorycpu" | "memoryperformance" => "memory",
|
||||
"video" | "videopreview" => "video",
|
||||
"3d" | "threed" => "3d",
|
||||
"scripting" | "scriptingexpressions" => "scripting",
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
}
|
||||
|
||||
fn kind_name(v: &Value) -> &'static str {
|
||||
match v {
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "list",
|
||||
Value::Object(_) => "object",
|
||||
Value::Null => "value",
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` converted to the type of `slot` (numbers from strings, booleans from 0/1…).
|
||||
fn coerce(slot: &Value, value: Value) -> Option<Value> {
|
||||
Some(match (slot, value) {
|
||||
(Value::Bool(_), Value::Bool(b)) => json!(b),
|
||||
(Value::Bool(_), Value::Number(n)) => json!(n.as_f64()? != 0.0),
|
||||
(Value::Bool(_), Value::String(s)) => json!(matches!(s.as_str(), "true" | "on" | "1" | "yes")),
|
||||
(Value::Number(cur), v) => {
|
||||
let f = match &v {
|
||||
Value::Number(n) => n.as_f64()?,
|
||||
Value::String(s) => s.trim().parse().ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
if cur.is_u64() || cur.is_i64() { json!(f.round().max(0.0) as u64) } else { json!(f) }
|
||||
}
|
||||
(Value::String(_), Value::String(s)) => json!(s),
|
||||
(Value::String(_), Value::Number(n)) => json!(n.to_string()),
|
||||
(Value::Array(_), v @ Value::Array(_)) => v,
|
||||
(Value::Object(_), v @ Value::Object(_)) => v,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- schema (Settings dialog)
|
||||
|
||||
/// What a setting row edits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Kind {
|
||||
Bool,
|
||||
/// Integer with range and unit.
|
||||
Int(i64, i64, &'static str),
|
||||
/// Number with range and unit.
|
||||
Float(f64, f64, &'static str),
|
||||
/// One of (label, value).
|
||||
Choice(&'static [(&'static str, &'static str)]),
|
||||
Text,
|
||||
/// A folder or file path (text + Choose…).
|
||||
Path,
|
||||
/// `#rrggbb`.
|
||||
Color,
|
||||
/// UI brightness slider.
|
||||
Slider(f64, f64),
|
||||
}
|
||||
|
||||
/// One row of a settings page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Item {
|
||||
Section(&'static str),
|
||||
Setting {
|
||||
key: &'static str,
|
||||
label: &'static str,
|
||||
kind: Kind,
|
||||
/// Changes behaviour today (false = shown for parity, listed in docs/preferences.md).
|
||||
live: bool,
|
||||
},
|
||||
/// A button running a command (`params` is JSON).
|
||||
Button {
|
||||
label: &'static str,
|
||||
command: &'static str,
|
||||
params: &'static str,
|
||||
},
|
||||
/// The 16 label names and colours.
|
||||
Labels,
|
||||
/// Output device picker (device list from the host).
|
||||
AudioDevices {
|
||||
key: &'static str,
|
||||
},
|
||||
/// Read-only information line.
|
||||
Note(&'static str),
|
||||
}
|
||||
|
||||
/// One page of the Settings dialog.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Page {
|
||||
pub id: &'static str,
|
||||
pub title: &'static str,
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
const fn s(key: &'static str, label: &'static str, kind: Kind, live: bool) -> Item {
|
||||
Item::Setting { key, label, kind, live }
|
||||
}
|
||||
|
||||
const B: Kind = Kind::Bool;
|
||||
const ON_OFF_RES: &[(&str, &str)] = &[("1/2", "1/2"), ("1/4", "1/4"), ("1/8", "1/8")];
|
||||
const STYLES: &[(&str, &str)] = &[("Lines", "lines"), ("Dashed Lines", "dashed"), ("Dots", "dots")];
|
||||
|
||||
/// The Settings pages in After Effects 2026 order.
|
||||
pub fn pages() -> Vec<Page> {
|
||||
use Item::*;
|
||||
vec![
|
||||
Page {
|
||||
id: "general",
|
||||
title: "General",
|
||||
items: vec![
|
||||
s("general.undoLevels", "Levels of Undo", Kind::Int(1, 99, ""), true),
|
||||
s("general.pathPointSize", "Path Point and Handle Size", Kind::Int(3, 20, "px"), true),
|
||||
s("general.recentItems", "Recent Projects Shown", Kind::Int(1, 30, ""), true),
|
||||
Section("Options"),
|
||||
s("general.showToolTips", "Show Tool Tips", B, true),
|
||||
s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true),
|
||||
s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, false),
|
||||
s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true),
|
||||
s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, false),
|
||||
s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, false),
|
||||
s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, false),
|
||||
s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, false),
|
||||
s("general.useSystemColorPicker", "Use System Color Picker", B, false),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "startup",
|
||||
title: "Startup & Repair",
|
||||
items: vec![
|
||||
s("startup.showHomeOnLaunch", "Show Home Screen When Launching", B, true),
|
||||
s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, false),
|
||||
s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true),
|
||||
Section("Repair"),
|
||||
Button { label: "Reset Settings", command: "prefs.reset", params: "{}" },
|
||||
Button { label: "Reset Keyboard Shortcuts", command: "shortcuts.reset", params: "{}" },
|
||||
Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# },
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "project",
|
||||
title: "Project",
|
||||
items: vec![
|
||||
Section("New Project"),
|
||||
s("project.useTemplate", "New Project Loads Template", B, true),
|
||||
s("project.templatePath", "Template Project", Kind::Path, true),
|
||||
Section("Auto-Save"),
|
||||
s("autoSave.enabled", "Automatically Save Projects", B, true),
|
||||
s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true),
|
||||
s("autoSave.maxVersions", "Maximum Project Versions", Kind::Int(1, 99, ""), true),
|
||||
s("autoSave.location", "Auto-Save Location", Kind::Choice(&[("Next to Project", "nextToProject"), ("Custom Location", "custom")]), true),
|
||||
s("autoSave.folder", "Custom Location", Kind::Path, true),
|
||||
s("autoSave.saveOnRenderStart", "Save When Starting Render Queue", B, true),
|
||||
Button { label: "Save Now", command: "file.autoSave", params: "{}" },
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "composition",
|
||||
title: "Composition",
|
||||
items: vec![
|
||||
Section("Motion Path"),
|
||||
s(
|
||||
"composition.motionPath",
|
||||
"Motion Path",
|
||||
Kind::Choice(&[
|
||||
("All Keyframes", "all"),
|
||||
("No Motion Path", "none"),
|
||||
("No More Than (Seconds)", "seconds"),
|
||||
("No More Than (Keyframes)", "keyframes"),
|
||||
]),
|
||||
false,
|
||||
),
|
||||
s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), false),
|
||||
s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), false),
|
||||
Section("Display"),
|
||||
s("composition.showRenderingProgress", "Show Rendering Progress in Info Panel and Flowchart", B, true),
|
||||
s("composition.hardwareAcceleratePanels", "Hardware Accelerate Composition, Layer and Footage Panels", B, false),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "previews",
|
||||
title: "Previews",
|
||||
items: vec![
|
||||
s("previews.adaptiveResolutionLimit", "Adaptive Resolution Limit", Kind::Choice(ON_OFF_RES), true),
|
||||
s("previews.showInternalWireframes", "Show Internal Wireframes", B, false),
|
||||
s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true),
|
||||
s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true),
|
||||
s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), false),
|
||||
Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" },
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "appearance",
|
||||
title: "Appearance",
|
||||
items: vec![
|
||||
s("appearance.theme", "Theme", Kind::Choice(&[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]), true),
|
||||
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
|
||||
Section("Labels and Colors"),
|
||||
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
|
||||
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, false),
|
||||
s("appearance.cycleMaskColors", "Cycle Mask Colors", B, false),
|
||||
s("appearance.useGradients", "Use Gradients", B, false),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "grids",
|
||||
title: "Grids & Guides",
|
||||
items: vec![
|
||||
Section("Grid"),
|
||||
s("grids.gridColor", "Color", Kind::Color, true),
|
||||
s("grids.gridSpacing", "Gridline Every", Kind::Float(1.0, 10_000.0, "px"), true),
|
||||
s("grids.gridSubdivisions", "Subdivisions", Kind::Int(1, 100, ""), true),
|
||||
s("grids.gridStyle", "Style", Kind::Choice(STYLES), false),
|
||||
Section("Proportional Grid"),
|
||||
s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), false),
|
||||
s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), false),
|
||||
Section("Guides"),
|
||||
s("grids.guideColor", "Color", Kind::Color, true),
|
||||
s("grids.guideStyle", "Style", Kind::Choice(STYLES), false),
|
||||
Section("Safe Margins"),
|
||||
s("grids.actionSafe", "Action-safe", Kind::Float(0.0, 50.0, "%"), true),
|
||||
s("grids.titleSafe", "Title-safe", Kind::Float(0.0, 50.0, "%"), true),
|
||||
],
|
||||
},
|
||||
Page { id: "labels", title: "Labels", items: vec![Labels] },
|
||||
Page {
|
||||
id: "type",
|
||||
title: "Type",
|
||||
items: vec![
|
||||
s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), false),
|
||||
s("type.fontPreview", "Show Font Preview", B, false),
|
||||
s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), false),
|
||||
s("type.fontNamesInEnglish", "Show Font Names in English", B, false),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "import",
|
||||
title: "Import",
|
||||
items: vec![
|
||||
s("import.stillFootage", "Still Footage", Kind::Choice(&[("Length of Composition", "compLength"), ("Duration (Seconds)", "seconds")]), true),
|
||||
s("import.stillSeconds", "Still Duration", Kind::Float(0.04, 86_400.0, "s"), true),
|
||||
s("import.sequenceFps", "Sequence Footage", Kind::Float(1.0, 999.0, "frames per second"), true),
|
||||
s("import.reportMissingFrames", "Report Missing Frames", B, false),
|
||||
s(
|
||||
"import.unlabeledAlpha",
|
||||
"Interpret Unlabeled Alpha As",
|
||||
Kind::Choice(&[
|
||||
("Ask User", "ask"),
|
||||
("Guess", "guess"),
|
||||
("Ignore", "ignore"),
|
||||
("Straight", "straight"),
|
||||
("Premultiplied", "premultiplied"),
|
||||
]),
|
||||
false,
|
||||
),
|
||||
s(
|
||||
"import.dragImportAs",
|
||||
"Default Drag Import As",
|
||||
Kind::Choice(&[("Footage", "footage"), ("Composition", "comp"), ("Composition - Retain Layer Sizes", "compLayerSizes")]),
|
||||
false,
|
||||
),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "export",
|
||||
title: "Export",
|
||||
items: vec![
|
||||
s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, false),
|
||||
s("export.segmentSequences", "Segment Sequences", B, false),
|
||||
s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), false),
|
||||
s("export.segmentMovies", "Segment Movie Files", B, false),
|
||||
s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), false),
|
||||
s("export.appendBitsToName", "Append Bit Depth to File Name", B, false),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "audio",
|
||||
title: "Audio",
|
||||
items: vec![
|
||||
Section("Audio Hardware"),
|
||||
AudioDevices { key: "audio.outputDevice" },
|
||||
s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), false),
|
||||
Section("Audio Output Mapping"),
|
||||
s("audio.outputLeft", "Left", Kind::Int(1, 64, "channel"), true),
|
||||
s("audio.outputRight", "Right", Kind::Int(1, 64, "channel"), true),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "disk",
|
||||
title: "Disk",
|
||||
items: vec![
|
||||
Section("Disk Cache"),
|
||||
s("disk.diskCacheEnabled", "Enable Disk Cache", B, false),
|
||||
s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), false),
|
||||
s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, false),
|
||||
Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# },
|
||||
Section("Media Cache"),
|
||||
s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, false),
|
||||
s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, false),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "memory",
|
||||
title: "Memory & CPU",
|
||||
items: vec![
|
||||
s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), false),
|
||||
Section("Cache Budgets"),
|
||||
s("memory.layerCacheMb", "Layer Cache", Kind::Int(64, 1 << 20, "MB"), true),
|
||||
s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true),
|
||||
s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true),
|
||||
s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, false),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "video",
|
||||
title: "Video",
|
||||
items: vec![
|
||||
s("video.enableOutput", "Enable Video Preview Output", B, false),
|
||||
s("video.device", "Video Device", Kind::Text, false),
|
||||
s("video.outputDuringPlayback", "Video Output During Playback", B, false),
|
||||
s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, false),
|
||||
s("video.disableWhenBackground", "Disable Video Output When in Background", B, false),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "3d",
|
||||
title: "3D",
|
||||
items: vec![
|
||||
s("threeD.defaultRenderer", "Default 3D Renderer", Kind::Choice(&[("Classic 3D", "classic"), ("Advanced 3D", "advanced")]), true),
|
||||
s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, false),
|
||||
s("threeD.extendedViewer", "Extended Viewer", B, false),
|
||||
s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, false),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "scripting",
|
||||
title: "Scripting & Expressions",
|
||||
items: vec![
|
||||
Section("Application Scripting"),
|
||||
s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, false),
|
||||
s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, false),
|
||||
s("scripting.enableJsDebugger", "Enable JavaScript Debugger", B, false),
|
||||
Section("Expressions Editor"),
|
||||
s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), false),
|
||||
s("scripting.syntaxHighlighting", "Syntax Highlighting", B, false),
|
||||
s("scripting.lineNumbers", "Line Numbers", B, false),
|
||||
s("scripting.autoComplete", "Auto-complete", B, false),
|
||||
s("scripting.bracketMatching", "Bracket Matching", B, false),
|
||||
s("scripting.wordWrap", "Word Wrap", B, false),
|
||||
s("scripting.errorBanner", "Show Expression Error Banner", B, false),
|
||||
Note("Expressions use the JavaScript engine."),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Keys of every setting that doesn't change behaviour yet (the TODO list in
|
||||
/// `docs/preferences.md`).
|
||||
pub fn todo_keys() -> Vec<&'static str> {
|
||||
pages()
|
||||
.into_iter()
|
||||
.flat_map(|p| p.items)
|
||||
.filter_map(|i| match i {
|
||||
Item::Setting { key, live: false, .. } => Some(key),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The schema as JSON (for `prefs.pages`).
|
||||
pub fn pages_json() -> Value {
|
||||
let kind = |k: &Kind| -> Value {
|
||||
match k {
|
||||
Kind::Bool => json!({"type": "bool"}),
|
||||
Kind::Int(a, b, u) => json!({"type": "int", "min": a, "max": b, "unit": u}),
|
||||
Kind::Float(a, b, u) => json!({"type": "number", "min": a, "max": b, "unit": u}),
|
||||
Kind::Choice(o) => json!({"type": "choice", "options": o.iter().map(|(l, v)| json!({"label": l, "value": v})).collect::<Vec<_>>()}),
|
||||
Kind::Text => json!({"type": "text"}),
|
||||
Kind::Path => json!({"type": "path"}),
|
||||
Kind::Color => json!({"type": "color"}),
|
||||
Kind::Slider(a, b) => json!({"type": "slider", "min": a, "max": b}),
|
||||
}
|
||||
};
|
||||
let pages: Vec<Value> = pages()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let items: Vec<Value> = p
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
Item::Section(t) => json!({"section": t}),
|
||||
Item::Setting { key, label, kind: k, live } => {
|
||||
let mut o = kind(k);
|
||||
o["key"] = json!(key);
|
||||
o["label"] = json!(label);
|
||||
o["live"] = json!(live);
|
||||
o
|
||||
}
|
||||
Item::Button { label, command, params } => {
|
||||
json!({"button": label, "command": command, "params": serde_json::from_str::<Value>(params).unwrap_or_default()})
|
||||
}
|
||||
Item::Labels => json!({"labels": "labels.N.name / labels.N.color (N = 0..15)"}),
|
||||
Item::AudioDevices { key } => json!({"type": "device", "key": key, "label": "Default Output"}),
|
||||
Item::Note(t) => json!({"note": t}),
|
||||
})
|
||||
.collect();
|
||||
json!({"id": p.id, "title": p.title, "items": items})
|
||||
})
|
||||
.collect();
|
||||
json!(pages)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
//! Session side of Settings.
|
||||
|
||||
use crate::Session;
|
||||
use crate::prefs::{PREFS_FILE, Prefs};
|
||||
|
||||
impl Session {
|
||||
/// Load settings from the config store and apply them.
|
||||
pub fn load_settings(&mut self) {
|
||||
if let Some(c) = &self.config
|
||||
&& let Some(t) = c.read(PREFS_FILE)
|
||||
{
|
||||
self.prefs = Prefs::from_json(&t);
|
||||
}
|
||||
self.prefs_changed();
|
||||
}
|
||||
|
||||
/// Settings changed: apply what the engine owns (cache budgets) and tell frontends.
|
||||
pub fn prefs_changed(&mut self) {
|
||||
self.prefs.normalize();
|
||||
self.layer_cache.set_budget(self.prefs.layer_cache_bytes());
|
||||
self.footage.set_cache_budget(self.prefs.media_cache_bytes());
|
||||
// Fewer undo levels apply right away.
|
||||
let levels = self.prefs.general.undo_levels.max(1) as usize;
|
||||
if self.history.undo.len() > levels {
|
||||
let extra = self.history.undo.len() - levels;
|
||||
self.history.undo.drain(..extra);
|
||||
}
|
||||
self.prefs_revision += 1;
|
||||
}
|
||||
|
||||
/// Write settings to the config store.
|
||||
pub fn save_prefs(&self) {
|
||||
if let Some(c) = &self.config
|
||||
&& let Err(e) = c.write(PREFS_FILE, &self.prefs.to_json())
|
||||
{
|
||||
log::warn!("saving settings: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
//! Settings.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::config::{ConfigStore, MemoryConfig};
|
||||
use crate::prefs::{PREFS_FILE, Prefs};
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let d = std::env::temp_dir().join(format!("ec-settings-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn session_with_comp() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- prefs model
|
||||
|
||||
#[test]
|
||||
fn prefs_round_trip_through_json() {
|
||||
let mut p = Prefs::default();
|
||||
p.set("general.undoLevels", json!(12)).unwrap();
|
||||
p.set("labels.2.name", json!("Teal")).unwrap();
|
||||
p.set("autoSave.location", json!("custom")).unwrap();
|
||||
p.set("appearance.brightness", json!(0.25)).unwrap();
|
||||
p.push_recent("/a/b.ecproj");
|
||||
let back = Prefs::from_json(&p.to_json());
|
||||
assert_eq!(back, p);
|
||||
assert_eq!(Prefs::from_json(&Prefs::default().to_json()), Prefs::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_migrate_old_layout_and_keep_unknown_keys() {
|
||||
let old = r#"{
|
||||
"undoLevels": 50, "autoSaveMinutes": 7, "autoSaveVersions": 3, "theme": "light",
|
||||
"labelNames": ["Hot", "Sun"],
|
||||
"futureSetting": {"x": 1},
|
||||
"general": {"showToolTips": false, "notYetKnown": true},
|
||||
"memory": {"layerCacheMb": "not a number"}
|
||||
}"#;
|
||||
let p = Prefs::from_json(old);
|
||||
assert_eq!(p.version, crate::prefs::PREFS_VERSION);
|
||||
assert_eq!(p.general.undo_levels, 50);
|
||||
assert!(!p.general.show_tool_tips);
|
||||
assert_eq!(p.auto_save.interval_minutes, 7);
|
||||
assert_eq!(p.auto_save.max_versions, 3);
|
||||
assert_eq!(p.appearance.theme, "light");
|
||||
assert_eq!(p.labels[0].name, "Hot");
|
||||
assert_eq!(p.labels[1].name, "Sun");
|
||||
assert_eq!(p.labels[2].name, "Aqua");
|
||||
assert_eq!(p.labels.len(), 16);
|
||||
// A bad value falls back to that page's defaults; other pages survive.
|
||||
assert_eq!(p.memory.layer_cache_mb, 1024);
|
||||
// Unknown keys (a newer version's) are kept and written back.
|
||||
let again: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
|
||||
assert_eq!(again["futureSetting"], json!({"x": 1}));
|
||||
assert_eq!(again["general"]["notYetKnown"], json!(true));
|
||||
assert!(again.get("undoLevels").is_none(), "migrated keys leave the top level");
|
||||
// Garbage is defaults.
|
||||
assert_eq!(Prefs::from_json("not json"), Prefs::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_set_validates_keys_types_and_ranges() {
|
||||
let mut p = Prefs::default();
|
||||
assert!(p.set("general.nope", json!(1)).is_err());
|
||||
assert!(p.set("general.showToolTips", json!([1])).is_err());
|
||||
p.set("general.undoLevels", json!(500)).unwrap();
|
||||
assert_eq!(p.general.undo_levels, 99);
|
||||
p.set("general.undoLevels", json!("7")).unwrap();
|
||||
assert_eq!(p.general.undo_levels, 7);
|
||||
p.set("labels.0.color", json!("zzz")).unwrap();
|
||||
assert_eq!(p.labels[0].color, Prefs::default().labels[0].color);
|
||||
p.reset(Some("general")).unwrap();
|
||||
assert_eq!(p.general.undo_levels, 32);
|
||||
assert!(p.reset(Some("bogus")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_schema_key_exists_and_docs_list_the_todo_settings() {
|
||||
let p = Prefs::default();
|
||||
let mut ids = vec![];
|
||||
for page in crate::prefs::pages() {
|
||||
assert!(crate::prefs::page_id(page.id).is_some(), "page {}", page.id);
|
||||
ids.push(page.id);
|
||||
for item in page.items {
|
||||
if let crate::prefs::Item::Setting { key, .. } | crate::prefs::Item::AudioDevices { key } = item {
|
||||
assert!(p.get(key).is_some(), "schema key `{key}` is not a setting");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Settings menu and the dialog list the same pages.
|
||||
for (path, e) in crate::menus::entries() {
|
||||
if e.command == "app.settings" {
|
||||
let page = e.params["page"].as_str().unwrap();
|
||||
assert!(ids.contains(&page), "{path:?} opens unknown page {page}");
|
||||
}
|
||||
}
|
||||
let docs = include_str!("../../../docs/preferences.md");
|
||||
for k in crate::prefs::todo_keys() {
|
||||
assert!(docs.contains(&format!("`{k}`")), "docs/preferences.md must list the not-yet-wired setting `{k}`");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- prefs effects
|
||||
|
||||
#[test]
|
||||
fn prefs_commands_get_set_reset_and_persist() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = Session { config: Some(store.clone()), ..Default::default() };
|
||||
s.execute_checked("prefs.set", json!({"key": "general.recentItems", "value": 4})).unwrap();
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "general.recentItems"})).unwrap(), json!(4));
|
||||
s.execute("prefs.set", json!({"values": {"autoSave.maxVersions": 9, "grids.gridSpacing": 50}})).unwrap();
|
||||
assert!(s.execute("prefs.set", json!({"key": "nope.nope", "value": 1})).is_err());
|
||||
let saved = store.read(PREFS_FILE).unwrap();
|
||||
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
|
||||
s2.load_settings();
|
||||
assert_eq!(s2.prefs.general.recent_items, 4);
|
||||
assert_eq!(s2.prefs.auto_save.max_versions, 9);
|
||||
assert!(saved.contains("\"maxVersions\": 9"));
|
||||
s2.execute("prefs.reset", json!({"page": "project"})).unwrap();
|
||||
assert_eq!(s2.prefs.auto_save.max_versions, 5);
|
||||
assert!(s2.execute("prefs.pages", json!({})).unwrap().as_array().unwrap().len() >= 17);
|
||||
s2.execute("prefs.open", json!({"page": "Auto-Save"})).unwrap();
|
||||
assert!(
|
||||
s2.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, params } if command == "app.settings" && params["page"] == "project"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_levels_are_honoured() {
|
||||
let mut s = session_with_comp();
|
||||
s.execute("prefs.set", json!({"key": "general.undoLevels", "value": 3})).unwrap();
|
||||
for i in 0..6 {
|
||||
s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), 3);
|
||||
for _ in 0..3 {
|
||||
assert!(s.undo());
|
||||
}
|
||||
assert!(!s.undo());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 3);
|
||||
// Lowering the setting trims the history right away.
|
||||
s.execute("prefs.set", json!({"key": "general.undoLevels", "value": 1})).unwrap();
|
||||
assert!(s.history.undo.len() <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_labels_show_in_the_label_menu_and_apply_by_name() {
|
||||
let mut s = session_with_comp();
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "labels.0.name", "value": "Hot Sauce"})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "labels.0.color", "value": "#102030"})).unwrap();
|
||||
let red = crate::menus::entries().into_iter().find(|(_, e)| e.command == "edit.label" && e.params["label"] == "Red").unwrap().1;
|
||||
assert_eq!(crate::menus::entry_label(&s, red), "Hot Sauce");
|
||||
assert_eq!(s.prefs.label_rgb(effectcraft_color::Label::Red), [0x10, 0x20, 0x30]);
|
||||
s.execute("edit.label", json!({"label": "Yellow"})).unwrap();
|
||||
s.execute("edit.label", json!({"label": "hot sauce"})).unwrap();
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
assert_eq!(l.label, effectcraft_color::Label::Red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_budgets_are_applied() {
|
||||
let mut s = Session::default();
|
||||
s.execute("prefs.set", json!({"key": "memory.layerCacheMb", "value": 128})).unwrap();
|
||||
assert_eq!(s.layer_cache.budget(), 128 << 20);
|
||||
struct Src(std::sync::Mutex<usize>);
|
||||
impl effectcraft_render::FootageSource for Src {
|
||||
fn frame(&self, _: effectcraft_project::ItemId, _: &effectcraft_project::Footage, _: effectcraft_time::Tick) -> Option<Arc<effectcraft_raster::Image>> {
|
||||
None
|
||||
}
|
||||
fn set_cache_budget(&self, b: usize) {
|
||||
*self.0.lock().unwrap() = b;
|
||||
}
|
||||
}
|
||||
let src = Arc::new(Src(Default::default()));
|
||||
s.footage = src.clone();
|
||||
s.execute("prefs.set", json!({"key": "memory.mediaCacheMb", "value": 256})).unwrap();
|
||||
assert_eq!(*src.0.lock().unwrap(), 256 << 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layers_start_at_the_current_time_when_asked() {
|
||||
let mut s = session_with_comp();
|
||||
let nested = s.execute("comp.new", json!({"name": "Nested", "open": false})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.set_time(effectcraft_time::Tick::from_seconds_f64(2.0));
|
||||
s.execute("layer.addItem", json!({"item": nested})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].start_time, effectcraft_time::Tick::ZERO);
|
||||
s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": nested})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].start_time.seconds().round(), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_spatial_interpolation_linear() {
|
||||
let mut s = session_with_comp();
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
s.execute("prop.set", json!({"path": "transform/position", "value": [0, 0], "time": 0})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"path": "transform/position", "value": true})).ok();
|
||||
s.execute("prop.addKey", json!({"path": "transform/position", "time": 0})).unwrap();
|
||||
let auto =
|
||||
|s: &Session| s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().keys.iter().map(|k| k.spatial_auto).collect::<Vec<_>>();
|
||||
assert!(auto(&s).iter().all(|a| *a));
|
||||
s.execute("prefs.set", json!({"key": "general.defaultSpatialLinear", "value": true})).unwrap();
|
||||
s.execute("prop.addKey", json!({"path": "transform/position", "time": 1, "value": [100, 50]})).unwrap();
|
||||
assert_eq!(auto(&s), vec![true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_project_template_and_default_renderer() {
|
||||
let d = tmp("template");
|
||||
let tpl = d.join("tpl.ecproj");
|
||||
let mut a = session_with_comp();
|
||||
a.execute("file.saveAs", json!({"path": tpl.to_string_lossy()})).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("prefs.set", json!({"values": {"project.useTemplate": true, "project.templatePath": tpl.to_string_lossy()}})).unwrap();
|
||||
s.execute("file.newProject", json!({})).unwrap();
|
||||
assert_eq!(s.project.comps().count(), 1);
|
||||
assert!(s.path.is_none(), "a template opens untitled");
|
||||
s.execute("prefs.set", json!({"key": "threeD.defaultRenderer", "value": "advanced"})).unwrap();
|
||||
s.execute("comp.new", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
|
@ -451,6 +451,12 @@ impl Inner {
|
|||
}
|
||||
|
||||
impl FootageSource for MediaPool {
|
||||
fn set_cache_budget(&self, bytes: usize) {
|
||||
self.set_budget(bytes);
|
||||
}
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
Some(self.budget())
|
||||
}
|
||||
fn frame(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
if footage.missing {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -112,6 +112,29 @@ impl LayerCache {
|
|||
}
|
||||
}
|
||||
|
||||
/// The memory budget in bytes.
|
||||
pub fn budget(&self) -> usize {
|
||||
self.inner.lock().map(|g| g.budget).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Change the memory budget (evicts least-recently used entries when shrinking).
|
||||
pub fn set_budget(&self, budget: usize) {
|
||||
let Ok(mut g) = self.inner.lock() else { return };
|
||||
g.budget = budget;
|
||||
if g.bytes > budget {
|
||||
let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect();
|
||||
by_age.sort_unstable();
|
||||
for (_, k) in by_age {
|
||||
if g.bytes <= budget {
|
||||
break;
|
||||
}
|
||||
if let Some(e) = g.map.remove(&k) {
|
||||
g.bytes -= e.bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut g) = self.inner.lock() {
|
||||
g.map.clear();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ pub trait FootageSource: Send + Sync {
|
|||
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
|
||||
/// a cache ignore it.
|
||||
fn set_cache_budget(&self, _bytes: usize) {}
|
||||
/// The decoded-frame cache budget in bytes, if the source has a cache.
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]).
|
||||
|
|
|
|||
|
|
@ -38,7 +38,22 @@ pub trait AudioDevice: Send {
|
|||
}
|
||||
|
||||
/// Opens the default output device (`None` when there is none).
|
||||
pub type AudioDeviceFactory = Box<dyn Fn() -> Option<Box<dyn AudioDevice>>>;
|
||||
pub type AudioDeviceFactory = Box<dyn Fn(&AudioOutput) -> Option<Box<dyn AudioDevice>>>;
|
||||
|
||||
/// Which output to open (Settings ▸ Audio): device name (empty = the system default) and the
|
||||
/// 1-based device channels for left and right.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AudioOutput {
|
||||
pub device: String,
|
||||
pub left: u32,
|
||||
pub right: u32,
|
||||
}
|
||||
|
||||
impl AudioOutput {
|
||||
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> AudioOutput {
|
||||
AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right }
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock-protected sample queue between the feeder thread and the device callback.
|
||||
#[derive(Default)]
|
||||
|
|
|
|||
|
|
@ -129,6 +129,24 @@ impl Frames {
|
|||
v
|
||||
}
|
||||
|
||||
/// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest
|
||||
/// frames when it shrinks.
|
||||
pub fn set_budget(&self, bytes: usize) {
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
c.budget = bytes;
|
||||
while c.bytes > c.budget {
|
||||
let Some(old) = c.order.pop_front() else { break };
|
||||
if let Some(i) = c.map.remove(&old) {
|
||||
c.bytes -= i.pixels.len() * 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn budget(&self) -> usize {
|
||||
self.cache.lock().map(|c| c.budget).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
c.map.clear();
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ pub struct Hooks {
|
|||
pub pick_open_project: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
/// Opens the audio output for preview playback (the desktop app uses cpal).
|
||||
pub audio_device: Option<audio::AudioDeviceFactory>,
|
||||
/// Lists audio output devices (Settings ▸ Audio).
|
||||
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
|
||||
/// Picks a folder (Settings paths).
|
||||
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -122,6 +126,8 @@ pub struct EffectcraftApp {
|
|||
pub(crate) waveforms: panels::waveform::Cache,
|
||||
/// Last reveal shortcut and when (double-press shortcuts such as LL).
|
||||
pub(crate) last_reveal: Option<(String, f64)>,
|
||||
/// Settings revision applied to the theme, tooltips and caches.
|
||||
applied_prefs: Option<u64>,
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
|
|
@ -158,18 +164,48 @@ impl EffectcraftApp {
|
|||
meter: Default::default(),
|
||||
waveforms: Default::default(),
|
||||
last_reveal: None,
|
||||
applied_prefs: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
|
||||
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
|
||||
if self.applied_prefs == Some(self.session.prefs_revision) {
|
||||
return;
|
||||
}
|
||||
self.applied_prefs = Some(self.session.prefs_revision);
|
||||
let p = &self.session.prefs;
|
||||
self.ui.theme = theme::ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
|
||||
self.tokens = Tokens::from_prefs(p);
|
||||
theme::apply_visuals(ctx, &self.tokens);
|
||||
let tips = p.general.show_tool_tips;
|
||||
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
|
||||
self.ui.cache_when_idle = p.previews.cache_frames_when_idle;
|
||||
self.ui.viewer.fast_preview = p.previews.fast_previews;
|
||||
self.frames.set_budget(p.preview_cache_bytes());
|
||||
}
|
||||
|
||||
/// Change settings from the UI (applied next frame and saved).
|
||||
pub fn set_pref(&mut self, key: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
self.session.prefs.set(key, value)?;
|
||||
self.session.prefs_changed();
|
||||
self.session.save_prefs();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
self.control_rx = Some(rx);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) {
|
||||
self.ui.theme = k;
|
||||
self.tokens = Tokens::for_kind(k);
|
||||
theme::apply_visuals(ctx, &self.tokens);
|
||||
let name = match k {
|
||||
theme::ThemeKind::Dark => "dark",
|
||||
theme::ThemeKind::Darker => "darker",
|
||||
theme::ThemeKind::Light => "light",
|
||||
};
|
||||
let _ = self.set_pref("appearance.theme", json!(name));
|
||||
self.apply_prefs(ctx);
|
||||
}
|
||||
|
||||
pub fn set_workspace(&mut self, name: &str) {
|
||||
|
|
@ -203,8 +239,10 @@ impl EffectcraftApp {
|
|||
/// The render scale used by the viewer right now.
|
||||
pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 {
|
||||
if self.playback.playing && self.ui.viewer.res == state::Resolution::Auto {
|
||||
// Keep previews snappy: never more than the displayed size.
|
||||
return self.ui.viewer.res.scale(zoom, ppp).min(0.5);
|
||||
// Keep previews snappy: half the displayed size, but no lower than Settings ▸
|
||||
// Previews ▸ Adaptive Resolution Limit.
|
||||
let full = self.ui.viewer.res.scale(zoom, ppp);
|
||||
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
|
||||
}
|
||||
self.ui.viewer.res.scale(zoom, ppp)
|
||||
}
|
||||
|
|
@ -285,7 +323,8 @@ impl EffectcraftApp {
|
|||
if !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) {
|
||||
return;
|
||||
}
|
||||
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f()) else { return };
|
||||
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
|
||||
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
|
||||
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop) {
|
||||
Ok(a) => self.audio = Some(a),
|
||||
Err(e) => log::warn!("audio preview: {e}"),
|
||||
|
|
@ -536,6 +575,7 @@ impl EffectcraftApp {
|
|||
self.session.poll_render();
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
self.apply_prefs(&ctx);
|
||||
self.handle_events(&ctx);
|
||||
if let Some(rx) = self.command_inbox.take() {
|
||||
while let Ok(id) = rx.try_recv() {
|
||||
|
|
|
|||
|
|
@ -193,7 +193,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
}
|
||||
"view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins,
|
||||
"view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid,
|
||||
"view.fastPreviews" => app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview,
|
||||
"view.fastPreviews" => {
|
||||
let on = !app.ui.viewer.fast_preview;
|
||||
app.set_pref("previews.fastPreviews", json!(on))?;
|
||||
app.ui.viewer.fast_preview = on;
|
||||
}
|
||||
"timeline.zoomIn" | "timeline.zoomOut" => {
|
||||
let k = if id == "timeline.zoomIn" { 1.5 } else { 1.0 / 1.5 };
|
||||
crate::panels::timeline::zoom(app, ctx, k);
|
||||
|
|
@ -310,8 +314,21 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
Value::Null
|
||||
}
|
||||
"app.settings" => {
|
||||
app.dialog_state.settings_page = p.get("page").and_then(Value::as_str).unwrap_or("general").to_string();
|
||||
app.dialog = Some(crate::Dialog::Settings);
|
||||
let page = p.get("page").and_then(Value::as_str).unwrap_or("general");
|
||||
crate::panels::settings::open(app, effectcraft_engine::prefs::page_id(page).unwrap_or("general"));
|
||||
Value::Null
|
||||
}
|
||||
"app.gpuInfo" => {
|
||||
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
crate::panels::dialogs::info(
|
||||
app,
|
||||
"GPU Information",
|
||||
&format!(
|
||||
"Compositing: CPU, {threads} threads (pure Rust, rayon).\nDisplay: egui on wgpu.\nLayer cache: {} MB • Preview cache: {} MB.",
|
||||
app.session.layer_cache.budget() >> 20,
|
||||
app.frames.budget() >> 20
|
||||
),
|
||||
);
|
||||
Value::Null
|
||||
}
|
||||
"app.hide" => {
|
||||
|
|
@ -349,7 +366,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
app.toggle_play(now);
|
||||
json!({"playing": app.playback.playing})
|
||||
}
|
||||
"playback.cacheWhenIdle" => toggle(&mut app.ui.cache_when_idle, &p),
|
||||
"playback.cacheWhenIdle" => {
|
||||
let r = toggle(&mut app.ui.cache_when_idle, &p);
|
||||
app.set_pref("previews.cacheFramesWhenIdle", r.clone())?;
|
||||
r
|
||||
}
|
||||
"playback.audio" => {
|
||||
let r = toggle(&mut app.ui.preview_audio, &p);
|
||||
if !app.ui.preview_audio {
|
||||
|
|
@ -559,7 +580,7 @@ fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
|
|||
match e.command.as_str() {
|
||||
"edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()),
|
||||
"edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()),
|
||||
_ => e.label.clone(),
|
||||
_ => effectcraft_engine::menus::entry_label(&app.session, e),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -771,6 +792,13 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
for (key, mods) in events {
|
||||
// Escape closes dialogs.
|
||||
if key == egui::Key::Escape && app.dialog.is_some() {
|
||||
// Escape in Settings is Cancel: restore the settings from when it opened.
|
||||
if app.dialog == Some(crate::Dialog::Settings)
|
||||
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
|
||||
{
|
||||
app.session.prefs = p;
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
app.dialog = None;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -888,6 +916,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_label_shows_in_the_label_menu() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.set_pref("labels.1.name", json!("Sunflower")).unwrap();
|
||||
let labels: Vec<String> = menu_items(&app).into_iter().filter(|i| i.id == "edit.label").map(|i| i.label).collect();
|
||||
assert!(labels.contains(&"Sunflower".to_string()), "{labels:?}");
|
||||
assert!(!labels.contains(&"Yellow".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_items_cover_the_tree() {
|
||||
assert_eq!(menus().last(), Some(&"Help"));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ pub struct DialogState {
|
|||
pub palette_sel: usize,
|
||||
/// Settings dialog page id (`general`, `appearance`…).
|
||||
pub settings_page: String,
|
||||
/// Settings when the dialog opened (Cancel restores them).
|
||||
pub prefs_snapshot: Option<effectcraft_engine::prefs::Prefs>,
|
||||
/// The open parameter form.
|
||||
pub form: super::forms::Form,
|
||||
/// Message box (title, body).
|
||||
|
|
@ -98,7 +100,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings),
|
||||
Dialog::SolidSettings => solid(app, ctx, &t),
|
||||
Dialog::CommandPalette => palette(app, ctx, &t),
|
||||
Dialog::Settings => super::forms::show_settings(app, ctx, &t),
|
||||
Dialog::Settings => super::settings::show(app, ctx, &t),
|
||||
Dialog::Shortcuts => super::forms::show_shortcuts(app, ctx, &t),
|
||||
Dialog::Form => super::forms::show_form(app, ctx, &t),
|
||||
Dialog::Info => super::forms::show_info(app, ctx, &t),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Menu dialogs: a generic parameter form (numeric Transform dialogs, Mask Feather/Opacity/
|
||||
//! Expansion, Auto-Orient, Go to Time, Add Guide, Sequence Layers, Interpret Footage, Project
|
||||
//! Settings, placeholders…), Settings (Preferences), Keyboard Shortcuts, View Options and simple
|
||||
//! message boxes. Every form runs an engine command with the collected parameters, so whatever a
|
||||
//! Settings, placeholders…), Keyboard Shortcuts, View Options and simple message boxes
|
||||
//! (Settings: `settings`). Every form runs an engine command with the collected parameters, so whatever a
|
||||
//! dialog does an agent can do with one `engine.execute`.
|
||||
|
||||
use egui::{Color32, vec2};
|
||||
|
|
@ -359,98 +359,6 @@ pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
|||
}
|
||||
}
|
||||
|
||||
pub const SETTINGS_PAGES: [(&str, &str); 17] = [
|
||||
("general", "General"),
|
||||
("startup", "Startup & Repair"),
|
||||
("project", "Project"),
|
||||
("composition", "Composition"),
|
||||
("previews", "Previews"),
|
||||
("appearance", "Appearance"),
|
||||
("grids", "Grids & Guides"),
|
||||
("labels", "Labels"),
|
||||
("type", "Type"),
|
||||
("import", "Import"),
|
||||
("export", "Export"),
|
||||
("audio", "Audio"),
|
||||
("disk", "Disk"),
|
||||
("memory", "Memory & CPU"),
|
||||
("video", "Video"),
|
||||
("3d", "3D"),
|
||||
("scripting", "Scripting & Expressions"),
|
||||
];
|
||||
|
||||
pub fn show_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut page = app.dialog_state.settings_page.clone();
|
||||
let mut close = false;
|
||||
let mut theme = None;
|
||||
super::dialogs::modal(ctx, "Settings", vec2(640.0, 440.0), t, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(170.0);
|
||||
for (id, label) in SETTINGS_PAGES {
|
||||
if ui.selectable_label(page == id, label).clicked() {
|
||||
page = id.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
let title = SETTINGS_PAGES.iter().find(|p| p.0 == page).map(|p| p.1).unwrap_or("General");
|
||||
ui.label(egui::RichText::new(title).font(Tokens::semibold(13.0)));
|
||||
ui.add_space(8.0);
|
||||
match page.as_str() {
|
||||
"general" => {
|
||||
ui.checkbox(&mut app.ui.show_menu_bar, "Show the in-window menu bar");
|
||||
ui.checkbox(&mut app.ui.snapping, "Snapping");
|
||||
}
|
||||
"appearance" => {
|
||||
for (k, name) in [("dark", "Dark"), ("darker", "Darker"), ("light", "Light")] {
|
||||
if ui.button(name).clicked() {
|
||||
theme = crate::theme::ThemeKind::from_name(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
"previews" => {
|
||||
ui.checkbox(&mut app.ui.cache_when_idle, "Cache frames when idle");
|
||||
ui.checkbox(&mut app.ui.viewer.fast_preview, "Fast previews");
|
||||
}
|
||||
"grids" => {
|
||||
ui.checkbox(&mut app.ui.viewer.grid, "Show grid");
|
||||
ui.checkbox(&mut app.ui.viewer.guides, "Show guides");
|
||||
ui.checkbox(&mut app.ui.viewer.safe_margins, "Show title/action safe");
|
||||
}
|
||||
"labels" => {
|
||||
for l in effectcraft_engine::color::Label::ALL.into_iter().skip(1) {
|
||||
ui.horizontal(|ui| {
|
||||
let [r, g, b] = l.rgb();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(26.0, 14.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(r, g, b));
|
||||
ui.label(l.name());
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(egui::RichText::new("No options on this page yet.").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
app.dialog_state.settings_page = page;
|
||||
if let Some(k) = theme {
|
||||
app.set_theme(ctx, k);
|
||||
}
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut q = app.dialog_state.palette_query.clone();
|
||||
let mut close = false;
|
||||
|
|
|
|||
|
|
@ -83,8 +83,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ly += 18.0;
|
||||
}
|
||||
}
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps", app.fps), Tokens::ui(11.5), t.text_faint);
|
||||
// Settings ▸ Composition ▸ Show Rendering Progress in Info Panel.
|
||||
if app.session.prefs.composition.show_rendering_progress {
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
let rq = app.session.render_job.as_ref().map(|_| " • Rendering queue…").unwrap_or("");
|
||||
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps{rq}", app.fps), Tokens::ui(11.5), t.text_faint);
|
||||
}
|
||||
if !app.session.state.selected_keys.is_empty() {
|
||||
p.text(
|
||||
pos2(x0, ly + 18.0),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod misc;
|
|||
pub mod project;
|
||||
pub mod properties;
|
||||
pub mod render_queue;
|
||||
pub mod settings;
|
||||
pub mod text_panels;
|
||||
pub mod timeline;
|
||||
pub mod viewer;
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
Tokens::ui(12.0),
|
||||
if selected { Color32::WHITE } else { t.text },
|
||||
);
|
||||
lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, Tokens::label(it.label));
|
||||
lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, t.label(it.label));
|
||||
lp.text(pos2(col_type, r.center().y), Align2::LEFT_CENTER, it.type_name(), Tokens::ui(11.5), t.text_dim);
|
||||
if let Some(b) = file_size(&it) {
|
||||
lp.text(pos2(col_size, r.center().y), Align2::LEFT_CENTER, fmt_size(b), Tokens::ui(11.5), t.text_dim);
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
|
||||
let comp_item = app.session.project.item(it.comp);
|
||||
let label_col = comp_item.map(|i| Tokens::label(i.label)).unwrap_or(t.text_faint);
|
||||
let label_col = comp_item.map(|i| t.label(i.label)).unwrap_or(t.text_faint);
|
||||
lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col);
|
||||
lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim);
|
||||
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
//! The Settings dialog (After Effects' Preferences): the page list on the left, the page on the
|
||||
//! right, OK / Cancel / Previous / Next. Pages are built from the engine's schema
|
||||
//! (`effectcraft_engine::prefs::pages`), so the dialog, `prefs.pages` and the docs agree. Edits
|
||||
//! apply live; Cancel restores the settings from when the dialog opened, OK saves them.
|
||||
//!
|
||||
//! Automation ids: `settings.page.<id>`, `settings.<key>` (e.g. `settings.general.undoLevels`),
|
||||
//! `settings.labels.<n>.name` / `.color`, `settings.button.<label>`, `settings.ok`,
|
||||
//! `settings.cancel`, `settings.previous`, `settings.next`.
|
||||
|
||||
use effectcraft_engine::prefs::{Item, Kind, Page, pages};
|
||||
use egui::{Color32, RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Open the dialog on a page, remembering the settings for Cancel.
|
||||
pub fn open(app: &mut EffectcraftApp, page: &str) {
|
||||
if app.dialog != Some(Dialog::Settings) {
|
||||
app.dialog_state.prefs_snapshot = Some(app.session.prefs.clone());
|
||||
}
|
||||
app.dialog_state.settings_page = page.to_string();
|
||||
app.dialog = Some(Dialog::Settings);
|
||||
}
|
||||
|
||||
enum Act {
|
||||
Set(String, Value),
|
||||
Run(String, Value),
|
||||
PickFolder(String),
|
||||
PickProject(String),
|
||||
}
|
||||
|
||||
fn hex(c: [u8; 3]) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
fn reg(app: &mut EffectcraftApp, id: &str, r: &egui::Response, label: &str) {
|
||||
app.auto.add(id, r.rect, label);
|
||||
}
|
||||
|
||||
/// One page's rows.
|
||||
fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value, t: &Tokens, acts: &mut Vec<Act>) {
|
||||
let get = |key: &str| cur.pointer(&format!("/{}", key.replace('.', "/"))).cloned().unwrap_or(Value::Null);
|
||||
let label_w = 250.0;
|
||||
for item in &page.items {
|
||||
match *item {
|
||||
Item::Section(title) => {
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(title).font(Tokens::semibold(12.5)).color(t.tab_text_active));
|
||||
ui.separator();
|
||||
}
|
||||
Item::Note(text) => {
|
||||
ui.label(RichText::new(text).color(t.text_dim));
|
||||
}
|
||||
Item::Button { label, command, params } => {
|
||||
let r = ui.button(label);
|
||||
reg(app, &format!("settings.button.{}", label.trim_end_matches('.')), &r, label);
|
||||
if r.clicked() {
|
||||
acts.push(Act::Run(command.into(), serde_json::from_str(params).unwrap_or(json!({}))));
|
||||
}
|
||||
}
|
||||
Item::Labels => labels_ui(app, ui, cur, acts),
|
||||
Item::AudioDevices { key } => {
|
||||
let devices: Vec<String> = app.hooks.audio_devices.as_ref().map(|f| f()).unwrap_or_default();
|
||||
let sel = get(key).as_str().unwrap_or("").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(label_w, 18.0), egui::Label::new("Default Output:"));
|
||||
let shown = if sel.is_empty() { "System Default".to_string() } else { sel.clone() };
|
||||
let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(sel.is_empty(), "System Default").clicked() {
|
||||
acts.push(Act::Set(key.into(), json!("")));
|
||||
}
|
||||
for d in &devices {
|
||||
if ui.selectable_label(*d == sel, d).clicked() {
|
||||
acts.push(Act::Set(key.into(), json!(d)));
|
||||
}
|
||||
}
|
||||
});
|
||||
reg(app, &format!("settings.{key}"), &r.response, "Default Output");
|
||||
});
|
||||
if devices.is_empty() {
|
||||
ui.label(RichText::new("No device list from this host; the system default output is used.").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
Item::Setting { key, label, kind, live } => {
|
||||
let v = get(key);
|
||||
let id = format!("settings.{key}");
|
||||
let tip = (!live).then_some("Stored, but not used by EffectCraft yet");
|
||||
let mut resp: Option<egui::Response> = None;
|
||||
match kind {
|
||||
Kind::Bool => {
|
||||
let mut b = v.as_bool().unwrap_or(false);
|
||||
let r = ui.checkbox(&mut b, label);
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(b)));
|
||||
}
|
||||
resp = Some(r);
|
||||
}
|
||||
_ => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{label}:")).truncate());
|
||||
let r = match kind {
|
||||
Kind::Int(lo, hi, unit) => {
|
||||
let mut x = v.as_i64().unwrap_or(0);
|
||||
let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25));
|
||||
if !unit.is_empty() {
|
||||
ui.label(RichText::new(unit).color(t.text_dim));
|
||||
}
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(x)));
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Float(lo, hi, unit) => {
|
||||
let mut x = v.as_f64().unwrap_or(0.0);
|
||||
let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3));
|
||||
if !unit.is_empty() {
|
||||
ui.label(RichText::new(unit).color(t.text_dim));
|
||||
}
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(x)));
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Slider(lo, hi) => {
|
||||
let mut x = v.as_f64().unwrap_or(0.0);
|
||||
ui.label(RichText::new("Darker").color(t.text_dim));
|
||||
let r = ui.add(egui::Slider::new(&mut x, lo..=hi).show_value(false));
|
||||
ui.label(RichText::new("Lighter").color(t.text_dim));
|
||||
if ui.small_button("Default").clicked() {
|
||||
acts.push(Act::Set(key.into(), json!(0.0)));
|
||||
}
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(x)));
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Choice(opts) => {
|
||||
let curv = v.as_str().unwrap_or("").to_string();
|
||||
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
|
||||
egui::ComboBox::from_id_salt(("settings", key))
|
||||
.selected_text(shown)
|
||||
.width(220.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (l, val) in opts {
|
||||
if ui.selectable_label(*val == curv, *l).clicked() {
|
||||
acts.push(Act::Set(key.into(), json!(val)));
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
}
|
||||
Kind::Text | Kind::Path => {
|
||||
let mut s = v.as_str().unwrap_or("").to_string();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if kind == Kind::Path { 230.0 } else { 260.0 }));
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(s)));
|
||||
}
|
||||
if kind == Kind::Path {
|
||||
let b = ui.button("Choose...");
|
||||
reg(app, &format!("{id}.choose"), &b, "Choose...");
|
||||
if b.clicked() {
|
||||
acts.push(if key.ends_with("Path") { Act::PickProject(key.into()) } else { Act::PickFolder(key.into()) });
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Color => {
|
||||
let c = crate::panels::viewer::hex_rgb(v.as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
|
||||
let mut rgb = c;
|
||||
let r = ui.color_edit_button_srgb(&mut rgb);
|
||||
ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim));
|
||||
if rgb != c {
|
||||
acts.push(Act::Set(key.into(), json!(hex(rgb))));
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Bool => unreachable!(),
|
||||
};
|
||||
resp = Some(r);
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(r) = resp {
|
||||
let r = match tip {
|
||||
Some(tip) => r.on_hover_text(tip),
|
||||
None => r,
|
||||
};
|
||||
reg(app, &id, &r, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mut Vec<Act>) {
|
||||
ui.label("Label Colors and Names:");
|
||||
ui.add_space(4.0);
|
||||
let labels = cur.get("labels").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
egui::Grid::new("settings-labels").num_columns(4).spacing([10.0, 4.0]).show(ui, |ui| {
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
let c = crate::panels::viewer::hex_rgb(l["color"].as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
|
||||
let mut rgb = c;
|
||||
let r = ui.color_edit_button_srgb(&mut rgb);
|
||||
app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour");
|
||||
if rgb != c {
|
||||
acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb))));
|
||||
}
|
||||
let mut name = l["name"].as_str().unwrap_or("").to_string();
|
||||
let r = ui.add_sized(vec2(160.0, 20.0), egui::TextEdit::singleline(&mut name));
|
||||
app.auto.add(&format!("settings.labels.{i}.name"), r.rect, "label name");
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(format!("labels.{i}.name"), json!(name)));
|
||||
}
|
||||
if i % 2 == 1 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let r = ui.button("Reset Labels");
|
||||
app.auto.add("settings.button.Reset Labels", r.rect, "Reset Labels");
|
||||
if r.clicked() {
|
||||
acts.push(Act::Run("prefs.reset".into(), json!({"page": "labels"})));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let all = pages();
|
||||
let mut page = app.dialog_state.settings_page.clone();
|
||||
if !all.iter().any(|p| p.id == page) {
|
||||
page = "general".into();
|
||||
}
|
||||
let idx = all.iter().position(|p| p.id == page).unwrap_or(0);
|
||||
let cur = serde_json::to_value(&app.session.prefs).unwrap_or_default();
|
||||
let mut acts: Vec<Act> = vec![];
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
super::dialogs::modal(ctx, "Settings", vec2(820.0, 600.0), t, |ui| {
|
||||
let body_h = 470.0;
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), body_h), egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
ui.set_height(body_h);
|
||||
// Page list (left, like After Effects).
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(180.0);
|
||||
for p in &all {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click());
|
||||
let sel = page == p.id;
|
||||
if sel {
|
||||
ui.painter().rect_filled(r, 3.0, t.accent);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(10.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
p.title,
|
||||
Tokens::ui(12.5),
|
||||
if sel { Color32::WHITE } else { t.text },
|
||||
);
|
||||
app.auto.add(&format!("settings.page.{}", p.id), r, p.title);
|
||||
if resp.clicked() {
|
||||
page = p.id.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(590.0);
|
||||
let p = &all[idx];
|
||||
ui.label(RichText::new(p.title).font(Tokens::semibold(14.0)).color(t.tab_text_active));
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical().max_height(body_h - 30.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
page_ui(app, ui, p, &cur, t, &mut acts);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add_enabled(idx > 0, egui::Button::new("Previous"));
|
||||
app.auto.add("settings.previous", r.rect, "Previous");
|
||||
if r.clicked() {
|
||||
page = all[idx - 1].id.to_string();
|
||||
}
|
||||
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new("Next"));
|
||||
app.auto.add("settings.next", r.rect, "Next");
|
||||
if r.clicked() {
|
||||
page = all[idx + 1].id.to_string();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("settings.ok", r.rect, "OK");
|
||||
ok |= r.clicked();
|
||||
let r = ui.button("Cancel");
|
||||
app.auto.add("settings.cancel", r.rect, "Cancel");
|
||||
cancel |= r.clicked();
|
||||
});
|
||||
});
|
||||
});
|
||||
app.dialog_state.settings_page = page;
|
||||
for a in acts {
|
||||
match a {
|
||||
Act::Set(k, v) => {
|
||||
if let Err(e) = app.session.prefs.set(&k, v) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
Act::Run(cmd, params) => {
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, &cmd, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
// Buttons such as Reset Settings change the baseline Cancel returns to.
|
||||
if cmd.starts_with("prefs.") {
|
||||
app.dialog_state.prefs_snapshot = Some(app.session.prefs.clone());
|
||||
}
|
||||
if app.dialog != Some(Dialog::Settings) && app.dialog != Some(Dialog::Info) {
|
||||
app.dialog = Some(Dialog::Settings);
|
||||
}
|
||||
}
|
||||
Act::PickFolder(k) => {
|
||||
if let Some(f) = app.hooks.pick_folder.as_ref().and_then(|f| f()) {
|
||||
let _ = app.session.prefs.set(&k, json!(f));
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
}
|
||||
Act::PickProject(k) => {
|
||||
if let Some(f) = app.hooks.pick_open_project.as_ref().and_then(|f| f()) {
|
||||
let _ = app.session.prefs.set(&k, json!(f));
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel {
|
||||
if let Some(p) = app.dialog_state.prefs_snapshot.take() {
|
||||
app.session.prefs = p;
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
app.dialog = None;
|
||||
} else if ok {
|
||||
app.dialog_state.prefs_snapshot = None;
|
||||
app.session.save_prefs();
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -635,7 +635,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let x = tm.x(m.time.seconds());
|
||||
pr.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(x - 4.0, ruler.max.y - 10.0), pos2(x + 4.0, ruler.max.y - 10.0), pos2(x, ruler.max.y - 4.0)],
|
||||
Tokens::label(m.label),
|
||||
t.label(m.label),
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
|
|
@ -763,11 +763,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
// Label swatch.
|
||||
let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
|
||||
lp.rect_filled(lr, 2.0, Tokens::label(layer.label));
|
||||
lp.rect_filled(lr, 2.0, t.label(layer.label));
|
||||
let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click());
|
||||
lresp.context_menu(|ui| {
|
||||
for lab in effectcraft_engine::color::Label::ALL {
|
||||
if ui.button(lab.name()).clicked() {
|
||||
let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) };
|
||||
if ui.button(name).clicked() {
|
||||
actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()})));
|
||||
ui.close();
|
||||
}
|
||||
|
|
@ -936,7 +937,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let x_in = tm.x(layer.in_point.seconds());
|
||||
let x_out = tm.x(layer.out_point.seconds());
|
||||
let bar = Rect::from_min_max(pos2(x_in, r.min.y + 3.0), pos2(x_out, r.max.y - 3.0));
|
||||
let lc = Tokens::label(layer.label);
|
||||
let lc = t.label(layer.label);
|
||||
let fill = if is_sel { lc.gamma_multiply(0.85) } else { lc.gamma_multiply(0.55) };
|
||||
gp.rect_filled(bar, 2.0, fill);
|
||||
if is_sel {
|
||||
|
|
|
|||
|
|
@ -319,7 +319,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
|
||||
// Guides.
|
||||
if app.ui.viewer.safe_margins {
|
||||
for (k, a) in [(0.9, 120u8), (0.8, 160)] {
|
||||
let g = &app.session.prefs.grids;
|
||||
let (act, title) = (1.0 - g.action_safe as f32 / 100.0, 1.0 - g.title_safe as f32 / 100.0);
|
||||
for (k, a) in [(act, 120u8), (title, 160)] {
|
||||
let r = Rect::from_center_size(comp_rect.center(), comp_rect.size() * k);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_white_alpha(a)), StrokeKind::Middle);
|
||||
}
|
||||
|
|
@ -328,22 +330,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
painter.line_segment([c - vec2(0.0, 10.0), c + vec2(0.0, 10.0)], Stroke::new(1.0, Color32::from_white_alpha(140)));
|
||||
}
|
||||
if app.ui.viewer.grid {
|
||||
let step = 100.0 * zoom;
|
||||
// Settings ▸ Grids & Guides: gridline spacing, subdivisions and colour.
|
||||
let g = &app.session.prefs.grids;
|
||||
let [r, gg, b] = hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]);
|
||||
let major = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 90));
|
||||
let minor = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 35));
|
||||
let step = (g.grid_spacing as f32 * zoom).max(2.0);
|
||||
let sub = g.grid_subdivisions.max(1) as f32;
|
||||
let sstep = step / sub;
|
||||
let draw_minor = sstep >= 4.0;
|
||||
let mut i = 0u32;
|
||||
let mut gx = comp_rect.min.x;
|
||||
while gx <= comp_rect.max.x {
|
||||
painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 160, 255, 50)));
|
||||
gx += step;
|
||||
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
|
||||
if is_major || draw_minor {
|
||||
painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor });
|
||||
}
|
||||
gx += sstep;
|
||||
i += 1;
|
||||
}
|
||||
let mut i = 0u32;
|
||||
let mut gy = comp_rect.min.y;
|
||||
while gy <= comp_rect.max.y {
|
||||
painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 160, 255, 50)));
|
||||
gy += step;
|
||||
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
|
||||
if is_major || draw_minor {
|
||||
painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor });
|
||||
}
|
||||
gy += sstep;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Comp guides (View ▸ Show Guides) and the region of interest.
|
||||
if app.ui.viewer.guides {
|
||||
let stroke = Stroke::new(1.0, Color32::from_rgb(0x3c, 0xc8, 0xf0));
|
||||
let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
|
||||
let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b));
|
||||
for g in &comp.guides {
|
||||
if g.vertical {
|
||||
let x = comp_rect.min.x + g.position as f32 * zoom;
|
||||
|
|
@ -372,8 +393,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id()));
|
||||
}
|
||||
if app.ui.viewer.show_layer_controls {
|
||||
// Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for
|
||||
// Layer Handles and Paths.
|
||||
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
|
||||
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
|
||||
let label_handles = app.session.prefs.appearance.use_label_color_for_handles;
|
||||
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
|
||||
let col = Tokens::label(l.label);
|
||||
let col = if label_handles { t.label(l.label) } else { t.accent };
|
||||
// Motion path of animated position.
|
||||
if let Some(pos) = l.transform().and_then(|tr| tr.get("position"))
|
||||
&& pos.keys.len() > 1
|
||||
|
|
@ -438,10 +464,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
painter.circle_filled(h, 3.0, mc);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m });
|
||||
}
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, mc);
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
|
||||
} else {
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Color32::from_black_alpha(160));
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m });
|
||||
|
|
@ -467,7 +493,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
painter.add(egui::Shape::closed_line(sq.clone(), Stroke::new(1.0, col)));
|
||||
for i in 0..8 {
|
||||
let hp = if i < 4 { sq[i] } else { sq[i - 4] + (sq[(i - 3) % 4] - sq[i - 4]) * 0.5 };
|
||||
let hr = Rect::from_center_size(hp, vec2(HANDLE, HANDLE));
|
||||
let hr = Rect::from_center_size(hp, vec2(hs, hs));
|
||||
painter.rect_filled(hr, 0.0, col);
|
||||
painter.rect_stroke(hr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), StrokeKind::Outside);
|
||||
handle_hits.push((l.id, i, hp));
|
||||
|
|
@ -930,7 +956,7 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap,
|
|||
};
|
||||
for l in ectx.comp.layers.iter().filter(|l| (l.is_camera() || l.is_light()) && l.is_active_at(ectx.time)) {
|
||||
let sel = selected.contains(&l.id);
|
||||
let col = Tokens::label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 });
|
||||
let col = app.tokens.label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 });
|
||||
let stroke = Stroke::new(if sel { 1.5 } else { 1.0 }, col);
|
||||
let (eye, fwd, down) = three_d::camera::layer_frame(ectx, l);
|
||||
let poi = |l: &Layer| -> Option<Vec3> {
|
||||
|
|
@ -1283,3 +1309,13 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
|
||||
}
|
||||
|
||||
/// `#rrggbb` → sRGB bytes.
|
||||
pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
|
||||
Some([p(0)?, p(2)?, p(4)?])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ pub struct Tokens {
|
|||
pub timecode: Color32,
|
||||
pub danger: Color32,
|
||||
pub warning: Color32,
|
||||
/// Label colours (`Label::ALL` order), from Settings ▸ Labels.
|
||||
pub labels: [Color32; 17],
|
||||
pub radius: f32,
|
||||
pub radius_sm: f32,
|
||||
pub gap: f32,
|
||||
|
|
@ -112,6 +114,7 @@ impl Tokens {
|
|||
timecode: Color32::from_rgb(0x3d, 0x8f, 0xf5),
|
||||
danger: Color32::from_rgb(0xe0, 0x4a, 0x3c),
|
||||
warning: Color32::from_rgb(0xe8, 0x9a, 0x2c),
|
||||
labels: default_labels(),
|
||||
radius: 6.0,
|
||||
radius_sm: 3.0,
|
||||
gap: 4.0,
|
||||
|
|
@ -172,11 +175,65 @@ impl Tokens {
|
|||
pub fn medium(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Name("medium".into()))
|
||||
}
|
||||
/// sRGB colour of an item/layer label.
|
||||
pub fn label(l: effectcraft_color::Label) -> Color32 {
|
||||
/// sRGB colour of an item/layer label (Settings ▸ Labels).
|
||||
pub fn label(&self, l: effectcraft_color::Label) -> Color32 {
|
||||
let i = effectcraft_color::Label::ALL.iter().position(|x| *x == l).unwrap_or(0);
|
||||
self.labels[i]
|
||||
}
|
||||
|
||||
/// Theme tokens for the current settings: theme, UI brightness and label colours.
|
||||
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> Tokens {
|
||||
let kind = ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
|
||||
let mut t = Tokens::for_kind(kind).with_brightness(p.appearance.brightness as f32);
|
||||
for (i, l) in effectcraft_color::Label::ALL.iter().enumerate() {
|
||||
let [r, g, b] = p.label_rgb(*l);
|
||||
t.labels[i] = Color32::from_rgb(r, g, b);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Lighten (b > 0) or darken (b < 0) the interface greys, like After Effects' brightness
|
||||
/// slider. Accent colours, labels and keyframe colours are kept.
|
||||
pub fn with_brightness(mut self, b: f32) -> Tokens {
|
||||
let b = b.clamp(-1.0, 1.0);
|
||||
if b.abs() < 1e-3 {
|
||||
return self;
|
||||
}
|
||||
let adj = |c: &mut Color32| {
|
||||
let f = |v: u8| -> u8 {
|
||||
let v = v as f32;
|
||||
(if b > 0.0 { v + (110.0 - v).max(0.0) * b * 0.8 + 10.0 * b } else { v * (1.0 + b * 0.6) }).round().clamp(0.0, 255.0) as u8
|
||||
};
|
||||
*c = Color32::from_rgb(f(c.r()), f(c.g()), f(c.b()));
|
||||
};
|
||||
for c in [
|
||||
&mut self.app_bg,
|
||||
&mut self.header_bg,
|
||||
&mut self.panel_bg,
|
||||
&mut self.hover,
|
||||
&mut self.pressed,
|
||||
&mut self.field_bg,
|
||||
&mut self.field_border,
|
||||
&mut self.separator,
|
||||
&mut self.row,
|
||||
&mut self.row_alt,
|
||||
&mut self.row_selected,
|
||||
&mut self.tl_bg,
|
||||
&mut self.tl_ruler_bg,
|
||||
&mut self.pasteboard,
|
||||
&mut self.work_area,
|
||||
] {
|
||||
adj(c);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn default_labels() -> [Color32; 17] {
|
||||
effectcraft_color::Label::ALL.map(|l| {
|
||||
let [r, g, b] = l.rgb();
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
# Settings
|
||||
|
||||
## Settings (Preferences)
|
||||
|
||||
EffectCraft ▸ Settings (macOS) or Edit ▸ Preferences (Windows/Linux), `Cmd+Alt+;`. The dialog has
|
||||
After Effects 2026's pages (General, Startup & Repair, Project, Composition, Previews, Appearance,
|
||||
Grids & Guides, Labels, Type, Import, Export, Audio, Disk, Memory & CPU, Video, 3D, Scripting &
|
||||
Expressions) with OK, Cancel, Previous and Next. Older page names still open the right page
|
||||
(`prefs.open {"page": "Auto-Save"}` opens Project, `Media & Disk Cache` opens Disk, `Memory &
|
||||
Performance` opens Memory & CPU).
|
||||
|
||||
The model is `effectcraft_engine::prefs::Prefs`: serde, versioned (`version`), addressed by dotted
|
||||
keys (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`). It is stored as
|
||||
`prefs.json` through the session's `ConfigStore` (the desktop app uses the platform config
|
||||
directory: `~/Library/Application Support/EffectCraft` on macOS, `%APPDATA%\EffectCraft` on
|
||||
Windows, `$XDG_CONFIG_HOME/effectcraft` on Linux; the web app can back the same trait with
|
||||
`localStorage`). Loading migrates older layouts, keeps unknown keys (from newer versions) and
|
||||
falls back to defaults for values that don't parse.
|
||||
|
||||
Commands (CLI, MCP, control channel):
|
||||
|
||||
| Command | Parameters |
|
||||
|---|---|
|
||||
| `prefs.get` | `{key?}` (omit for everything) |
|
||||
| `prefs.set` | `{key, value}` or `{values: {key: value}}` |
|
||||
| `prefs.reset` | `{page?}` (omit for everything) |
|
||||
| `prefs.open` | `{page?}` opens the Settings dialog |
|
||||
| `prefs.pages` | the page schema: every row, its key, type, range and whether it is wired |
|
||||
|
||||
### Settings that change behaviour
|
||||
|
||||
- `general.undoLevels`: Levels of Undo
|
||||
- `general.pathPointSize`: Path Point and Handle Size
|
||||
- `general.recentItems`: Recent Projects Shown
|
||||
- `general.showToolTips`: Show Tool Tips
|
||||
- `general.createLayersAtCompStart`: Create Layers at Composition Start Time
|
||||
- `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear
|
||||
- `startup.showHomeOnLaunch`: Show Home Screen When Launching
|
||||
- `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash
|
||||
- `project.useTemplate`: New Project Loads Template
|
||||
- `project.templatePath`: Template Project
|
||||
- `autoSave.enabled`: Automatically Save Projects
|
||||
- `autoSave.intervalMinutes`: Save Every
|
||||
- `autoSave.maxVersions`: Maximum Project Versions
|
||||
- `autoSave.location`: Auto-Save Location
|
||||
- `autoSave.folder`: Custom Location
|
||||
- `autoSave.saveOnRenderStart`: Save When Starting Render Queue
|
||||
- `composition.showRenderingProgress`: Show Rendering Progress in Info Panel and Flowchart
|
||||
- `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit
|
||||
- `previews.cacheFramesWhenIdle`: Cache Frames When Idle
|
||||
- `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects)
|
||||
- `appearance.theme`: Theme
|
||||
- `appearance.brightness`: Brightness
|
||||
- `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths
|
||||
- `grids.gridColor`: Color
|
||||
- `grids.gridSpacing`: Gridline Every
|
||||
- `grids.gridSubdivisions`: Subdivisions
|
||||
- `grids.guideColor`: Color
|
||||
- `grids.actionSafe`: Action-safe
|
||||
- `grids.titleSafe`: Title-safe
|
||||
- `import.stillFootage`: Still Footage
|
||||
- `import.stillSeconds`: Still Duration
|
||||
- `import.sequenceFps`: Sequence Footage
|
||||
- `audio.outputDevice`: Default Output (device list from the desktop host)
|
||||
- `audio.outputLeft`: Left (Audio Output Mapping)
|
||||
- `audio.outputRight`: Right (Audio Output Mapping)
|
||||
- `memory.layerCacheMb`: Layer Cache
|
||||
- `memory.mediaCacheMb`: Footage Frame Cache
|
||||
- `memory.previewCacheMb`: Preview (RAM) Cache
|
||||
- `threeD.defaultRenderer`: Default 3D Renderer
|
||||
- `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu,
|
||||
the timeline, project panel, render queue and viewer handles
|
||||
|
||||
### Not wired yet (TODO)
|
||||
|
||||
These rows are in the dialog for After Effects parity and are stored, but nothing reads them yet.
|
||||
`prefs.pages` reports them with `"live": false`. A test keeps this list in step with the schema.
|
||||
|
||||
- `general.switchesAffectNestedComps`: Switches Affect Nested Comps
|
||||
- `general.preserveConstantVertexCount`: Preserve Constant Vertex and Feather Point Count when Editing Masks
|
||||
- `general.syncTimeRelatedItems`: Synchronize Time of All Related Items
|
||||
- `general.expressionPickWhipCompact`: Expression Pick Whip Writes Compact English
|
||||
- `general.createSplitLayersAbove`: Create Split Layers Above Original Layer
|
||||
- `general.useSystemColorPicker`: Use System Color Picker
|
||||
- `startup.showHomeOnOpenProject`: Show Home Screen When Opening a Project
|
||||
- `composition.motionPath`: Motion Path
|
||||
- `composition.motionPathSeconds`: Seconds
|
||||
- `composition.motionPathKeyframes`: Keyframes
|
||||
- `composition.hardwareAcceleratePanels`: Hardware Accelerate Composition, Layer and Footage Panels
|
||||
- `previews.showInternalWireframes`: Show Internal Wireframes
|
||||
- `previews.zoomQuality`: Viewer Zoom Quality
|
||||
- `appearance.useLabelColorForTabs`: Use Label Color for Related Tabs
|
||||
- `appearance.cycleMaskColors`: Cycle Mask Colors
|
||||
- `appearance.useGradients`: Use Gradients
|
||||
- `grids.gridStyle`: Style
|
||||
- `grids.proportionalHorizontal`: Horizontal
|
||||
- `grids.proportionalVertical`: Vertical
|
||||
- `grids.guideStyle`: Style
|
||||
- `type.textEngine`: Text Engine
|
||||
- `type.fontPreview`: Show Font Preview
|
||||
- `type.recentFonts`: Number of Recent Fonts to Display
|
||||
- `type.fontNamesInEnglish`: Show Font Names in English
|
||||
- `import.reportMissingFrames`: Report Missing Frames
|
||||
- `import.unlabeledAlpha`: Interpret Unlabeled Alpha As
|
||||
- `import.dragImportAs`: Default Drag Import As
|
||||
- `export.defaultOutputFolder`: Default Output Folder
|
||||
- `export.segmentSequences`: Segment Sequences
|
||||
- `export.segmentSequenceFiles`: Files per Segment
|
||||
- `export.segmentMovies`: Segment Movie Files
|
||||
- `export.segmentMovieMb`: Segment Size
|
||||
- `export.appendBitsToName`: Append Bit Depth to File Name
|
||||
- `audio.previewSampleRate`: Preview Sample Rate
|
||||
- `disk.diskCacheEnabled`: Enable Disk Cache
|
||||
- `disk.diskCacheMaxGb`: Maximum Disk Cache Size
|
||||
- `disk.diskCacheFolder`: Disk Cache Folder
|
||||
- `disk.mediaCacheFolder`: Database and Cache Folder
|
||||
- `disk.conformedMediaFolder`: Conformed Audio Folder
|
||||
- `memory.ramReservedGb`: RAM Reserved for Other Applications
|
||||
- `memory.reduceCacheWhenLow`: Reduce Cache Size When System Is Low on Memory
|
||||
- `video.enableOutput`: Enable Video Preview Output
|
||||
- `video.device`: Video Device
|
||||
- `video.outputDuringPlayback`: Video Output During Playback
|
||||
- `video.mirrorOnMonitor`: Mirror on Computer Monitor
|
||||
- `video.disableWhenBackground`: Disable Video Output When in Background
|
||||
- `threeD.showReferenceAxes`: Show 3D Reference Axes
|
||||
- `threeD.extendedViewer`: Extended Viewer
|
||||
- `threeD.realtimeShadows`: Realtime Shadows in Draft
|
||||
- `scripting.allowScriptsWriteFiles`: Allow Scripts to Write Files and Access Network
|
||||
- `scripting.warnExecutingFiles`: Warn User When Executing Files
|
||||
- `scripting.enableJsDebugger`: Enable JavaScript Debugger
|
||||
- `scripting.editorFontSize`: Font Size
|
||||
- `scripting.syntaxHighlighting`: Syntax Highlighting
|
||||
- `scripting.lineNumbers`: Line Numbers
|
||||
- `scripting.autoComplete`: Auto-complete
|
||||
- `scripting.bracketMatching`: Bracket Matching
|
||||
- `scripting.wordWrap`: Word Wrap
|
||||
- `scripting.errorBanner`: Show Expression Error Banner
|
||||
Loading…
Reference in New Issue