simple_editor\ui/
frame_ui.rs

1//! "Export Frame" window: save the frame at the playhead as an image.
2//! Options: resolution (Project / 2x / 4x / Custom W x H), upscaling mode (`Scaler` for the render plus
3//! the ffmpeg flag for the final resize), format (PNG / JPG / WebP with a quality slider for the lossy
4//! ones), and "Render with effects" vs "Source frame only" (the decoded frame of the top-most clip under
5//! the playhead, no compositing). Non-modal; returns the chosen options when the user confirms.
6
7use crate::model::Scaler;
8// the resize flags are the export window's list: a scaler picked there (area / spline) must not
9// silently turn into something else here
10use crate::ui::combo;
11use crate::ui::export_ui::SCALERS as RESIZE;
12use eframe::egui;
13use std::path::PathBuf;
14
15#[derive(Clone, Debug)]
16pub struct FrameExport {
17    pub out: PathBuf,
18    pub size: (u32, u32),
19    pub scaler: Scaler,
20    /// ffmpeg scale flag for the final resize (one of `ui::export_ui::SCALERS`).
21    pub resize: String,
22    /// false = the raw source frame under the playhead.
23    pub with_effects: bool,
24    /// 1..100 for JPG/WebP.
25    pub quality: u32,
26}
27
28#[derive(Default)]
29pub struct FrameUi {
30    pub open: bool,
31    pub preset: String,
32    pub custom: (u32, u32),
33    pub with_effects: bool,
34    pub quality: u32,
35    /// Compositor resampling used while rendering (defaults to the project's).
36    pub scaler: Scaler,
37    /// ffmpeg scale flag for the final resize.
38    pub resize: String,
39}
40
41const PRESETS: [(&str, &str); 4] = [("project", "Project"), ("2x", "2 ×"), ("4x", "4 ×"), ("custom", "Custom")];
42const FORMATS: [(&str, &str); 3] = [("png", "PNG (lossless)"), ("jpg", "JPG"), ("webp", "WebP")];
43
44/// Output size for a preset. `custom` is clamped to something ffmpeg will accept.
45fn size_of(pw: u32, ph: u32, preset: &str, custom: (u32, u32)) -> (u32, u32) {
46    match preset {
47        "2x" => (pw * 2, ph * 2),
48        "4x" => (pw * 4, ph * 4),
49        "custom" => (custom.0.clamp(16, 16384), custom.1.clamp(16, 16384)),
50        _ => (pw.max(1), ph.max(1)),
51    }
52}
53
54/// What `settings.frame_resolution` remembers: a preset name, or "WxH" for a custom size (`init` parses
55/// both — storing the literal "custom" would lose the size).
56fn stored_resolution(state: &FrameUi) -> String {
57    if state.preset == "custom" {
58        format!("{}x{}", state.custom.0, state.custom.1)
59    } else {
60        state.preset.clone()
61    }
62}
63
64fn lossy(format: &str) -> bool {
65    format != "png"
66}
67
68pub fn show(
69    ctx: &egui::Context,
70    state: &mut FrameUi,
71    project: &crate::model::Project,
72    settings: &mut crate::settings::Settings,
73) -> Option<FrameExport> {
74    if !state.open {
75        return None;
76    }
77    if state.preset.is_empty() {
78        init(state, project, settings);
79    }
80    let mut out = None;
81    let mut open = state.open;
82    egui::Window::new("Export Frame").open(&mut open).resizable(false).default_width(300.0).show(ctx, |ui| {
83        let size = egui::Grid::new("frame_opts")
84            .num_columns(2)
85            .spacing([12.0, 6.0])
86            .show(ui, |ui| {
87                ui.label("Resolution");
88                if combo(ui, "frame_res", &mut state.preset, &PRESETS, None) {
89                    settings.frame_resolution = stored_resolution(state);
90                }
91                ui.end_row();
92                if state.preset == "custom" {
93                    ui.label("Size");
94                    ui.horizontal(|ui| {
95                        let (mut w, mut h) = state.custom;
96                        ui.add(egui::DragValue::new(&mut w).range(16..=16384));
97                        ui.label("×");
98                        ui.add(egui::DragValue::new(&mut h).range(16..=16384));
99                        state.custom = (w, h);
100                    });
101                    ui.end_row();
102                }
103                let size = size_of(project.width, project.height, &state.preset, state.custom);
104                ui.label("Output");
105                ui.weak(format!("{}×{}", size.0, size.1));
106                ui.end_row();
107
108                ui.label("Render");
109                egui::ComboBox::from_id_salt("frame_scaler").selected_text(state.scaler.name()).show_ui(ui, |ui| {
110                    for s in Scaler::ALL {
111                        ui.selectable_value(&mut state.scaler, s, s.name());
112                    }
113                });
114                ui.end_row();
115
116                ui.label("Resize");
117                ui.horizontal(|ui| {
118                    combo(ui, "frame_resize", &mut state.resize, &RESIZE, None);
119                    if size == (project.width, project.height) {
120                        ui.weak("(same size)");
121                    }
122                });
123                ui.end_row();
124
125                ui.label("Format");
126                combo(ui, "frame_format", &mut settings.frame_format, &FORMATS, None);
127                ui.end_row();
128
129                if lossy(&settings.frame_format) {
130                    ui.label("Quality");
131                    ui.add(egui::Slider::new(&mut state.quality, 1..=100));
132                    ui.end_row();
133                }
134                size
135            })
136            .inner;
137
138        ui.horizontal(|ui| {
139            ui.radio_value(&mut state.with_effects, true, "Render with effects");
140            ui.radio_value(&mut state.with_effects, false, "Source frame only");
141        });
142        if !state.with_effects {
143            ui.weak("The decoded frame of the top-most clip under the playhead, at its own size.");
144        }
145        ui.add_space(4.0);
146        if ui.button("Save Frame…").clicked() {
147            settings.frame_quality = state.quality;
148            settings.frame_resolution = stored_resolution(state);
149            settings.save();
150            if let Some(path) = pick(project, &settings.frame_format) {
151                out = Some(FrameExport {
152                    out: path,
153                    size,
154                    scaler: state.scaler,
155                    resize: state.resize.clone(),
156                    with_effects: state.with_effects,
157                    quality: state.quality.clamp(1, 100),
158                });
159            }
160        }
161    });
162    state.open = open && out.is_none();
163    out
164}
165
166fn init(state: &mut FrameUi, project: &crate::model::Project, settings: &crate::settings::Settings) {
167    state.custom = (project.width, project.height);
168    state.scaler = project.scaler;
169    state.quality = settings.frame_quality.clamp(1, 100);
170    state.with_effects = true;
171    if state.resize.is_empty() {
172        state.resize = if RESIZE.iter().any(|(v, _)| *v == settings.export_scaler) {
173            settings.export_scaler.clone()
174        } else {
175            "lanczos".into()
176        };
177    }
178    let r = settings.frame_resolution.as_str();
179    state.preset = if PRESETS.iter().any(|(v, _)| *v == r) {
180        r.to_string()
181    } else if let Some((w, h)) = r.split_once('x').and_then(|(w, h)| Some((w.parse().ok()?, h.parse().ok()?))) {
182        state.custom = (w, h);
183        "custom".into()
184    } else {
185        "project".into()
186    };
187}
188
189fn pick(project: &crate::model::Project, format: &str) -> Option<PathBuf> {
190    let ext = if format.is_empty() { "png" } else { format };
191    rfd::FileDialog::new()
192        .add_filter(ext.to_uppercase(), &[ext])
193        .set_file_name(format!("{}-frame.{ext}", project.name))
194        .save_file()
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::model::Project;
201    use crate::settings::Settings;
202
203    #[test]
204    fn sizes() {
205        assert_eq!(size_of(1920, 1080, "project", (0, 0)), (1920, 1080));
206        assert_eq!(size_of(1920, 1080, "2x", (0, 0)), (3840, 2160));
207        assert_eq!(size_of(1920, 1080, "4x", (0, 0)), (7680, 4320));
208        assert_eq!(size_of(1920, 1080, "custom", (640, 4)), (640, 16), "custom sizes are clamped");
209        assert_eq!(size_of(1920, 1080, "", (0, 0)), (1920, 1080), "an unknown preset is the project size");
210    }
211
212    #[test]
213    fn init_from_settings() {
214        let p = Project::new();
215        let mut s = Settings::default();
216        let mut st = FrameUi::default();
217        init(&mut st, &p, &s);
218        assert_eq!(st.preset, "project");
219        assert_eq!(st.resize, "lanczos");
220        assert_eq!(st.quality, s.frame_quality);
221        // a stored "WxH" comes back as a custom size
222        s.frame_resolution = "800x600".into();
223        s.export_scaler = "bicubic".into();
224        let mut st = FrameUi::default();
225        init(&mut st, &p, &s);
226        assert_eq!((st.preset.as_str(), st.custom), ("custom", (800, 600)));
227        assert_eq!(st.resize, "bicubic");
228        // every export scaler is offered here too — no silent downgrade to lanczos
229        s.export_scaler = "spline".into();
230        let mut st = FrameUi::default();
231        init(&mut st, &p, &s);
232        assert_eq!(st.resize, "spline");
233        s.export_scaler = "nonsense".into();
234        let mut st = FrameUi::default();
235        init(&mut st, &p, &s);
236        assert_eq!(st.resize, "lanczos");
237    }
238
239    /// A custom size survives a restart: settings store "WxH", not the literal "custom".
240    #[test]
241    fn custom_size_round_trips_through_settings() {
242        let p = Project::new();
243        let mut s = Settings::default();
244        let st = FrameUi { preset: "custom".into(), custom: (3840, 2160), ..Default::default() };
245        s.frame_resolution = stored_resolution(&st);
246        assert_eq!(s.frame_resolution, "3840x2160");
247        let mut back = FrameUi::default();
248        init(&mut back, &p, &s);
249        assert_eq!((back.preset.as_str(), back.custom), ("custom", (3840, 2160)));
250        // a plain preset still stores its own name
251        let st = FrameUi { preset: "2x".into(), ..Default::default() };
252        assert_eq!(stored_resolution(&st), "2x");
253    }
254
255    /// Headless: the window lays out for every preset and confirms nothing on its own.
256    #[test]
257    fn show_headless() {
258        let ctx = egui::Context::default();
259        let p = Project::new();
260        let mut s = Settings::default();
261        for preset in ["project", "2x", "custom"] {
262            for format in ["png", "jpg"] {
263                s.frame_format = format.into();
264                let mut st = FrameUi { open: true, ..Default::default() };
265                st.preset = preset.into();
266                st.custom = (640, 480);
267                let _ = ctx.run(egui::RawInput::default(), |ctx| assert!(show(ctx, &mut st, &p, &mut s).is_none()));
268                assert!(st.open);
269            }
270        }
271    }
272}