Skip to main content

mcraw_tui/
preset.rs

1use std::path::PathBuf;
2
3use crate::color::{ColorSpace, TransferFunction};
4use crate::export::{
5    Av1Profile, CodecFamily, DnxhrProfile, H264Profile, HevcProfile, ProResProfile,
6    RateControl, Vp9Profile,
7};
8
9/// A named bundle of every export setting that the user can configure.
10///
11/// `ExportPreset` is the on-disk representation used by the preset save/load
12/// feature. It captures *all* knob positions so applying a preset is a
13/// straight field copy.
14#[derive(Debug, Clone)]
15pub struct ExportPreset {
16    pub name: String,
17
18    pub color_space: ColorSpace,
19    pub transfer_function: TransferFunction,
20    pub codec_family: CodecFamily,
21
22    pub prores_profile: ProResProfile,
23    pub dnxhr_profile: DnxhrProfile,
24    pub hevc_profile: HevcProfile,
25    pub h264_profile: H264Profile,
26    pub av1_profile: Av1Profile,
27    pub vp9_profile: Vp9Profile,
28
29    pub rate_control: RateControl,
30    /// Optional export folder override. When `None`, the runtime default
31    /// (file's parent directory) is used.
32    pub export_folder: Option<PathBuf>,
33}
34
35/// On-disk file format. Held as a thin wrapper so we can evolve the JSON
36/// schema without breaking existing `presets.json` files.
37#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
38struct PresetFile {
39    #[serde(default)]
40    schema_version: u32,
41    #[serde(default)]
42    presets: Vec<StoredPreset>,
43}
44
45/// Flat, stringly-typed mirror of `ExportPreset` for JSON serialization.
46/// We keep this separate from `ExportPreset` so that future changes to
47/// the in-memory representation (e.g. a richer rate-control type) don't
48/// silently break saved files.
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50struct StoredPreset {
51    name: String,
52
53    color_space: String,
54    transfer_function: String,
55    codec_family: String,
56
57    #[serde(default)]
58    prores_profile: Option<String>,
59    #[serde(default)]
60    dnxhr_profile: Option<String>,
61    #[serde(default)]
62    hevc_profile: Option<String>,
63    #[serde(default)]
64    h264_profile: Option<String>,
65    #[serde(default)]
66    av1_profile: Option<String>,
67    #[serde(default)]
68    vp9_profile: Option<String>,
69
70    /// Either a `RateControl` name (e.g. "Lossless", "High Quality", "Custom"),
71    /// or "Custom:<value>" for the custom string variant.
72    rate_control: String,
73    #[serde(default)]
74    export_folder: Option<PathBuf>,
75}
76
77const PRESETS_SCHEMA_VERSION: u32 = 1;
78
79impl ExportPreset {
80    /// Snapshot the current settings into a named preset.
81    pub fn snapshot(
82        name: String,
83        color_space: ColorSpace,
84        transfer_function: TransferFunction,
85        codec_family: CodecFamily,
86        prores_profile: ProResProfile,
87        dnxhr_profile: DnxhrProfile,
88        hevc_profile: HevcProfile,
89        h264_profile: H264Profile,
90        av1_profile: Av1Profile,
91        vp9_profile: Vp9Profile,
92        rate_control: RateControl,
93        export_folder: Option<PathBuf>,
94    ) -> Self {
95        Self {
96            name,
97            color_space,
98            transfer_function,
99            codec_family,
100            prores_profile,
101            dnxhr_profile,
102            hevc_profile,
103            h264_profile,
104            av1_profile,
105            vp9_profile,
106            rate_control,
107            export_folder,
108        }
109    }
110
111    /// Path to the user's presets file. Same directory as `favourites.json`.
112    pub fn presets_file() -> Option<PathBuf> {
113        let mut dir = dirs::config_dir()?;
114        dir.push("mcraw-tui");
115        std::fs::create_dir_all(&dir).ok()?;
116        dir.push("presets.json");
117        Some(dir)
118    }
119
120    /// Read all stored presets. Missing/corrupt files yield an empty list.
121    pub fn load_all() -> Vec<ExportPreset> {
122        let path = match Self::presets_file() {
123            Some(p) => p,
124            None => return Vec::new(),
125        };
126        let data = match std::fs::read_to_string(&path) {
127            Ok(d) => d,
128            Err(_) => return Vec::new(),
129        };
130        let parsed: PresetFile = match serde_json::from_str(&data) {
131            Ok(p) => p,
132            Err(e) => {
133                tracing::warn!("presets.json parse failed, ignoring: {}", e);
134                return Vec::new();
135            }
136        };
137        parsed.presets.into_iter().filter_map(Self::from_stored).collect()
138    }
139
140    /// Write the full list of presets, replacing any previous contents.
141    pub fn save_all(presets: &[ExportPreset]) {
142        let path = match Self::presets_file() {
143            Some(p) => p,
144            None => return,
145        };
146        let stored: Vec<StoredPreset> = presets.iter().map(Self::to_stored).collect();
147        let file = PresetFile { schema_version: PRESETS_SCHEMA_VERSION, presets: stored };
148        match serde_json::to_string_pretty(&file) {
149            Ok(data) => {
150                if let Err(e) = std::fs::write(&path, data) {
151                    tracing::warn!("failed to write presets.json: {}", e);
152                }
153            }
154            Err(e) => tracing::warn!("failed to serialize presets: {}", e),
155        }
156    }
157
158    /// Replace a preset of the same name (or append). Preserves the order of
159    /// the existing list with the matching slot updated.
160    pub fn upsert(list: &mut Vec<ExportPreset>, preset: ExportPreset) {
161        if let Some(pos) = list.iter().position(|p| p.name == preset.name) {
162            list[pos] = preset;
163        } else {
164            list.push(preset);
165        }
166    }
167
168    /// Remove a preset by name. Returns true if anything was removed.
169    pub fn remove_by_name(list: &mut Vec<ExportPreset>, name: &str) -> bool {
170        let before = list.len();
171        list.retain(|p| p.name != name);
172        list.len() != before
173    }
174
175    fn to_stored(p: &ExportPreset) -> StoredPreset {
176        StoredPreset {
177            name: p.name.clone(),
178            color_space: cs_to_str(p.color_space).to_string(),
179            transfer_function: tf_to_str(p.transfer_function).to_string(),
180            codec_family: p.codec_family.name().to_string(),
181            prores_profile: Some(p.prores_profile.name().to_string()),
182            dnxhr_profile: Some(p.dnxhr_profile.name().to_string()),
183            hevc_profile: Some(p.hevc_profile.name().to_string()),
184            h264_profile: Some(p.h264_profile.name().to_string()),
185            av1_profile: Some(p.av1_profile.name().to_string()),
186            vp9_profile: Some(p.vp9_profile.name().to_string()),
187            rate_control: rate_to_str(&p.rate_control),
188            export_folder: p.export_folder.clone(),
189        }
190    }
191
192    fn from_stored(s: StoredPreset) -> Option<ExportPreset> {
193        let color_space = cs_from_str(&s.color_space)?;
194        let transfer_function = tf_from_str(&s.transfer_function)?;
195        let codec_family = codec_from_str(&s.codec_family)?;
196        let prores_profile = s.prores_profile.as_deref()
197            .and_then(prores_from_str).unwrap_or_default();
198        let dnxhr_profile = s.dnxhr_profile.as_deref()
199            .and_then(dnxhr_from_str).unwrap_or_default();
200        let hevc_profile = s.hevc_profile.as_deref()
201            .and_then(hevc_from_str).unwrap_or_default();
202        let h264_profile = s.h264_profile.as_deref()
203            .and_then(h264_from_str).unwrap_or_default();
204        let av1_profile = s.av1_profile.as_deref()
205            .and_then(av1_from_str).unwrap_or_default();
206        let vp9_profile = s.vp9_profile.as_deref()
207            .and_then(vp9_from_str).unwrap_or_default();
208        let rate_control = rate_from_str(&s.rate_control);
209
210        Some(ExportPreset {
211            name: s.name,
212            color_space,
213            transfer_function,
214            codec_family,
215            prores_profile,
216            dnxhr_profile,
217            hevc_profile,
218            h264_profile,
219            av1_profile,
220            vp9_profile,
221            rate_control,
222            export_folder: s.export_folder,
223        })
224    }
225}
226
227// ---------------------------------------------------------------------------
228// Enum <-> string conversion helpers
229// ---------------------------------------------------------------------------
230//
231// We avoid the `strum` dependency and write the conversions by hand. The
232// lookups are `O(n)` but `n` is tiny and these run only on save/load.
233
234fn cs_to_str(cs: ColorSpace) -> &'static str {
235    // Match the display name so users see consistent strings in JSON.
236    cs.name()
237}
238
239fn cs_from_str(s: &str) -> Option<ColorSpace> {
240    ColorSpace::all().iter().copied().find(|c| c.name() == s)
241}
242
243fn tf_to_str(tf: TransferFunction) -> &'static str {
244    tf.name()
245}
246
247fn tf_from_str(s: &str) -> Option<TransferFunction> {
248    TransferFunction::all().iter().copied().find(|t| t.name() == s)
249}
250
251fn codec_from_str(s: &str) -> Option<CodecFamily> {
252    CodecFamily::all().iter().copied().find(|c| c.name() == s)
253}
254
255fn prores_from_str(s: &str) -> Option<ProResProfile> {
256    ProResProfile::all().iter().copied().find(|p| p.name() == s)
257}
258
259fn dnxhr_from_str(s: &str) -> Option<DnxhrProfile> {
260    DnxhrProfile::all().iter().copied().find(|p| p.name() == s)
261}
262
263fn hevc_from_str(s: &str) -> Option<HevcProfile> {
264    HevcProfile::all().iter().copied().find(|p| p.name() == s)
265}
266
267fn h264_from_str(s: &str) -> Option<H264Profile> {
268    H264Profile::all().iter().copied().find(|p| p.name() == s)
269}
270
271fn av1_from_str(s: &str) -> Option<Av1Profile> {
272    Av1Profile::all().iter().copied().find(|p| p.name() == s)
273}
274
275fn vp9_from_str(s: &str) -> Option<Vp9Profile> {
276    Vp9Profile::all().iter().copied().find(|p| p.name() == s)
277}
278
279fn rate_to_str(r: &RateControl) -> String {
280    r.name()
281}
282
283fn rate_from_str(s: &str) -> RateControl {
284    // Stored form is the display name produced by `RateControl::name()`.
285    // For the named variants that's e.g. "Lossless", "High Quality",
286    // "Master 400M", "Standard 150M". For the custom variant the name
287    // is "Custom: [value]" (or "Custom: []" for an empty value).
288    if let Some(inner) = s.strip_prefix("Custom: [").and_then(|x| x.strip_suffix(']')) {
289        return RateControl::Custom(inner.to_string());
290    }
291    match s {
292        "Lossless" => RateControl::Lossless,
293        "High Quality" => RateControl::High,
294        "Standard" => RateControl::Standard,
295        "Master 400M" => RateControl::Master400M,
296        "Standard 150M" => RateControl::Standard150M,
297        _ => RateControl::Lossless, // Forward-compat fallback.
298    }
299}
300
301// ---------------------------------------------------------------------------
302// Default impls for codec-profile enums
303// ---------------------------------------------------------------------------
304//
305// The profile enums in `export.rs` are `Copy + PartialEq` but do not derive
306// `Default`. Local impls let us write `unwrap_or_default()` when a stored
307// preset omits a field (forward-compatibility for old presets files).
308
309impl Default for ProResProfile { fn default() -> Self { ProResProfile::HQ } }
310impl Default for DnxhrProfile { fn default() -> Self { DnxhrProfile::HQX } }
311impl Default for HevcProfile { fn default() -> Self { HevcProfile::Main10_420 } }
312impl Default for H264Profile { fn default() -> Self { H264Profile::Main8bit } }
313impl Default for Av1Profile { fn default() -> Self { Av1Profile::Profile0_420_10bit } }
314impl Default for Vp9Profile { fn default() -> Self { Vp9Profile::Profile2_420_10bit } }
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn sample() -> ExportPreset {
321        ExportPreset::snapshot(
322            "ARRIRAW-bake".to_string(),
323            ColorSpace::ARRIWideGamut4,
324            TransferFunction::ARRIlog4,
325            CodecFamily::ProRes,
326            ProResProfile::HQ,
327            DnxhrProfile::HQX,
328            HevcProfile::Main10_420,
329            H264Profile::Main8bit,
330            Av1Profile::Profile0_420_10bit,
331            Vp9Profile::Profile2_420_10bit,
332            RateControl::Lossless,
333            Some(PathBuf::from("/tmp/out")),
334        )
335    }
336
337    #[test]
338    fn roundtrip_through_stored() {
339        let original = sample();
340        let stored = ExportPreset::to_stored(&original);
341        let recovered = ExportPreset::from_stored(stored).expect("from_stored");
342        assert_eq!(recovered.name, original.name);
343        assert_eq!(recovered.color_space, original.color_space);
344        assert_eq!(recovered.transfer_function, original.transfer_function);
345        assert_eq!(recovered.codec_family, original.codec_family);
346        assert_eq!(recovered.prores_profile, original.prores_profile);
347        assert_eq!(recovered.export_folder, original.export_folder);
348    }
349
350    #[test]
351    fn upsert_replaces_same_name() {
352        let mut list = vec![sample()];
353        let mut updated = sample();
354        updated.color_space = ColorSpace::Rec709;
355        ExportPreset::upsert(&mut list, updated);
356        assert_eq!(list.len(), 1);
357        assert_eq!(list[0].color_space, ColorSpace::Rec709);
358    }
359
360    #[test]
361    fn upsert_appends_new_name() {
362        let mut list = vec![sample()];
363        let mut new_one = sample();
364        new_one.name = "ProRes-4444".to_string();
365        ExportPreset::upsert(&mut list, new_one);
366        assert_eq!(list.len(), 2);
367    }
368
369    #[test]
370    fn remove_by_name_drops_matching() {
371        let mut list = vec![sample()];
372        let mut second = sample();
373        second.name = "second".to_string();
374        list.push(second);
375        assert!(ExportPreset::remove_by_name(&mut list, "second"));
376        assert_eq!(list.len(), 1);
377        assert!(!ExportPreset::remove_by_name(&mut list, "missing"));
378    }
379
380    #[test]
381    fn custom_rate_roundtrip() {
382        let mut p = sample();
383        p.rate_control = RateControl::Custom("80M".to_string());
384        let stored = ExportPreset::to_stored(&p);
385        let recovered = ExportPreset::from_stored(stored).expect("from_stored");
386        if let RateControl::Custom(s) = recovered.rate_control {
387            assert_eq!(s, "80M");
388        } else {
389            panic!("expected Custom, got {:?}", recovered.rate_control);
390        }
391    }
392}