simple_editor\ui/
export_ui.rs

1//! Export window (non-blocking egui::Window "Export"): resolution (Project / 720p / 1080p / 1440p / 4K /
2//! Custom W×H, keeping the project aspect unless unlocked), scaler for up/downscaling (Nearest, Bilinear,
3//! Bicubic, Lanczos, Area, Spline → ffmpeg flags), encoder (auto + detected), quality CRF, preset, an
4//! "audio only" hint by extension, and the Fast Lossless Cut option when `export::lossless_segments` applies
5//! (with the keyframe-accuracy note). "Export…" opens the save dialog and returns the options; the window
6//! stays usable while an export runs (progress + cancel are shown by the app). Settings remember the last
7//! scaler/resolution. Returns Some(options) when the user confirmed an export this frame.
8
9use crate::engine::export::{self, ExportOptions};
10use crate::media::Backend;
11use crate::model::Project;
12use crate::settings::Settings;
13use crate::ui::{combo, encoder_options, ENCODER_PRESETS};
14use eframe::egui;
15use std::path::Path;
16
17#[derive(Default)]
18pub struct ExportUi {
19    pub open: bool,
20    /// "project" | "720" | "1080" | "1440" | "2160" | "custom"
21    pub preset: String,
22    pub custom: (u32, u32),
23    pub lossless: bool,
24    /// Off by default: exports strip metadata unless the user turns this on.
25    pub metadata_on: bool,
26    /// Editable `(key, value)` rows, seeded from the project the first time it is turned on.
27    pub metadata: Vec<(String, String)>,
28}
29
30pub struct ExportChoice {
31    pub opts: ExportOptions,
32    pub lossless: bool,
33}
34
35const RES_PRESETS: [(&str, &str); 6] = [
36    ("project", "Project"),
37    ("720", "720p"),
38    ("1080", "1080p"),
39    ("1440", "1440p"),
40    ("2160", "4K"),
41    ("custom", "Custom"),
42];
43/// ffmpeg `scale` flags offered anywhere an export resizes (also used by the Export Frame window).
44pub const SCALERS: [(&str, &str); 6] = [
45    ("neighbor", "Nearest"),
46    ("bilinear", "Bilinear"),
47    ("bicubic", "Bicubic"),
48    ("lanczos", "Lanczos"),
49    ("area", "Area"),
50    ("spline", "Spline"),
51];
52
53/// Output size for a resolution preset: None = project size. Height presets derive the width from the
54/// project aspect (rounded to even).
55fn preset_size(pw: u32, ph: u32, preset: &str, custom: (u32, u32)) -> Option<(u32, u32)> {
56    match preset {
57        "project" | "" => None,
58        "custom" => Some((custom.0.max(16), custom.1.max(16))),
59        h => {
60            let h: u32 = h.parse().ok()?;
61            let w = (pw as f64 / ph.max(1) as f64 * h as f64).round() as u32;
62            let out = (w + (w & 1), h);
63            if out == (pw, ph) {
64                None
65            } else {
66                Some(out)
67            }
68        }
69    }
70}
71
72pub fn show(
73    ctx: &egui::Context,
74    state: &mut ExportUi,
75    project: &Project,
76    settings: &mut Settings,
77    encoders: &[String],
78    exporting: bool,
79) -> Option<ExportChoice> {
80    if !state.open {
81        return None;
82    }
83    if state.preset.is_empty() {
84        init_from_settings(state, settings, project);
85    }
86    let mut out = None;
87    let mut open = state.open;
88    egui::Window::new("Export").open(&mut open).resizable(false).default_width(300.0).show(ctx, |ui| {
89        egui::Grid::new("export_opts").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
90            ui.label("Resolution");
91            let mut changed = combo(ui, "export_res", &mut state.preset, &RES_PRESETS, None);
92            ui.end_row();
93            if state.preset == "custom" {
94                ui.label("Size");
95                ui.horizontal(|ui| {
96                    let (mut w, mut h) = state.custom;
97                    changed |= ui.add(egui::DragValue::new(&mut w).range(16..=8192)).changed();
98                    ui.label("×");
99                    changed |= ui.add(egui::DragValue::new(&mut h).range(16..=8192)).changed();
100                    state.custom = (w, h);
101                });
102                ui.end_row();
103            }
104            let size = preset_size(project.width, project.height, &state.preset, state.custom);
105            ui.label("Output");
106            let (w, h) = size.unwrap_or((project.width, project.height));
107            ui.weak(format!("{w}×{h}"));
108            ui.end_row();
109            if changed {
110                settings.export_resolution =
111                    if state.preset == "project" { "project".into() } else { format!("{w}x{h}") };
112            }
113
114            ui.label("Scaler");
115            ui.horizontal(|ui| {
116                combo(ui, "export_scaler", &mut settings.export_scaler, &SCALERS, None);
117                if size.is_none() {
118                    ui.weak("(same size)");
119                }
120            });
121            ui.end_row();
122
123            ui.label("Encoder");
124            let opts: Vec<(&str, &str)> = std::iter::once(("auto", "auto"))
125                .chain(encoder_options(encoders).into_iter().map(|e| (e, e)))
126                .collect();
127            combo(ui, "export_encoder", &mut settings.encoder, &opts, None);
128            ui.end_row();
129
130            ui.label("Quality (CRF)");
131            ui.horizontal(|ui| {
132                ui.add(egui::DragValue::new(&mut settings.crf).range(0..=51));
133                ui.weak("lower = better");
134            });
135            ui.end_row();
136
137            ui.label("Preset");
138            let opts: Vec<(&str, &str)> = ENCODER_PRESETS.iter().map(|p| (*p, *p)).collect();
139            combo(ui, "export_preset", &mut settings.preset, &opts, None);
140            ui.end_row();
141        });
142
143        ui.add_space(2.0);
144        if ui.checkbox(&mut state.metadata_on, "Write metadata").changed() && state.metadata.is_empty() {
145            state.metadata = vec![
146                ("title".into(), project.name.clone()),
147                ("artist".into(), String::new()),
148                ("comment".into(), String::new()),
149            ];
150        }
151        if state.metadata_on {
152            let mut drop: Option<usize> = None;
153            for (i, (k, v)) in state.metadata.iter_mut().enumerate() {
154                ui.horizontal(|ui| {
155                    ui.add(egui::TextEdit::singleline(k).desired_width(70.0).hint_text("key"));
156                    ui.add(egui::TextEdit::singleline(v).desired_width(140.0).hint_text("value"));
157                    if ui.small_button("\u{2715}").on_hover_text("Remove").clicked() {
158                        drop = Some(i);
159                    }
160                });
161            }
162            if let Some(i) = drop {
163                state.metadata.remove(i);
164            }
165            if ui.small_button("+ field").clicked() {
166                state.metadata.push((String::new(), String::new()));
167            }
168        } else {
169            ui.weak("No title, encoder or creation time is written.");
170        }
171        ui.add_space(2.0);
172
173        if export::lossless_segments(project).is_some() {
174            ui.checkbox(&mut state.lossless, "Fast lossless cut (-c copy)");
175            if state.lossless {
176                ui.weak("Instant, no re-encode; cuts snap to keyframes (may shift up to a few frames).");
177            }
178        } else {
179            state.lossless = false;
180        }
181        ui.weak("Audio extensions (mp3, wav, m4a, flac) export audio only; gif has no audio.");
182        if settings.gpu || settings.preview_quality < 100 {
183            // Settings ▸ Performance only changes what you watch, never what is written
184            ui.weak("GPU preview and preview quality do not change the exported picture.");
185        }
186        ui.add_space(4.0);
187        ui.horizontal(|ui| {
188            let can = !exporting && !project.is_empty();
189            if ui.add_enabled(can, egui::Button::new("Export…")).clicked() {
190                settings.save();
191                if let Some(choice) = pick_and_build(state, project, settings) {
192                    out = Some(choice);
193                }
194            }
195            if exporting {
196                ui.weak("export running…");
197            }
198        });
199    });
200    state.open = open;
201    if !open {
202        settings.save(); // the window's options are settings — keep them when it closes without exporting
203    }
204    out
205}
206
207fn init_from_settings(state: &mut ExportUi, settings: &Settings, project: &Project) {
208    state.custom = (project.width, project.height);
209    let r = settings.export_resolution.as_str();
210    if r == "project" || r.is_empty() {
211        state.preset = "project".into();
212        return;
213    }
214    let parsed = r.split_once('x').and_then(|(w, h)| Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?)));
215    let Some((w, h)) = parsed else {
216        state.preset = "project".into();
217        return;
218    };
219    state.custom = (w, h);
220    // matches a height preset with the project aspect? use the preset, else custom
221    let named = ["720", "1080", "1440", "2160"]
222        .iter()
223        .find(|p| preset_size(project.width, project.height, p, (0, 0)) == Some((w, h)));
224    state.preset = named.map(|p| (*p).to_string()).unwrap_or_else(|| "custom".into());
225}
226
227/// Save dialog (same filters as the app's Export) → ExportChoice.
228fn pick_and_build(state: &ExportUi, project: &Project, settings: &Settings) -> Option<ExportChoice> {
229    let mut d = rfd::FileDialog::new();
230    if state.lossless {
231        let src = project.source_video.clone().or_else(|| project.assets.first().map(|a| a.path.clone()));
232        let ext = src
233            .as_ref()
234            .and_then(|s| Path::new(s).extension().map(|e| e.to_string_lossy().into_owned()))
235            .unwrap_or_else(|| "mp4".into());
236        d = d
237            .add_filter(format!("{} (same container)", ext.to_uppercase()), &[ext.as_str()])
238            .set_file_name(format!("{}_cut.{ext}", project.name));
239    } else {
240        d = d
241            .add_filter("MP4 video", &["mp4"])
242            .add_filter("MOV video", &["mov"])
243            .add_filter("MKV video", &["mkv"])
244            .add_filter("WebM video", &["webm"])
245            .add_filter("AVI video", &["avi"])
246            .add_filter("GIF", &["gif"])
247            .add_filter("MP3 audio", &["mp3"])
248            .add_filter("WAV audio", &["wav"])
249            .add_filter("M4A audio", &["m4a"])
250            .add_filter("FLAC audio", &["flac"])
251            .add_filter("Any (by extension)", &["*"])
252            .set_file_name(format!("{}_edit.mp4", project.name));
253    }
254    if let Some(dir) = project.source_video.as_ref().and_then(|p| Path::new(p).parent()) {
255        d = d.set_directory(dir);
256    }
257    let out_path = d.save_file()?;
258    Some(ExportChoice {
259        opts: ExportOptions {
260            out_path,
261            encoder: settings.encoder.clone(),
262            crf: settings.crf,
263            preset: settings.preset.clone(),
264            backend: Backend::parse(&settings.decoder),
265            out_size: preset_size(project.width, project.height, &state.preset, state.custom),
266            scaler: settings.export_scaler.clone(),
267            frames: crate::engine::export::FrameSource::Cpu,
268            metadata: if state.metadata_on { state.metadata.clone() } else { Vec::new() },
269        },
270        lossless: state.lossless,
271    })
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn preset_sizes() {
280        // 16:9 project: 720p derives 1280 wide
281        assert_eq!(preset_size(1920, 1080, "720", (0, 0)), Some((1280, 720)));
282        assert_eq!(preset_size(1920, 1080, "1440", (0, 0)), Some((2560, 1440)));
283        // same as project → None
284        assert_eq!(preset_size(1920, 1080, "1080", (0, 0)), None);
285        assert_eq!(preset_size(1920, 1080, "project", (0, 0)), None);
286        // odd widths are rounded to even: 854.4 → 854
287        assert_eq!(preset_size(1280, 720, "480", (0, 0)), Some((854, 480)));
288        assert_eq!(preset_size(1920, 1080, "custom", (100, 50)), Some((100, 50)));
289    }
290
291    #[test]
292    fn init_matches_named_preset() {
293        let mut p = Project::new(); // 1920x1080
294        p.width = 1920;
295        p.height = 1080;
296        let mut s = Settings::default();
297        s.export_resolution = "1280x720".into();
298        let mut st = ExportUi::default();
299        init_from_settings(&mut st, &s, &p);
300        assert_eq!(st.preset, "720");
301        s.export_resolution = "640x360".into();
302        st.preset.clear();
303        init_from_settings(&mut st, &s, &p);
304        assert_eq!(st.preset, "custom");
305        assert_eq!(st.custom, (640, 360));
306    }
307
308    /// Headless: the window lays out without panicking and returns None with no interaction.
309    #[test]
310    fn show_headless() {
311        let project = Project::new();
312        let mut settings = Settings::default();
313        let mut state = ExportUi { open: true, ..Default::default() };
314        let ctx = egui::Context::default();
315        for _ in 0..2 {
316            let _ = ctx.run(egui::RawInput::default(), |ctx| {
317                let r = show(ctx, &mut state, &project, &mut settings, &["libx264".into()], false);
318                assert!(r.is_none());
319            });
320        }
321        assert!(state.open);
322        assert_eq!(state.preset, "project");
323    }
324}