diff --git a/README.md b/README.md index 47d5e5af..24006fbd 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: motion tracking, puppet, -paint and roto tools, Lottie, preferences and a shortcut editor, and GPU compositing. The web build +paint and roto tools, Lottie and GPU compositing. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser, single-threaded for now. Projects are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened. diff --git a/ROADMAP.md b/ROADMAP.md index 18fd03c7..106b464d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,9 +19,9 @@ once. | M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done | | M11 | Animation tools: audio playback, meters and waveforms (done); presets, Motion Sketch, Wiggler, Lottie | In progress | | M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress | -| M13+ | Puppet, paint, roto, motion tracking, preferences and shortcut editor, a plugin API | Planned | +| M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned | | M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded | -| M14 | Built for agents: MCP server, command-line tool, control channel | Done | +| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done | ## How far from full parity diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index bef9ff2e..eea7ffaa 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -1,4 +1,5 @@ -//! Audio preview output through cpal (the default output device). +//! Audio preview output through cpal (the device and channel mapping chosen in Settings ▸ Audio; +//! the default output device when none is set or it is gone). //! //! cpal streams are not `Send` on every platform, so each preview runs its stream on a small //! dedicated thread that owns it until stopped. The callback pulls interleaved stereo from the @@ -12,21 +13,41 @@ use std::thread::JoinHandle; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{FromSample, SizedSample}; -use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed}; +use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput}; pub struct CpalOut { + out: AudioOutput, rate: u32, latency: Arc, stop: Option>, thread: Option>, } -/// Open the default output device (`None` without one). -pub fn open() -> Option> { +/// Names of the output devices (Settings ▸ Audio). +pub fn devices() -> Vec { let host = cpal::default_host(); - let dev = host.default_output_device()?; + #[allow(deprecated)] + host.output_devices().map(|d| d.filter_map(|d| d.name().ok()).collect()).unwrap_or_default() +} + +/// The named output device, or the default one. +fn device(name: &str) -> Option { + let host = cpal::default_host(); + if !name.is_empty() { + #[allow(deprecated)] + let found = host.output_devices().ok().and_then(|mut d| d.find(|d| d.name().ok().as_deref() == Some(name))); + if found.is_some() { + return found; + } + } + host.default_output_device() +} + +/// Open the output device (`None` without one). +pub fn open(out: &AudioOutput) -> Option> { + let dev = device(&out.device)?; let cfg = dev.default_output_config().ok()?; - Some(Box::new(CpalOut { rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None })) + Some(Box::new(CpalOut { out: out.clone(), rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None })) } fn run>( @@ -34,8 +55,11 @@ fn run>( cfg: &cpal::StreamConfig, feed: Arc, latency: Arc, + map: (usize, usize), ) -> Result { let ch = cfg.channels as usize; + // Output mapping: device channels (0-based) for left and right; out of range → 1 and 2. + let (left, right) = if map.0 < ch && map.1 < ch { map } else { (0, 1) }; let rate = cfg.sample_rate.0 as f64; let mut stereo: Vec = Vec::new(); dev.build_output_stream( @@ -46,11 +70,15 @@ fn run>( feed.pull(&mut stereo); for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) { for (c, o) in f.iter_mut().enumerate() { - let v = match (ch, c) { - (1, _) => (s[0] + s[1]) * 0.5, - (_, 0) => s[0], - (_, 1) => s[1], - _ => 0.0, + // Mono devices, or left and right mapped to the same channel, get the mix. + let v = if ch == 1 || (c == left && c == right) { + (s[0] + s[1]) * 0.5 + } else if c == left { + s[0] + } else if c == right { + s[1] + } else { + 0.0 }; *o = T::from_sample(v.clamp(-1.0, 1.0)); } @@ -77,11 +105,12 @@ impl AudioDevice for CpalOut { let (ready_tx, ready_rx) = mpsc::channel::>(); let latency = self.latency.clone(); let rate = self.rate; + let name = self.out.device.clone(); + let map = ((self.out.left.max(1) - 1) as usize, (self.out.right.max(1) - 1) as usize); let thread = std::thread::Builder::new() .name("ec-audio-out".into()) .spawn(move || { - let host = cpal::default_host(); - let Some(dev) = host.default_output_device() else { + let Some(dev) = device(&name) else { let _ = ready_tx.send(Err("no audio output device".into())); return; }; @@ -95,10 +124,10 @@ impl AudioDevice for CpalOut { let mut cfg: cpal::StreamConfig = sup.config(); cfg.sample_rate = cpal::SampleRate(rate); let stream = match sup.sample_format() { - cpal::SampleFormat::F32 => run::(&dev, &cfg, feed, latency), - cpal::SampleFormat::I16 => run::(&dev, &cfg, feed, latency), - cpal::SampleFormat::U16 => run::(&dev, &cfg, feed, latency), - cpal::SampleFormat::I32 => run::(&dev, &cfg, feed, latency), + cpal::SampleFormat::F32 => run::(&dev, &cfg, feed, latency, map), + cpal::SampleFormat::I16 => run::(&dev, &cfg, feed, latency, map), + cpal::SampleFormat::U16 => run::(&dev, &cfg, feed, latency, map), + cpal::SampleFormat::I32 => run::(&dev, &cfg, feed, latency, map), f => Err(format!("unsupported sample format {f:?}")), }; let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) { diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 2c6ca716..70cbd933 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -15,14 +15,14 @@ fn main() -> eframe::Result { let mut control_port: Option = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut demo = true; - let mut home = false; + let mut home: Option = None; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { "--control" => control_port = args.next().and_then(|p| p.parse().ok()), "--demo" => demo = true, "--empty" => demo = false, - "--home" => home = true, + "--home" => home = Some(true), "--version" => { println!("effectcraft {}", env!("CARGO_PKG_VERSION")); return Ok(()); @@ -46,6 +46,13 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut session = effectcraft_host::session(); + // Settings, shortcut presets and the crash-recovery sentinel live in the platform + // config directory. Agent-driven runs (`--control`) skip crash recovery. + if let Some(dir) = config_dir() { + session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir))); + } + session.load_settings(); + let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; 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 +67,12 @@ 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; + if let Some(r) = recovery { + app.offer_recovery(r); + } 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 +82,11 @@ 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()))); + app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| { + rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().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 +97,24 @@ fn main() -> eframe::Result { ) } +/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides): +/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows), +/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others). +fn config_dir() -> Option { + use std::path::PathBuf; + if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") { + return Some(PathBuf::from(d)); + } + let home = || std::env::var_os("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + return home().map(|h| h.join("Library/Application Support/EffectCraft")); + } + if cfg!(target_os = "windows") { + return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft")); + } + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft")) +} + /// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard /// focus stays where it is (macOS; winit activates ignoring other apps by default). fn agent_event_loop(agent: bool) -> Option { diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs new file mode 100644 index 00000000..2b6321f5 --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -0,0 +1,193 @@ +//! Auto-save and crash recovery (PRJ-7). +//! +//! - **Auto-save**: every *n* minutes a dirty project is written to an "EffectCraft Auto-Save" +//! folder next to it (or a custom folder) as ` auto-save N.ecproj`, the After Effects +//! naming. Slots rotate through 1…max versions, overwriting the oldest. Writes are atomic +//! (temporary file + rename), so a crash mid-write never damages the previous auto-save. +//! - **Crash recovery**: while the app runs, a sentinel (`session.lock` in the config store) +//! records the open project and its latest auto-save. A clean exit removes it; finding one at +//! launch means the last session ended unexpectedly, and the frontend offers to open the +//! latest auto-save. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::config::ConfigStore; +use crate::prefs::Prefs; + +/// Folder created next to a project for its auto-saves. +pub const AUTOSAVE_FOLDER: &str = "EffectCraft Auto-Save"; +/// Crash-recovery sentinel in the config store. +pub const SENTINEL: &str = "session.lock"; +const EXT: &str = "ecproj"; + +/// Auto-save bookkeeping of a session. +#[derive(Clone, Debug, Default)] +pub struct AutoSaveState { + /// Wall-clock seconds of the last auto-save (or of the first tick). + pub last: Option, + /// Slot written last in this session. + pub last_slot: Option, + /// Revision written last (an unchanged project isn't saved again). + pub saved_revision: Option, + /// Path of the latest auto-save. + pub last_path: Option, + /// This session owns the crash-recovery sentinel ([`crate::Session::begin_recovery`]). + pub sentinel: bool, +} + +/// File-name stem of a project (`/a/Intro.ecproj` → `Intro`; untitled → `Untitled Project`). +pub fn project_stem(path: Option<&str>) -> String { + path.and_then(|p| Path::new(p).file_stem()).map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled Project".into()) +} + +/// ` auto-save N.ecproj`. +pub fn file_name(stem: &str, n: u32) -> String { + format!("{stem} auto-save {n}.{EXT}") +} + +/// The slot number of an auto-save file name for `stem`. +pub fn slot_of(stem: &str, name: &str) -> Option { + let rest = name.strip_prefix(stem)?.strip_prefix(" auto-save ")?; + rest.strip_suffix(&format!(".{EXT}"))?.parse().ok() +} + +/// Where a project's auto-saves go: next to the project, or the custom folder; untitled projects +/// use the custom folder or `fallback` (the app's own Auto-Save folder). +pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option { + let a = &prefs.auto_save; + if a.location == "custom" && !a.folder.trim().is_empty() { + return Some(PathBuf::from(a.folder.trim())); + } + match project.and_then(|p| Path::new(p).parent()) { + Some(dir) => Some(dir.join(AUTOSAVE_FOLDER)), + None => fallback.map(|f| f.join(AUTOSAVE_FOLDER)), + } +} + +/// Existing auto-saves of `stem` in `dir`: (slot, path, modified). +pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> { + let Ok(rd) = std::fs::read_dir(dir) else { return vec![] }; + let mut v: Vec<_> = rd + .flatten() + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let n = slot_of(stem, &name)?; + let m = e.metadata().ok()?.modified().ok()?; + Some((n, e.path(), m)) + }) + .collect(); + v.sort_by_key(|x| x.0); + v +} + +/// The slot to write next: after `last` (this session), else after the newest file on disk, +/// wrapping at `max`. +pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Option, max: u32) -> u32 { + let max = max.max(1); + let after = last.or_else(|| existing.iter().filter(|e| e.0 <= max).max_by_key(|e| (e.2, e.0)).map(|e| e.0)); + match after { + Some(n) => n % max + 1, + None => 1, + } +} + +/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path. +pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option, json: &str) -> std::io::Result<(PathBuf, u32)> { + let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?; + std::fs::create_dir_all(&dir)?; + let stem = project_stem(project); + let max = prefs.auto_save.max_versions.max(1); + let have = existing(&dir, &stem); + let slot = next_slot(&have, last_slot, max); + let path = dir.join(file_name(&stem, slot)); + crate::config::atomic_write(&path, json.as_bytes())?; + // Max versions lowered: drop slots above it. + for (n, p, _) in have { + if n > max { + let _ = std::fs::remove_file(p); + } + } + Ok((path, slot)) +} + +/// The newest auto-save of a project, if any. +pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option { + let dir = folder(prefs, project, fallback)?; + existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1) +} + +/// What the crash-recovery sentinel records. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Sentinel { + pub pid: u32, + /// The open project (None = untitled). + pub project: Option, + /// Latest auto-save of this run. + pub autosave: Option, + /// Whether the project had unsaved changes. + pub dirty: bool, +} + +/// A previous run that didn't exit cleanly. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Recovery { + pub project: Option, + /// The auto-save to offer (exists on disk). + pub autosave: Option, + pub dirty: bool, +} + +/// At launch: report an unclean previous exit (a leftover sentinel), then start this run's +/// sentinel. +pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option { + let found = store.read(SENTINEL).map(|t| serde_json::from_str::(&t).unwrap_or_default()); + let _ = store.write(SENTINEL, &serde_json::to_string(&Sentinel { pid: pid(), ..Default::default() }).unwrap_or_default()); + let s = found?; + let fallback = store.dir(); + let autosave = s + .autosave + .clone() + .filter(|p| Path::new(p).is_file()) + .or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string())); + Some(Recovery { project: s.project, autosave, dirty: s.dirty }) +} + +/// This process id (0 where processes don't exist, e.g. the browser). +pub fn pid() -> u32 { + #[cfg(not(target_arch = "wasm32"))] + return std::process::id(); + #[cfg(target_arch = "wasm32")] + 0 +} + +/// Record the current project / latest auto-save in the sentinel. +pub fn update(store: &dyn ConfigStore, s: &Sentinel) { + let _ = store.write(SENTINEL, &serde_json::to_string(s).unwrap_or_default()); +} + +/// Clean exit: remove the sentinel. +pub fn end(store: &dyn ConfigStore) { + let _ = store.remove(SENTINEL); +} + +/// After Effects' Increment and Save name: `Intro.ecproj` → `Intro 2.ecproj`, `Intro 2.ecproj` +/// → `Intro 3.ecproj` (skipping names that exist). +pub fn increment_path(path: &str, exists: impl Fn(&str) -> bool) -> String { + let p = Path::new(path); + let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + let (base, mut n) = match stem.rsplit_once(' ') { + Some((b, n)) if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => (b.to_string(), n.parse::().unwrap_or(1) + 1), + _ => (stem.clone(), 2), + }; + loop { + let cand = p.with_file_name(format!("{base} {n}.{EXT}")).to_string_lossy().to_string(); + if !exists(&cand) { + return cand; + } + n += 1; + } +} diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 996df5df..5ebd491e 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -93,6 +93,10 @@ fn new_comp(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name").unwrap_or("Comp 1").to_string(); let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97); let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0))); + // Settings ▸ 3D ▸ Default 3D Renderer. + if s.prefs.three_d.default_renderer == "advanced" { + c.renderer = effectcraft_project::Renderer::Advanced3D; + } apply_settings(&mut c, p); let id = s.edit("New Composition", None, |proj, st| { let mut name = name.clone(); diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 9aa1692b..5ce1cec1 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -390,7 +390,7 @@ fn split(s: &mut Session, p: &Value) -> Result { fn label(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "label").unwrap_or("Red"); - let lab = Label::from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?; + let lab = s.prefs.label_from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?; let (cid, ids) = layers_p(s, p)?; let items = s.state.project_selection.clone(); s.edit("Label", None, |proj, _| { diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 343ca611..92e8836e 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -12,7 +12,17 @@ fn has_path(s: &Session) -> std::result::Result<(), String> { } fn new_project(s: &mut Session, _: &Value) -> Result { + // Settings ▸ Project ▸ New Project Loads Template: open the template as an untitled project. + let tpl = s.prefs.project.template_path.trim().to_string(); + if s.prefs.project.use_template && !tpl.is_empty() { + let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?; + let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?; + s.replace_project(Project::from_json(&text)?, None); + s.update_sentinel(); + return Ok(json!({"template": tpl})); + } s.replace_project(Project::default(), None); + s.update_sentinel(); Ok(Value::Null) } @@ -26,12 +36,13 @@ fn demo(s: &mut Session, _: &Value) -> Result { Ok(json!({"comp": s.state.active_comp.map(|c| c.0)})) } -fn open(s: &mut Session, p: &Value) -> Result { +pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?; let proj = Project::from_json(&text)?; s.replace_project(proj, Some(path.to_string())); + s.note_project_path(path); Ok(json!({"path": path})) } @@ -40,6 +51,7 @@ fn save_to(s: &mut Session, path: &str) -> Result { s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.path = Some(path.to_string()); s.saved_revision = s.revision; + s.note_project_path(path); s.toast(format!("Saved {path}")); Ok(json!({"path": path, "bytes": json.len()})) } @@ -56,12 +68,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result { fn increment_save(s: &mut Session, _: &Value) -> Result { let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?; - let stem = cur.trim_end_matches(".ecproj"); - let (base, n) = match stem.rsplit_once('_') { - Some((b, n)) if n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty() => (b.to_string(), n.parse::().unwrap_or(0) + 1), - _ => (stem.to_string(), 2), - }; - save_to(s, &format!("{base}_{n}.ecproj")) + // After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`. + let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists()); + save_to(s, &next) } fn revert(s: &mut Session, _: &Value) -> Result { @@ -79,9 +88,19 @@ fn import(s: &mut Session, p: &Value) -> Result { let mut ids = vec![]; let mut errors = vec![]; let mut probed = vec![]; + let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps); for path in &paths { match importer.probe(path) { - Ok(f) => probed.push((path.clone(), f)), + Ok(mut f) => { + // Settings ▸ Import ▸ Sequence Footage frames per second. + if f.kind == FootageKind::Sequence { + let r = seq_rate; + let frames = f.frame_rate.frame_at(f.duration); + f.frame_rate = r; + f.duration = r.tick_of(frames.max(1)); + } + probed.push((path.clone(), f)) + } Err(e) => errors.push(format!("{path}: {e}")), } } diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index b344c2a0..2a02356f 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -24,6 +24,7 @@ pub fn specs() -> Vec { "{page?: general|startup|project|composition|previews|appearance|grids|labels|type|import|export|audio|disk|memory|video|3d|scripting}", always ), + fe!("app.gpuInfo", "GPU Information...", [], None, "{}", always), fe!("app.hide", "Hide EffectCraft", [], None, "{}", always), fe!("app.quit", "Quit EffectCraft", [], Some("Cmd+Q"), "{}", always), fe!("app.commandPalette", "Quick Apply...", ["Edit"], Some("Cmd+Shift+Space"), "{query?}", always), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 94605428..09a50660 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -252,12 +252,19 @@ fn add_item(s: &mut Session, p: &Value) -> Result { let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone(); let (src, size, dur) = match &it.kind { ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)), - ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), (f.kind != effectcraft_project::FootageKind::Still).then_some(f.duration)), + ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => { + // Settings ▸ Import ▸ Still Footage: length of the composition or a duration. + let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds)); + (LayerSource::Footage { item }, (f.width, f.height), d) + } + ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)), ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None), ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")), }; let fr = comp.frame_rate; - let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO)); + // Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time). + let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() }; + let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(default_start)); let id = s.edit("Add Footage to Comp", None, |proj, st| { let mut l = build::layer(proj, &comp, &it.name, src, size, dur); l.start_time = start; diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 69e5ab69..0795bba3 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -21,6 +21,7 @@ mod prop; pub mod puppet; mod query; mod render_queue; +mod settings; mod stubs; mod styles; mod text_anim; @@ -108,6 +109,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(track::specs()); v.extend(paint::specs()); v.extend(puppet::specs()); + v.extend(settings::specs()); v.extend(stubs::specs()); v }) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 3164274c..05dc37fa 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -30,11 +30,23 @@ fn with_prop( f: impl FnOnce(&mut Property, Tick) -> Result, ) -> Result { let t = s.time(); + let linear = s.prefs.general.default_spatial_linear; s.edit(label, merge, |proj, _| { let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?; - f(pr, lt) + let before: Vec = pr.keys.iter().map(|k| k.time).collect(); + let r = f(pr, lt)?; + // Settings ▸ General ▸ Default Spatial Interpolation to Linear: new keys of spatial + // properties get straight motion paths instead of Auto Bezier. + if linear && pr.spatial { + for k in pr.keys.iter_mut().filter(|k| !before.contains(&k.time)) { + k.spatial_auto = false; + k.spatial_in = [0.0; 3]; + k.spatial_out = [0.0; 3]; + } + } + Ok(r) }) } diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index d07b03a7..b2d4f9e8 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -344,6 +344,14 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { fn render(s: &mut Session, p: &Value) -> Result { let wait = b_p(p, "wait").unwrap_or(true); + // Settings ▸ Project ▸ Auto-Save ▸ Save When Starting Render Queue. + if s.prefs.auto_save.enabled + && s.prefs.auto_save.save_on_render_start + && s.is_dirty() + && let Err(e) = s.autosave_now() + { + log::warn!("{e}"); + } let ids = s.start_render(wait).map_err(EngineError::Other)?; let items: Vec = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect(); Ok(json!({"rendering": s.is_rendering(), "items": items})) diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs new file mode 100644 index 00000000..fe5aec57 --- /dev/null +++ b/crates/engine/src/commands/settings.rs @@ -0,0 +1,278 @@ +//! Settings (`prefs.*`), keyboard shortcut presets (`shortcuts.*`), File ▸ Open Recent and +//! auto-save commands. + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, str_p}; +use crate::shortcuts::{DEFAULT_PRESET, binding_key, normalize}; +use crate::{EngineError, Result, Session, cmd, query}; + +// ---------------------------------------------------------------- prefs + +fn prefs_get(s: &mut Session, p: &Value) -> Result { + let key = str_p(p, "key").unwrap_or(""); + s.prefs.get(key).ok_or_else(|| bad("prefs.get", format!("unknown setting `{key}`"))) +} + +fn prefs_set(s: &mut Session, p: &Value) -> Result { + let mut pairs: Vec<(String, Value)> = vec![]; + if let Some(Value::Object(m)) = p.get("values") { + pairs.extend(m.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + if let Some(k) = str_p(p, "key") { + let v = p.get("value").cloned().ok_or_else(|| bad("prefs.set", "missing `value`"))?; + pairs.push((k.to_string(), v)); + } + if pairs.is_empty() { + return Err(bad("prefs.set", "give `key` and `value` (or `values: {key: value}`)")); + } + let mut next = s.prefs.clone(); + for (k, v) in &pairs { + next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?; + } + s.prefs = next; + s.prefs_changed(); + s.save_prefs(); + let out: serde_json::Map = pairs.iter().map(|(k, _)| (k.clone(), s.prefs.get(k).unwrap_or(Value::Null))).collect(); + Ok(Value::Object(out)) +} + +fn prefs_reset(s: &mut Session, p: &Value) -> Result { + let page = str_p(p, "page"); + s.prefs.reset(page).map_err(|e| bad("prefs.reset", e))?; + s.prefs_changed(); + s.save_prefs(); + s.toast(match page { + Some(pg) => format!("Reset {pg} settings"), + None => "Reset all settings".into(), + }); + Ok(json!({"reset": page.unwrap_or("all")})) +} + +fn prefs_open(s: &mut Session, p: &Value) -> Result { + let page = str_p(p, "page").unwrap_or("general"); + let id = crate::prefs::page_id(page).ok_or_else(|| bad("prefs.open", format!("unknown settings page `{page}`")))?; + super::frontend(s, "app.settings", &json!({"page": id})) +} + +fn prefs_pages(_: &mut Session, _: &Value) -> Result { + Ok(crate::prefs::pages_json()) +} + +// ---------------------------------------------------------------- shortcuts + +/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). +fn bindable(s: &Session, p: &Value, cmd: &str) -> Result { + let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?; + let params = p.get("params").cloned().unwrap_or(Value::Null); + let t = s.shortcuts(); + let key = binding_key(c, ¶ms); + t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) +} + +fn keys_param(p: &Value, cmd: &str) -> Result> { + Ok(match p.get("keys") { + None | Some(Value::Null) => vec![], + Some(Value::String(k)) if k.trim().is_empty() => vec![], + Some(Value::String(k)) => vec![k.clone()], + Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(), + Some(v) => return Err(bad(cmd, format!("`keys` must be a string or a list, not {v}"))), + }) +} + +fn sc_list(s: &mut Session, p: &Value) -> Result { + let q = str_p(p, "query").unwrap_or("").to_lowercase(); + let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false); + let keys_q = str_p(p, "keys").and_then(normalize); + let t = s.shortcuts(); + let rows: Vec = t + .bindables + .iter() + .filter_map(|b| { + let keys = t.keys.get(&b.key).cloned().unwrap_or_default(); + if assigned && keys.is_empty() { + return None; + } + if let Some(k) = &keys_q + && !keys.contains(k) + { + return None; + } + let hay = format!("{} {} {} {}", b.label, b.command, b.path.join(" "), keys.join(" ")).to_lowercase(); + if !q.split_whitespace().all(|w| hay.contains(w)) { + return None; + } + let mut o = serde_json::to_value(b).unwrap_or_default(); + o["keys"] = json!(keys); + Some(o) + }) + .collect(); + Ok(json!({"preset": s.keymaps.active, "presets": s.keymaps.names(), "count": rows.len(), "commands": rows})) +} + +fn sc_set(s: &mut Session, p: &Value) -> Result { + let b = bindable(s, p, "shortcuts.set")?; + let keys = keys_param(p, "shortcuts.set")?; + let conflicts: Vec<_> = keys.iter().flat_map(|k| s.shortcuts().conflicts(&b, k)).collect(); + let created = s.keymaps.set(&b, &keys).map_err(|e| bad("shortcuts.set", e))?; + s.shortcuts_changed(); + let now = s.shortcuts().keys.get(&b.key).cloned().unwrap_or_default(); + if let Some(n) = &created { + s.toast(format!("The default shortcuts can't be changed: created the preset \"{n}\"")); + } + if !conflicts.is_empty() { + let names: Vec = conflicts.iter().map(|c| format!("{} ({})", c.label, c.keys)).collect(); + s.toast(format!("Shortcut conflict: also used by {}", names.join(", "))); + } + Ok(json!({"command": b.key, "keys": now, "preset": s.keymaps.active, "created": created, "conflicts": conflicts})) +} + +fn sc_reset(s: &mut Session, p: &Value) -> Result { + if let Some(c) = str_p(p, "command") { + let b = bindable(s, p, "shortcuts.reset")?; + let defaults = b.defaults.clone(); + s.keymaps.set(&b, &defaults).map_err(|e| bad("shortcuts.reset", e))?; + s.shortcuts_changed(); + return Ok(json!({"command": c, "keys": defaults})); + } + s.keymaps.reset_active(); + s.shortcuts_changed(); + Ok(json!({"preset": s.keymaps.active})) +} + +fn sc_preset(s: &mut Session, p: &Value) -> Result { + let op = str_p(p, "op").unwrap_or("select"); + let name = str_p(p, "name"); + let e = |m: String| bad("shortcuts.preset", m); + let k = &mut s.keymaps; + match op { + "select" => k.select(name.unwrap_or(DEFAULT_PRESET)).map_err(e)?, + "new" => { + k.duplicate(DEFAULT_PRESET, Some(name.unwrap_or("Custom"))).map_err(e)?; + } + "duplicate" => { + let from = str_p(p, "from").map(str::to_string).unwrap_or_else(|| k.active.clone()); + k.duplicate(&from, name).map_err(e)?; + } + "delete" => { + let n = name.map(str::to_string).unwrap_or_else(|| k.active.clone()); + k.delete(&n).map_err(e)?; + } + "rename" => { + let to = str_p(p, "newName").ok_or_else(|| bad("shortcuts.preset", "missing `newName`"))?; + let from = name.map(str::to_string).unwrap_or_else(|| k.active.clone()); + k.rename(&from, to).map_err(e)?; + } + _ => return Err(bad("shortcuts.preset", "op must be select, new, duplicate, delete or rename")), + } + s.shortcuts_changed(); + Ok(json!({"preset": s.keymaps.active, "presets": s.keymaps.names()})) +} + +fn sc_export(s: &mut Session, p: &Value) -> Result { + let doc = s.keymaps.export(str_p(p, "preset")).map_err(|e| bad("shortcuts.export", e))?; + if let Some(path) = str_p(p, "path") { + let text = serde_json::to_string_pretty(&doc).unwrap_or_default(); + s.services.write_file(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + return Ok(json!({"path": path, "preset": doc["name"]})); + } + Ok(doc) +} + +fn sc_import(s: &mut Session, p: &Value) -> Result { + let doc = match (str_p(p, "path"), p.get("preset")) { + (Some(path), _) => { + let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + serde_json::from_slice(&bytes).map_err(|e| bad("shortcuts.import", format!("{path}: {e}")))? + } + (None, Some(v)) if v.is_object() => v.clone(), + _ => return Err(bad("shortcuts.import", "give `path` or `preset` (an exported document)")), + }; + let name = s.keymaps.import(&doc, str_p(p, "name")).map_err(|e| bad("shortcuts.import", e))?; + s.shortcuts_changed(); + Ok(json!({"preset": name})) +} + +fn sc_conflicts(s: &mut Session, _: &Value) -> Result { + let c: Vec = + s.shortcuts().all_conflicts().into_iter().map(|(a, c)| json!({"command": a, "keys": c.keys, "with": c.key, "scope": c.scope})).collect(); + Ok(json!(c)) +} + +// ---------------------------------------------------------------- recent / auto-save + +fn open_recent(s: &mut Session, p: &Value) -> Result { + let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) { + (Some(path), _) => path.to_string(), + (None, Some(i)) => s.prefs.recent_projects.get(i as usize).cloned().ok_or_else(|| bad("file.openRecent", format!("no recent project #{i}")))?, + _ => s.prefs.recent_projects.first().cloned().ok_or_else(|| bad("file.openRecent", "no recent projects"))?, + }; + match super::file::open(s, &json!({"path": path})) { + Ok(v) => Ok(v), + Err(e) => { + // A project that's gone drops out of the list. + if !std::path::Path::new(&path).exists() { + s.prefs.recent_projects.retain(|x| *x != path); + s.save_prefs(); + } + Err(e) + } + } +} + +fn clear_recent(s: &mut Session, _: &Value) -> Result { + s.prefs.recent_projects.clear(); + s.save_prefs(); + s.prefs_revision += 1; + Ok(Value::Null) +} + +fn has_recent(s: &Session) -> std::result::Result<(), String> { + if s.prefs.recent_projects.is_empty() { Err("no recent projects".into()) } else { Ok(()) } +} + +fn auto_save(s: &mut Session, _: &Value) -> Result { + let path = s.autosave_now()?; + s.toast(format!("Auto-saved to {path}")); + Ok(json!({"path": path})) +} + +fn recovery_info(s: &mut Session, _: &Value) -> Result { + let root = s.default_autosave_root(); + let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()); + Ok(json!({ + "lastAutoSave": s.autosave.last_path, + "latestOnDisk": latest, + "folder": crate::autosave::folder(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()), + "recentProjects": s.prefs.recent_projects, + })) +} + +pub fn specs() -> Vec { + vec![ + query!("prefs.get", "Get Setting", "{key?: e.g. general.undoLevels (omit for all settings)}", prefs_get), + cmd!("prefs.set", "Change Setting", [], None, "{key, value, values?: {key: value}}", always, prefs_set), + cmd!("prefs.reset", "Reset Settings", [], None, "{page?: general|labels|project|…}", always, prefs_reset), + cmd!( + "prefs.open", + "Open Settings", + [], + None, + "{page?: general|startup|project|composition|previews|appearance|grids|labels|type|import|export|audio|disk|memory|video|3d|scripting}", + always, + prefs_open + ), + query!("prefs.pages", "Settings Pages", "{}", prefs_pages), + query!("shortcuts.list", "List Keyboard Shortcuts", "{query?, keys?, assigned?: bool}", sc_list), + cmd!("shortcuts.set", "Set Keyboard Shortcut", [], None, "{command, params?, keys: \"Cmd+Shift+K\" | [keys] | null}", always, sc_set), + cmd!("shortcuts.reset", "Reset Keyboard Shortcuts", [], None, "{command?, params?}", always, sc_reset), + cmd!("shortcuts.preset", "Keyboard Shortcut Preset", [], None, "{op: select|new|duplicate|delete|rename, name?, from?, newName?}", always, sc_preset), + cmd!("shortcuts.export", "Export Keyboard Shortcuts", [], None, "{preset?, path?}", always, sc_export), + cmd!("shortcuts.import", "Import Keyboard Shortcuts", [], None, "{path? | preset?: exported document, name?}", always, sc_import), + query!("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "{}", sc_conflicts), + cmd!("file.openRecent", "Open Recent", [], None, "{index? | path?}", has_recent, open_recent), + cmd!("file.clearRecent", "Clear Recent Projects", ["File", "Open Recent"], None, "{}", has_recent, clear_recent), + cmd!("file.autoSave", "Auto-Save Now", [], None, "{}", always, auto_save), + query!("file.recoveryInfo", "Auto-Save Status", "{}", recovery_info), + ] +} diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs new file mode 100644 index 00000000..d84eeb7c --- /dev/null +++ b/crates/engine/src/config.rs @@ -0,0 +1,104 @@ +//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery +//! sentinel). 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`, `shortcuts.json`, `session.lock`). +pub trait ConfigStore: Send + Sync { + fn read(&self, name: &str) -> Option; + fn write(&self, name: &str, data: &str) -> std::io::Result<()>; + fn remove(&self, name: &str) -> std::io::Result<()>; + /// A directory on disk next to the configuration (default Auto-Save folder for untitled + /// projects); `None` for stores that aren't on the file system. + fn dir(&self) -> Option { + None + } +} + +/// In-memory store (tests, headless sessions). +#[derive(Default)] +pub struct MemoryConfig { + pub files: Mutex>, +} + +impl ConfigStore for MemoryConfig { + fn read(&self, name: &str) -> Option { + self.files.lock().ok()?.get(name).cloned() + } + fn write(&self, name: &str, data: &str) -> std::io::Result<()> { + if let Ok(mut m) = self.files.lock() { + m.insert(name.to_string(), data.to_string()); + } + Ok(()) + } + fn remove(&self, name: &str) -> std::io::Result<()> { + if let Ok(mut m) = self.files.lock() { + m.remove(name); + } + Ok(()) + } +} + +/// Files in one directory, written atomically. +pub struct DirConfig { + pub dir: PathBuf, +} + +impl DirConfig { + pub fn new(dir: impl Into) -> DirConfig { + DirConfig { dir: dir.into() } + } +} + +impl ConfigStore for DirConfig { + fn read(&self, name: &str) -> Option { + std::fs::read_to_string(self.dir.join(name)).ok() + } + fn write(&self, name: &str, data: &str) -> std::io::Result<()> { + std::fs::create_dir_all(&self.dir)?; + atomic_write(&self.dir.join(name), data.as_bytes()) + } + fn remove(&self, name: &str) -> std::io::Result<()> { + match std::fs::remove_file(self.dir.join(name)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + } + } + fn dir(&self) -> Option { + Some(self.dir.clone()) + } +} + +/// The temporary file an atomic write of `path` goes through. +pub fn temp_path(path: &Path) -> PathBuf { + let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); + name.push(".tmp"); + path.with_file_name(name) +} + +/// Write `data` to `path` atomically: write a temporary file next to it, flush it to disk, then +/// rename it over `path`. A crash at any point leaves either the old file or the new one, never a +/// torn file. +pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { + atomic_write_with(path, data, |tmp, data| { + use std::io::Write; + let mut f = std::fs::File::create(tmp)?; + f.write_all(data)?; + f.sync_all() + }) +} + +/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A +/// failed write removes the temporary file and leaves `path` untouched. +pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> { + let tmp = temp_path(path); + if let Err(e) = write(&tmp, data) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + std::fs::rename(&tmp, path) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 39e36362..f1af0111 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,11 +8,16 @@ //! The project is an `Arc` edited copy-on-write; undo keeps whole-project snapshots //! (compositions are `Arc`s, so untouched comps are shared). +pub mod autosave; 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; +pub mod shortcuts; pub mod tracking; use std::sync::Arc; @@ -65,10 +70,7 @@ impl Services for FsServices { std::fs::read(path) } fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { - let p = std::path::Path::new(path); - let tmp = p.with_extension("ecproj.tmp"); - std::fs::write(&tmp, data)?; - std::fs::rename(&tmp, p) + config::atomic_write(std::path::Path::new(path), data) } } @@ -224,6 +226,21 @@ pub struct Session { pub journal: Vec<(String, Value)>, /// Processed-layer pixels reused across frames and edits (content-keyed, never stale). pub layer_cache: Arc, + /// Settings (Preferences). + pub prefs: prefs::Prefs, + /// Bumped whenever settings change (frontends re-apply theme, labels…). + pub prefs_revision: u64, + /// Where settings, shortcut presets and the crash-recovery sentinel are stored (`None` = + /// nothing persists). + pub config: Option>, + /// Keyboard shortcut presets. + pub keymaps: shortcuts::Keymaps, + /// Frontend-only commands offered for binding. + pub ui_commands: Vec, + /// Cache of the resolved active preset (read it with [`Session::shortcuts`]). + pub shortcut_table: std::sync::OnceLock, + /// Auto-save bookkeeping. + pub autosave: autosave::AutoSaveState, } impl Default for Session { @@ -246,6 +263,13 @@ impl Default for Session { events: vec![], journal: vec![], layer_cache: Arc::new(LayerCache::default()), + prefs: prefs::Prefs::default(), + prefs_revision: 0, + config: None, + keymaps: shortcuts::Keymaps::default(), + ui_commands: vec![], + shortcut_table: std::sync::OnceLock::new(), + autosave: autosave::AutoSaveState::default(), } } } @@ -298,8 +322,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); @@ -483,6 +509,8 @@ mod tests_effects; #[cfg(test)] mod tests_menu_cmds; #[cfg(test)] +mod tests_settings; +#[cfg(test)] mod tests_styles; #[cfg(test)] mod tests_text; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index dc641003..25baa140 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -93,6 +93,17 @@ pub fn entries() -> Vec<(Vec, &'static MenuEntry)> { out } +/// Display label of an entry: label names come from Settings ▸ Labels. +pub fn entry_label(s: &Session, e: &MenuEntry) -> String { + match e.command.as_str() { + "edit.label" => match e.params.get("label").and_then(Value::as_str).and_then(effectcraft_color::Label::from_name) { + Some(l) if l != effectcraft_color::Label::None => s.prefs.label_name(l), + _ => e.label.clone(), + }, + _ => e.label.clone(), + } +} + /// Check-mark state of an entry (`None` = not a toggle / radio entry). Frontend-only toggles /// (rulers, grid…) are answered by the frontend. pub fn checked(s: &Session, command: &str, params: &Value) -> Option { @@ -292,6 +303,8 @@ File New Project | file.newProject New Folder | project.newFolder Open Project... | file.open + Open Recent + Clear Recent Projects | file.clearRecent --- Close | file.close Close Project | file.closeProject diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs new file mode 100644 index 00000000..ec06b3f0 --- /dev/null +++ b/crates/engine/src/prefs.rs @@ -0,0 +1,951 @@ +//! Settings (After Effects' Preferences): a versioned serde model, addressed by dotted keys +//! (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`), plus the page schema the +//! Settings dialog and agents (`prefs.pages`) are built from. +//! +//! Settings are stored as JSON (`prefs.json`) through the session's [`crate::config::ConfigStore`]. +//! Loading is forgiving: missing keys take their defaults, unknown keys (written by a newer +//! version) are kept and written back, and older layouts are migrated ([`migrate`]). +//! +//! Every setting in [`pages`] says whether it already changes behaviour (`live`). Settings that +//! don't yet are still shown (After Effects parity of the dialog) and are listed in +//! `docs/preferences.md`; a test keeps that list in step with the schema. + +use std::collections::BTreeMap; + +use effectcraft_color::Label; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +/// Current settings layout version. +pub const PREFS_VERSION: u32 = 2; +/// Settings file name in the config store. +pub const PREFS_FILE: &str = "prefs.json"; + +/// Unknown keys inside a page, kept so a newer version's settings survive a round trip. +type Extra = BTreeMap; + +macro_rules! page { + ($(#[$m:meta])* $name:ident { $($(#[$fm:meta])* $f:ident : $t:ty = $d:expr),* $(,)? }) => { + $(#[$m])* + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] + #[serde(default, rename_all = "camelCase")] + pub struct $name { + $($(#[$fm])* pub $f: $t,)* + #[serde(flatten)] + pub extra: Extra, + } + impl Default for $name { + fn default() -> Self { + $name { $($f: $d,)* extra: Extra::new() } + } + } + }; +} + +page!(General { + /// Levels of Undo (1–99). + undo_levels: u32 = 32, + /// Path Point and Handle Size (px). + path_point_size: u32 = 5, + show_tool_tips: bool = true, + /// Create New Layers at Composition Start Time (off: at the current time). + create_layers_at_comp_start: bool = true, + switches_affect_nested_comps: bool = true, + /// Default Spatial Interpolation to Linear. + default_spatial_linear: bool = false, + preserve_constant_vertex_count: bool = true, + /// Synchronize Time of All Related Items. + sync_time_related_items: bool = true, + expression_pick_whip_compact: bool = true, + create_split_layers_above: bool = false, + use_system_color_picker: bool = false, + /// Number of projects in File ▸ Open Recent. + recent_items: u32 = 10, +}); + +page!(Startup { + show_home_on_launch: bool = true, + show_home_on_open_project: bool = false, + /// After a crash, offer to open the most recent auto-save. + offer_crash_recovery: bool = true, +}); + +page!(ProjectPrefs { + /// Open a template project for File ▸ New ▸ New Project. + use_template: bool = false, + template_path: String = String::new(), +}); + +page!(AutoSave { + enabled: bool = true, + interval_minutes: u32 = 20, + max_versions: u32 = 5, + /// `nextToProject` (an "EffectCraft Auto-Save" folder beside the project) or `custom`. + location: String = "nextToProject".into(), + folder: String = String::new(), + save_on_render_start: bool = false, +}); + +page!(CompositionPrefs { + /// Motion path: `all`, `none`, `seconds`, `keyframes`. + motion_path: String = "keyframes".into(), + motion_path_seconds: f64 = 15.0, + motion_path_keyframes: u32 = 15, + show_rendering_progress: bool = true, + hardware_accelerate_panels: bool = true, +}); + +page!(Previews { + /// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`. + adaptive_resolution_limit: String = "1/8".into(), + show_internal_wireframes: bool = false, + cache_frames_when_idle: bool = false, + fast_previews: bool = false, + /// `faster` or `moreAccurate`. + zoom_quality: String = "moreAccurate".into(), + show_gpu_info: bool = false, +}); + +page!(Appearance { + /// `dark`, `darker` or `light`. + theme: String = "dark".into(), + /// User interface brightness, -1 (darker) … 1 (lighter). + brightness: f64 = 0.0, + use_label_color_for_handles: bool = true, + use_label_color_for_tabs: bool = true, + cycle_mask_colors: bool = true, + use_gradients: bool = true, +}); + +page!(Grids { + grid_color: String = "#7a9cff".into(), + grid_spacing: f64 = 100.0, + grid_subdivisions: u32 = 4, + /// `lines`, `dashed` or `dots`. + grid_style: String = "lines".into(), + proportional_horizontal: u32 = 4, + proportional_vertical: u32 = 4, + guide_color: String = "#3cc8f0".into(), + guide_style: String = "lines".into(), + action_safe: f64 = 10.0, + title_safe: f64 = 20.0, +}); + +page!(TypePrefs { + /// `latin` or `southAsian` (South Asian and Middle Eastern). + text_engine: String = "latin".into(), + font_preview: bool = true, + recent_fonts: u32 = 10, + font_names_in_english: bool = false, +}); + +page!(Import { + /// Still footage duration: `compLength` or `seconds`. + still_footage: String = "compLength".into(), + still_seconds: f64 = 5.0, + sequence_fps: f64 = 30.0, + report_missing_frames: bool = true, + /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. + unlabeled_alpha: String = "ask".into(), + /// Default drag import as: `footage`, `comp`, `compLayerSizes`. + drag_import_as: String = "footage".into(), +}); + +page!(Export { + default_output_folder: String = String::new(), + segment_sequences: bool = false, + segment_sequence_files: u32 = 700, + segment_movies: bool = false, + segment_movie_mb: u32 = 1024, + append_bits_to_name: bool = false, +}); + +page!(Audio { + /// Output device name (empty = the system default). + output_device: String = String::new(), + /// Output mapping: device channel for the left / right output (1-based). + output_left: u32 = 1, + output_right: u32 = 2, + preview_sample_rate: u32 = 48_000, +}); + +page!(Disk { + disk_cache_enabled: bool = true, + disk_cache_max_gb: u32 = 100, + disk_cache_folder: String = String::new(), + media_cache_folder: String = String::new(), + conformed_media_folder: String = String::new(), +}); + +page!(Memory { + ram_reserved_gb: u32 = 6, + /// Processed-layer cache budget (MB). + layer_cache_mb: u32 = 1024, + /// Decoded footage frame cache budget (MB). + media_cache_mb: u32 = 1024, + /// RAM preview (viewer frame) cache budget (MB). + preview_cache_mb: u32 = 3072, + reduce_cache_when_low: bool = true, +}); + +page!(Video { + enable_output: bool = false, + device: String = String::new(), + output_during_playback: bool = true, + mirror_on_monitor: bool = true, + disable_when_background: bool = false, +}); + +page!(ThreeD { + /// Default 3D renderer for new compositions: `classic` or `advanced`. + default_renderer: String = "classic".into(), + show_reference_axes: bool = true, + extended_viewer: bool = false, + realtime_shadows: bool = true, +}); + +page!(Scripting { + allow_scripts_write_files: bool = false, + warn_executing_files: bool = true, + enable_js_debugger: bool = false, + editor_font_size: u32 = 13, + syntax_highlighting: bool = true, + line_numbers: bool = true, + auto_complete: bool = true, + bracket_matching: bool = true, + word_wrap: bool = false, + error_banner: bool = true, +}); + +/// One editable label: name and `#rrggbb` colour. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LabelPref { + pub name: String, + pub color: String, +} + +fn default_labels() -> Vec { + Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect() +} + +fn hex([r, g, b]: [u8; 3]) -> String { + format!("#{r:02x}{g:02x}{b:02x}") +} + +fn parse_hex(s: &str) -> Option<[u8; 3]> { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 { + return None; + } + let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok(); + Some([p(0)?, p(2)?, p(4)?]) +} + +/// All settings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Prefs { + pub version: u32, + pub general: General, + pub startup: Startup, + pub project: ProjectPrefs, + pub auto_save: AutoSave, + pub composition: CompositionPrefs, + pub previews: Previews, + pub appearance: Appearance, + pub grids: Grids, + /// The 16 labels (Red … Dark Green), in order. + pub labels: Vec, + #[serde(rename = "type")] + pub type_: TypePrefs, + pub import: Import, + pub export: Export, + pub audio: Audio, + pub disk: Disk, + pub memory: Memory, + pub video: Video, + #[serde(rename = "threeD")] + pub three_d: ThreeD, + pub scripting: Scripting, + /// File ▸ Open Recent, newest first. + pub recent_projects: Vec, + /// Top-level keys this version doesn't know (kept for newer versions). + #[serde(flatten)] + pub extra: Extra, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { + version: PREFS_VERSION, + general: General::default(), + startup: Startup::default(), + project: ProjectPrefs::default(), + auto_save: AutoSave::default(), + composition: CompositionPrefs::default(), + previews: Previews::default(), + appearance: Appearance::default(), + grids: Grids::default(), + labels: default_labels(), + type_: TypePrefs::default(), + import: Import::default(), + export: Export::default(), + audio: Audio::default(), + disk: Disk::default(), + memory: Memory::default(), + video: Video::default(), + three_d: ThreeD::default(), + scripting: Scripting::default(), + recent_projects: vec![], + extra: Extra::new(), + } + } +} + +/// Bring an older settings document up to [`PREFS_VERSION`]. +/// +/// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`, +/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level. +pub fn migrate(mut v: Value) -> Value { + let Some(obj) = v.as_object_mut() else { return json!({}) }; + let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0); + if version < 2 { + let moved = |from: &str, page: &str, key: &str, obj: &mut Map| { + if let Some(x) = obj.remove(from) { + let p = obj.entry(page.to_string()).or_insert_with(|| json!({})); + if let Some(p) = p.as_object_mut() { + p.entry(key.to_string()).or_insert(x); + } + } + }; + moved("undoLevels", "general", "undoLevels", obj); + moved("autoSaveEnabled", "autoSave", "enabled", obj); + moved("autoSaveMinutes", "autoSave", "intervalMinutes", obj); + moved("autoSaveVersions", "autoSave", "maxVersions", obj); + moved("theme", "appearance", "theme", obj); + if let Some(Value::Array(names)) = obj.remove("labelNames") { + let mut labels = default_labels(); + for (l, n) in labels.iter_mut().zip(names) { + if let Some(n) = n.as_str() { + l.name = n.to_string(); + } + } + obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default()); + } + } + obj.insert("version".into(), json!(PREFS_VERSION)); + v +} + +impl Prefs { + /// Parse a settings document (migrating older layouts; unknown keys are kept, bad values + /// fall back to defaults). + pub fn from_json(text: &str) -> Prefs { + let v: Value = serde_json::from_str(text).unwrap_or_else(|_| json!({})); + let v = migrate(v); + let mut p: Prefs = match serde_json::from_value(v.clone()) { + Ok(p) => p, + // One bad value shouldn't lose everything: take each page that parses. + Err(_) => { + let mut base = serde_json::to_value(Prefs::default()).unwrap_or_default(); + if let (Some(b), Some(o)) = (base.as_object_mut(), v.as_object()) { + for (k, x) in o { + let mut trial = b.clone(); + trial.insert(k.clone(), x.clone()); + if serde_json::from_value::(Value::Object(trial)).is_ok() { + b.insert(k.clone(), x.clone()); + } + } + } + serde_json::from_value(base).unwrap_or_default() + } + }; + p.normalize(); + p + } + + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).unwrap_or_default() + } + + /// Clamp values into their valid ranges and fill missing labels. + pub fn normalize(&mut self) { + self.version = PREFS_VERSION; + let g = &mut self.general; + g.undo_levels = g.undo_levels.clamp(1, 99); + g.path_point_size = g.path_point_size.clamp(3, 20); + g.recent_items = g.recent_items.clamp(1, 30); + let a = &mut self.auto_save; + a.interval_minutes = a.interval_minutes.clamp(1, 240); + a.max_versions = a.max_versions.clamp(1, 99); + self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0); + let defaults = default_labels(); + self.labels.truncate(defaults.len()); + for d in defaults.iter().skip(self.labels.len()) { + self.labels.push(d.clone()); + } + for (l, d) in self.labels.iter_mut().zip(&defaults) { + if parse_hex(&l.color).is_none() { + l.color = d.color.clone(); + } + } + let m = &mut self.memory; + m.layer_cache_mb = m.layer_cache_mb.clamp(64, 1 << 20); + m.media_cache_mb = m.media_cache_mb.clamp(64, 1 << 20); + m.preview_cache_mb = m.preview_cache_mb.clamp(64, 1 << 20); + self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0); + let n = self.general.recent_items as usize; + self.recent_projects.truncate(n); + } + + /// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything. + pub fn get(&self, key: &str) -> Option { + let v = serde_json::to_value(self).ok()?; + if key.is_empty() { + return Some(v); + } + let ptr = format!("/{}", key.replace('.', "/")); + v.pointer(&ptr).cloned() + } + + /// Set the value at a dotted key. The key must exist and the value must have its type. + pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> { + let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?; + let ptr = format!("/{}", key.replace('.', "/")); + let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?; + let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?; + *slot = value; + let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; + next.normalize(); + *self = next; + Ok(()) + } + + /// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is + /// kept. + pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> { + let d = Prefs::default(); + let Some(page) = page else { + *self = Prefs { recent_projects: std::mem::take(&mut self.recent_projects), ..d }; + return Ok(()); + }; + let keys: Vec<&str> = match page { + "project" => vec!["project", "autoSave"], + p => vec![section_key(p).ok_or_else(|| format!("unknown settings page `{p}`"))?], + }; + let dv = serde_json::to_value(&d).map_err(|e| e.to_string())?; + for k in keys { + if let Some(x) = dv.get(k) { + self.set(k, x.clone())?; + } + } + Ok(()) + } + + /// Display name of a label (Labels settings). + pub fn label_name(&self, l: Label) -> String { + match label_index(l) { + Some(i) => self.labels.get(i).map(|p| p.name.clone()).unwrap_or_else(|| l.name().to_string()), + None => l.name().to_string(), + } + } + + /// sRGB colour of a label (Labels settings). + pub fn label_rgb(&self, l: Label) -> [u8; 3] { + label_index(l).and_then(|i| self.labels.get(i)).and_then(|p| parse_hex(&p.color)).unwrap_or(l.rgb()) + } + + /// A label by its built-in name or its (renamed) display name. + pub fn label_from_name(&self, s: &str) -> Option