1use crate::model::Scaler;
8use 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 pub resize: String,
22 pub with_effects: bool,
24 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 pub scaler: Scaler,
37 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
44fn 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
54fn 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 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 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 #[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 let st = FrameUi { preset: "2x".into(), ..Default::default() };
252 assert_eq!(stored_resolution(&st), "2x");
253 }
254
255 #[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}