simple_editor/
settings.rs

1//! App settings — one JSON file at %APPDATA%\SimpleEditor\settings.json.
2
3use crate::theme::PaletteOverride;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
9#[serde(default)]
10pub struct RecentAsset {
11    pub path: String,
12    /// Unix seconds.
13    pub last_used: u64,
14    pub tags: Vec<String>,
15    /// 0 = none, 1..=8 = LABEL_COLORS index + 1.
16    pub label: u8,
17    /// Pinned entries stay at the top and are never evicted by the cap.
18    pub pinned: bool,
19}
20
21impl Default for RecentAsset {
22    fn default() -> Self {
23        Self { path: String::new(), last_used: 0, tags: Vec::new(), label: 0, pinned: false }
24    }
25}
26
27/// A saved editor layout (egui_tiles tree + popped-out panes), as JSON (see ui/layout.rs).
28#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
29pub struct LayoutProfile {
30    pub name: String,
31    pub json: String,
32}
33
34/// A saved keyframe curve for one property. Key times are normalised to 0..1 of the clip length unless
35/// `absolute` (seconds from the clip start); values are absolute.
36#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
37pub struct CurvePreset {
38    pub name: String,
39    pub keys: Vec<crate::model::Keyframe>,
40    pub absolute: bool,
41}
42
43/// A saved motion: curves for several properties at once ("slide in", "Ken Burns", …).
44/// Property names as in `Clip::props_mut` labels ("Position X", "Scale", …) or "<Effect>: <Param>".
45#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
46pub struct MotionPreset {
47    pub name: String,
48    pub props: Vec<(String, CurvePreset)>,
49}
50
51/// A saved effect chain (a `Vec<Effect>` as JSON) or node graph (a `NodeGraph`), captured from a clip
52/// by engine::presets. Machine-local like the other presets: it lives here, never in the project file.
53#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
54pub struct EffectPreset {
55    pub name: String,
56    pub json: String,
57}
58
59impl EffectPreset {
60    /// Which of the two it holds — a graph serialises to an object, an effect stack to an array.
61    pub fn is_graph(&self) -> bool {
62        self.json.trim_start().starts_with('{')
63    }
64}
65
66/// A reusable group of clips (relative times) + the assets they need, serialised by engine::presets.
67#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
68pub struct Template {
69    pub name: String,
70    pub json: String,
71}
72
73#[derive(Clone, Debug, Serialize, Deserialize)]
74#[serde(default)]
75pub struct Settings {
76    /// Directory containing ffmpeg.exe / ffprobe.exe. Empty = app dir, then PATH.
77    pub ffmpeg_dir: String,
78    /// Directory containing yt-dlp.exe (the optional URL import). Empty = app dir, then PATH.
79    pub ytdlp_dir: String,
80    /// Where URL downloads are written. Empty = the user's Videos folder.
81    pub download_dir: String,
82    /// "auto" (libx264 / libvpx-vp9 by extension) or an ffmpeg encoder name
83    /// ("libx264", "h264_nvenc", "h264_qsv", "h264_amf", "libx265", "hevc_nvenc", ...).
84    pub encoder: String,
85    /// Quality (CRF / CQ), lower = better. 18 is visually lossless for x264.
86    pub crf: u32,
87    /// x264/x265 preset ("ultrafast".."veryslow"); NVENC/QSV/AMF map to their own presets.
88    pub preset: String,
89    pub confirm_overwrite: bool,
90    /// Save (Ctrl+S) over the opened video uses the instant `-c copy` cut when the project is a plain cut
91    /// (cuts snap to keyframes) instead of re-encoding.
92    pub lossless_save: bool,
93    /// Register "Edit with Simple Editor" in the Explorer context menu for videos.
94    pub context_menu: bool,
95    /// "system" | "dark" | "light"
96    pub theme: String,
97    /// "auto" | "mf" | "ffmpeg"  (auto = Media Foundation, ffmpeg fallback)
98    pub decoder: String,
99    /// Preview is rendered at most this wide (pixels) to keep CPU low.
100    pub preview_max_width: u32,
101    pub snap: bool,
102    pub show_library: bool,
103    pub show_inspector: bool,
104    /// Action id -> shortcut text ("Ctrl+Shift+B"); only non-default bindings are stored. "" = unbound.
105    pub hotkeys: BTreeMap<String, String>,
106    pub recent_assets: Vec<RecentAsset>,
107    pub recent_projects: Vec<String>,
108    /// Current editor layout as JSON (empty = default layout).
109    pub layout: String,
110    /// Saved layout profiles (also exportable to / importable from `.sedit-layout` files).
111    pub layout_profiles: Vec<LayoutProfile>,
112    /// Keyframe / motion presets, effect-chain / node-graph presets and clip templates
113    /// (global, reusable across projects — the Presets pane lists all of them).
114    pub curve_presets: Vec<CurvePreset>,
115    pub motion_presets: Vec<MotionPreset>,
116    pub effect_presets: Vec<EffectPreset>,
117    pub templates: Vec<Template>,
118    /// Extra font files (.ttf/.otf) imported by the user (loaded by the text rasterizer + font lists).
119    pub user_fonts: Vec<String>,
120    /// Export: output scaling when the output size differs from the project
121    /// (ffmpeg scale flags: "neighbor" | "bilinear" | "bicubic" | "lanczos" | "area" | "spline").
122    pub export_scaler: String,
123    /// Export: "project" or "WxH".
124    pub export_resolution: String,
125    /// MCP server (AI co-editing) on 127.0.0.1:mcp_port/mcp.
126    pub mcp_enabled: bool,
127    pub mcp_port: u16,
128    // ---- round 3 ----
129    /// Render the preview on the GPU (OpenGL) when a context is available.
130    pub gpu: bool,
131    /// Preview render scale in percent (100 = full canvas; lower is faster).
132    pub preview_quality: u32,
133    /// "Movie mode": play back pre-rendered full-quality frames.
134    pub movie_mode: bool,
135    /// Preview plays background-built all-intra proxies instead of the originals.
136    pub use_proxies: bool,
137    /// Proxy height in pixels (width keeps aspect).
138    pub proxy_height: u32,
139    /// User icon picks: "action.<Action>" / "pane.<Pane>" -> glyph name, or "none" to remove.
140    pub icon_overrides: BTreeMap<String, String>,
141    /// Screen capture defaults.
142    pub capture_fps: u32,
143    pub capture_bitrate_kbps: u32,
144    pub capture_mic: String,
145    pub capture_desktop_audio: bool,
146    pub capture_cursor: bool,
147    /// Start/stop screen recording automatically when the editor loses/gains focus.
148    pub capture_on_blur: bool,
149    pub capture_dir: String,
150    /// Voiceover defaults.
151    pub voice_device: String,
152    pub voice_channels: u32,
153    /// Export-frame defaults ("project" | "2x" | "4x" | "WxH") and image format.
154    pub frame_resolution: String,
155    pub frame_format: String,
156    pub frame_quality: u32,
157    /// Stock image the effects panel renders its thumbnails from (empty = the embedded default).
158    pub effect_thumb_image: String,
159    /// Settings > Appearance: override for the custom-painted (timeline/preview/waveform) palette.
160    /// Default ("system" mode, every colour unset) reproduces today's Windows-derived palette exactly.
161    pub palette: PaletteOverride,
162    /// Directory containing a whisper.cpp binary (whisper-cli.exe). Empty = the cache's `whisper`
163    /// folder, then the app dir, then PATH. Speech-to-text is off until a model is downloaded.
164    pub whisper_dir: String,
165    /// Model the Subtitles pane offers first: a `engine::transcribe::MODELS` name or file name.
166    pub transcribe_model: String,
167}
168
169impl Default for Settings {
170    fn default() -> Self {
171        Self {
172            ffmpeg_dir: String::new(),
173            ytdlp_dir: String::new(),
174            download_dir: String::new(),
175            encoder: "auto".into(),
176            crf: 18,
177            preset: "veryfast".into(),
178            confirm_overwrite: true,
179            lossless_save: false,
180            context_menu: true,
181            theme: "system".into(),
182            decoder: "auto".into(),
183            preview_max_width: 1280,
184            snap: true,
185            show_library: true,
186            show_inspector: true,
187            hotkeys: BTreeMap::new(),
188            recent_assets: Vec::new(),
189            recent_projects: Vec::new(),
190            layout: String::new(),
191            layout_profiles: Vec::new(),
192            curve_presets: Vec::new(),
193            motion_presets: Vec::new(),
194            effect_presets: Vec::new(),
195            templates: Vec::new(),
196            user_fonts: Vec::new(),
197            export_scaler: "lanczos".into(),
198            export_resolution: "project".into(),
199            mcp_enabled: false,
200            mcp_port: 7337,
201            gpu: true,
202            preview_quality: 100,
203            movie_mode: false,
204            use_proxies: true,
205            proxy_height: 720,
206            icon_overrides: BTreeMap::new(),
207            capture_fps: 30,
208            capture_bitrate_kbps: 8000,
209            capture_mic: String::new(),
210            capture_desktop_audio: true,
211            capture_cursor: true,
212            capture_on_blur: false,
213            capture_dir: String::new(),
214            voice_device: String::new(),
215            voice_channels: 1,
216            frame_resolution: "project".into(),
217            frame_format: "png".into(),
218            frame_quality: 92,
219            effect_thumb_image: String::new(),
220            palette: PaletteOverride::default(),
221            whisper_dir: String::new(),
222            transcribe_model: String::new(),
223        }
224    }
225}
226
227impl Settings {
228    /// %APPDATA%\SimpleEditor
229    pub fn dir() -> PathBuf {
230        let base = std::env::var_os("APPDATA").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
231        base.join("SimpleEditor")
232    }
233    /// %LOCALAPPDATA%\SimpleEditor\cache (waveform peaks etc.)
234    pub fn cache_dir() -> PathBuf {
235        let base = std::env::var_os("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(Self::dir);
236        base.join("SimpleEditor").join("cache")
237    }
238    pub fn path() -> PathBuf {
239        Self::dir().join("settings.json")
240    }
241    pub fn load() -> Self {
242        std::fs::read_to_string(Self::path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
243    }
244    /// Writes a temp file then renames, so a failed write can't destroy the previous settings.
245    pub fn save(&self) {
246        let _ = std::fs::create_dir_all(Self::dir());
247        if let Ok(s) = serde_json::to_string_pretty(self) {
248            let tmp = Self::dir().join("settings.json.tmp");
249            if std::fs::write(&tmp, s).is_ok() {
250                let _ = std::fs::rename(tmp, Self::path());
251            }
252        }
253    }
254    pub fn now() -> u64 {
255        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
256    }
257    /// Record a media file as recently used (most recent first; pinned entries first; capped).
258    /// Keeps the entry's tags/label/pin if it already existed.
259    pub fn touch_recent(&mut self, path: &str) {
260        let existing = self.recent_assets.iter().position(|r| r.path.eq_ignore_ascii_case(path));
261        let mut entry = existing.map(|i| self.recent_assets.remove(i)).unwrap_or_default();
262        entry.path = path.to_string();
263        entry.last_used = Self::now();
264        self.recent_assets.insert(0, entry);
265        self.sort_recent();
266        // evict oldest unpinned beyond the cap
267        let mut n = self.recent_assets.len();
268        while n > 200 {
269            if let Some(i) = self.recent_assets.iter().rposition(|r| !r.pinned) {
270                self.recent_assets.remove(i);
271                n -= 1;
272            } else {
273                break;
274            }
275        }
276    }
277    pub fn remove_recent(&mut self, path: &str) {
278        self.recent_assets.retain(|r| !r.path.eq_ignore_ascii_case(path));
279    }
280    /// Pinned first, then most recently used.
281    pub fn sort_recent(&mut self) {
282        self.recent_assets.sort_by(|a, b| b.pinned.cmp(&a.pinned).then(b.last_used.cmp(&a.last_used)));
283    }
284    pub fn touch_recent_project(&mut self, path: &str) {
285        self.recent_projects.retain(|r| !r.eq_ignore_ascii_case(path));
286        self.recent_projects.insert(0, path.to_string());
287        self.recent_projects.truncate(20);
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    #[test]
295    fn recent_keeps_tags_and_pins() {
296        let mut s = Settings::default();
297        s.touch_recent("a.mp4");
298        s.recent_assets[0].tags.push("x".into());
299        s.recent_assets[0].pinned = true;
300        s.touch_recent("b.mp4");
301        s.touch_recent("A.MP4"); // same file, case-insensitive
302        assert_eq!(s.recent_assets.len(), 2);
303        assert_eq!(s.recent_assets[0].path, "A.MP4");
304        assert!(s.recent_assets[0].pinned && s.recent_assets[0].tags == vec!["x"]);
305        s.remove_recent("b.mp4");
306        assert_eq!(s.recent_assets.len(), 1);
307    }
308
309    #[test]
310    fn effect_thumb_image_round_trips() {
311        assert!(Settings::default().effect_thumb_image.is_empty(), "empty = the embedded default");
312        let mut s = Settings::default();
313        s.effect_thumb_image = r"C:\pics\stock.png".into();
314        let back: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
315        assert_eq!(back.effect_thumb_image, s.effect_thumb_image);
316        // an old settings file without the field still loads, back to the default
317        let old: Settings = serde_json::from_str("{}").unwrap();
318        assert!(old.effect_thumb_image.is_empty());
319    }
320
321    #[test]
322    fn palette_override_round_trips() {
323        assert_eq!(Settings::default().palette, PaletteOverride::default(), "unset = today's behaviour");
324        let mut s = Settings::default();
325        s.palette.mode = "custom".into();
326        s.palette.accent = Some([200, 30, 40]);
327        let back: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
328        assert_eq!(back.palette, s.palette);
329        // an old settings file without the field still loads, back to the default (system, no overrides)
330        let old: Settings = serde_json::from_str("{}").unwrap();
331        assert_eq!(old.palette, PaletteOverride::default());
332    }
333}