1use 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 pub preset: String,
22 pub custom: (u32, u32),
23 pub lossless: bool,
24 pub metadata_on: bool,
26 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];
43pub 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
53fn 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 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(); }
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 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
227fn 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 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 assert_eq!(preset_size(1920, 1080, "1080", (0, 0)), None);
285 assert_eq!(preset_size(1920, 1080, "project", (0, 0)), None);
286 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(); 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 #[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}