1use anyhow::Result;
2use crossterm::{
3 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
4 event::{Event, KeyEventKind, EnableBracketedPaste, DisableBracketedPaste, EnableMouseCapture, DisableMouseCapture},
5};
6use percent_encoding::percent_decode_str;
7use ratatui::backend::CrosstermBackend;
8use std::cell::Cell;
9use std::path::PathBuf;
10use std::sync::mpsc;
11use std::time::{Duration, Instant};
12use tokio::time;
13
14use crate::cli::{Cli, CliCommands, ResolvedCli};
15use crate::color::{ColorSpace, TransferFunction};
16use crate::export::{
17 Av1Profile, CodecFamily, DnxhrProfile, H264Profile, HevcProfile,
18 ProResProfile, RateControl, Vp9Profile,
19};
20use crate::hardware::probe_hardware;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use crate::decoder::Decoder;
24use crate::encoder::{EncodeJob, EncodeStatus, Encoder, OutputFormat};
25use crate::file::McrawFileInfo;
26use crate::file_browser::FileBrowser;
27use crate::preset::ExportPreset;
28use crate::stats::PipelineStats;
29
30pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
32use crate::ui::{self, ClickAction};
33
34#[derive(Debug, Clone)]
39pub struct ImportedFile {
40 pub path: String,
41 pub info: McrawFileInfo,
42 pub selected: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum QueueStatus {
47 Waiting,
48 Rendering,
49 Completed,
50 Failed(String),
51}
52
53#[derive(Debug, Clone)]
54pub struct QueuedFile {
55 pub path: String,
56 pub info: McrawFileInfo,
57 pub selected: bool,
58 pub status: QueueStatus,
59 pub progress: f64,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum FocusTarget {
64 MediaPool,
65 Queue,
66 ExportSettings,
67 Preview,
68 Grade,
69}
70
71#[derive(Debug, Clone, Copy)]
72pub struct GradeSliders {
73 pub exposure: f32,
74 pub contrast: f32,
75 pub saturation: f32,
76 pub shadows: f32,
77 pub highlights: f32,
78 pub temperature: f32,
79 pub tint: f32,
80 pub sharpen: f32,
81}
82
83impl GradeSliders {
84 pub fn name(index: usize) -> &'static str {
85 match index {
86 0 => "Exposure",
87 1 => "Contrast",
88 2 => "Saturation",
89 3 => "Shadows",
90 4 => "Highlights",
91 5 => "Temp",
92 6 => "Tint",
93 7 => "Sharpen",
94 _ => "",
95 }
96 }
97
98 pub fn default_val(index: usize) -> f32 {
99 match index {
100 0 => 0.0,
101 1 => 1.0,
102 2 => 1.0,
103 3 => 0.0,
104 4 => 0.0,
105 5 => 5200.0,
106 6 => 0.0,
107 7 => 0.0,
108 _ => 0.0,
109 }
110 }
111
112 pub fn min(index: usize) -> f32 {
113 match index {
114 0 => -5.0,
115 1 => 0.0,
116 2 => 0.0,
117 3 => -1.0,
118 4 => -1.0,
119 5 => 2000.0,
120 6 => -100.0,
121 7 => 0.0,
122 _ => 0.0,
123 }
124 }
125
126 pub fn max(index: usize) -> f32 {
127 match index {
128 0 => 5.0,
129 1 => 2.0,
130 2 => 2.0,
131 3 => 1.0,
132 4 => 1.0,
133 5 => 10000.0,
134 6 => 100.0,
135 7 => 1.0,
136 _ => 1.0,
137 }
138 }
139
140 pub fn step_small(index: usize) -> f32 {
141 match index {
142 0 => 0.1,
143 5 => 50.0,
144 6 => 1.0,
145 _ => 0.05,
146 }
147 }
148
149 pub fn step_large(index: usize) -> f32 {
150 match index {
151 0 => 1.0,
152 5 => 500.0,
153 6 => 10.0,
154 _ => 0.25,
155 }
156 }
157
158 pub fn value(&self, index: usize) -> f32 {
159 match index {
160 0 => self.exposure,
161 1 => self.contrast,
162 2 => self.saturation,
163 3 => self.shadows,
164 4 => self.highlights,
165 5 => self.temperature,
166 6 => self.tint,
167 7 => self.sharpen,
168 _ => 0.0,
169 }
170 }
171
172 pub fn normalized(&self, index: usize) -> f32 {
173 let v = self.value(index);
174 let lo = Self::min(index);
175 let hi = Self::max(index);
176 if hi <= lo { return 0.5; }
177 ((v - lo) / (hi - lo)).clamp(0.0, 1.0)
178 }
179
180 pub fn display_value(&self, index: usize) -> String {
181 let sign = |x: f32| if x >= 0.0 { "+" } else { "" };
182 match index {
183 0 => format!("{}{:.1} stops", sign(self.exposure), self.exposure),
184 1 => format!("{:.2}x", self.contrast),
185 2 => format!("{:.2}x", self.saturation),
186 3 => format!("{}{:.2}", sign(self.shadows), self.shadows),
187 4 => format!("{}{:.2}", sign(self.highlights), self.highlights),
188 5 => format!("{:.0}K", self.temperature),
189 6 => format!("{}{:.0}", sign(self.tint), self.tint),
190 _ => format!("{:.2}", self.sharpen),
191 }
192 }
193
194 pub fn set(&mut self, index: usize, v: f32) {
195 let lo = Self::min(index);
196 let hi = Self::max(index);
197 let v = v.clamp(lo, hi);
198 match index {
199 0 => self.exposure = v,
200 1 => self.contrast = v,
201 2 => self.saturation = v,
202 3 => self.shadows = v,
203 4 => self.highlights = v,
204 5 => self.temperature = v,
205 6 => self.tint = v,
206 7 => self.sharpen = v,
207 _ => {}
208 }
209 }
210
211 pub fn apply_delta(&mut self, index: usize, step: f32) {
212 let cur = self.value(index);
213 self.set(index, cur + step);
214 }
215
216 pub fn count() -> usize { 8 }
217}
218
219impl Default for GradeSliders {
220 fn default() -> Self {
221 Self {
222 exposure: 0.0,
223 contrast: 1.0,
224 saturation: 1.0,
225 shadows: 0.0,
226 highlights: 0.0,
227 temperature: 5200.0,
228 tint: 0.0,
229 sharpen: 0.0,
230 }
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum ImportPopupState {
236 Hidden,
237 DroppedFiles {
238 files: Vec<String>,
239 folder: String,
240 all_in_folder: Vec<String>,
241 },
242}
243
244#[derive(Debug)]
245pub enum ExportEvent {
246 Progress(f64),
247 Stats(Arc<PipelineStats>),
248 Done(Result<()>),
249}
250
251#[derive(Debug, Clone)]
255pub struct ExportSummary {
256 pub output_path: String,
257 pub codec_label: String,
258 pub profile_label: String,
259 pub color_space: String,
260 pub transfer: String,
261 pub rate_control: String,
262 pub frame_count: usize,
263 pub elapsed: Duration,
264 pub result: Result<(), String>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub enum Screen {
269 Browse,
270 Info,
271 Export,
272}
273
274#[derive(Debug, Clone)]
280pub struct FPSCounter {
281 last_draw: Instant,
282 frames_this_second: u32,
283 second_start: Instant,
284 smooth_fps: f64,
285}
286
287impl FPSCounter {
288 pub fn new() -> Self {
289 Self {
290 last_draw: Instant::now(),
291 frames_this_second: 0,
292 second_start: Instant::now(),
293 smooth_fps: 0.0,
294 }
295 }
296
297 pub fn tick(&mut self) {
299 let now = Instant::now();
300 self.frames_this_second += 1;
301 if now.duration_since(self.second_start).as_secs_f64() >= 1.0 {
302 let fps = self.frames_this_second as f64;
303 if self.smooth_fps == 0.0 {
304 self.smooth_fps = fps;
305 } else {
306 self.smooth_fps = self.smooth_fps * 0.9 + fps * 0.1;
307 }
308 self.frames_this_second = 0;
309 self.second_start = now;
310 }
311 self.last_draw = now;
312 }
313
314 pub fn fps(&self) -> f64 {
315 self.smooth_fps
316 }
317}
318
319pub struct App {
320 pub running: bool,
321 pub screen: Screen,
322 pub file_path: Option<String>,
323 pub file_info: Option<McrawFileInfo>,
324 pub frame_index: usize,
325 pub frame_count: usize,
326 pub encode_jobs: Vec<EncodeJob>,
327 pub status_message: String,
328 pub show_help: bool,
329 pub error: Option<String>,
330 pub browser: FileBrowser,
331
332 pub is_exporting: bool,
333 pub export_cancelled: bool,
334 pub export_progress: f64,
335 pub export_rx: Option<mpsc::Receiver<ExportEvent>>,
336 pub cancel_token: Option<Arc<AtomicBool>>,
337
338 pub last_export_summary: Option<ExportSummary>,
341
342 pub pending_export_summary: Option<ExportSummary>,
346
347 pub current_rendering_index: Option<usize>,
349
350 pub export_folder: Option<std::path::PathBuf>,
352
353 pub favourite_folders: Vec<std::path::PathBuf>,
355
356 pub help_scroll: u16,
358
359 pub show_culling: bool,
361
362 pub show_grade_screen: bool,
364
365 pub export_color_space: ColorSpace,
367 pub export_transfer_function: TransferFunction,
368 pub export_codec_family: CodecFamily,
369 pub export_focus: ExportFocus,
370 pub export_start_time: Option<Instant>,
371
372 pub prores_profile: ProResProfile,
374 pub dnxhr_profile: DnxhrProfile,
375 pub hevc_profile: HevcProfile,
376 pub h264_profile: H264Profile,
377 pub av1_profile: Av1Profile,
378 pub vp9_profile: Vp9Profile,
379
380 pub hardware_caps: crate::hardware::HardwareCaps,
382
383 pub active_rate_control: RateControl,
385 pub is_editing_custom_rate: bool,
386
387 pub grade_sliders: GradeSliders,
389 pub grade_focus: usize,
390 pub grade_dragging: Option<(usize, u16, u16)>,
392
393 pub imported_files: Vec<ImportedFile>,
395 pub media_pool_index: usize,
396
397 pub queue: Vec<QueuedFile>,
398 pub queue_index: usize,
399
400 pub show_browser: bool,
401 pub import_popup: ImportPopupState,
402
403 pub focus_target: FocusTarget,
404
405 pub show_full_info: bool,
406
407 pub last_browser_click: Option<(Instant, usize)>,
409
410 pub last_grade_click: Option<(Instant, usize)>,
412
413 pub drop_highlight: Option<Instant>,
415
416 pub drop_import_rx: Option<mpsc::Receiver<DropImportEvent>>,
418 pub drop_import_cancel: Option<Arc<AtomicBool>>,
419
420 pub drop_preview: Option<DropPreview>,
422
423 pub browser_scroll_offset: Cell<usize>,
425
426 pub show_favourites_bar: bool,
428
429 pub browsing_favourites: bool,
433
434 pub favourites_scroll_offset: Cell<usize>,
436
437 pub last_clicked_favourite: Option<(Instant, usize)>,
439
440 pub presets: Vec<crate::preset::ExportPreset>,
446
447 pub active_preset: Option<String>,
451
452 pub preset_picker: PresetPickerState,
454
455 pub preset_naming: Option<PresetNamingState>,
458
459 pub spinner_frame: u8,
461 pub progress_anim_offset: u8,
462
463 pub fps_counter: FPSCounter,
465
466 pub shockwave_ticks_remaining: u8,
468
469 pub grade_strip_active: bool,
471 pub grade_morph: Option<(usize, u8)>,
473 pub phosphor_trail: Vec<(f32, u8)>,
475 pub grade_before_snapshot: Option<GradeSliders>,
477 pub grade_strip_idle_ticks: u8,
479}
480
481#[derive(Debug, Clone, Default)]
484pub struct PresetPickerState {
485 pub open: bool,
486 pub index: usize,
487 pub message: Option<String>,
488}
489
490#[derive(Debug, Clone)]
491pub struct PresetNamingState {
492 pub name: String,
493 pub message: Option<String>,
494}
495
496pub enum DropImportEvent {
498 FileReady { path: String, info: McrawFileInfo },
499 Failed { path: String, error: String },
500 Complete { imported: usize, failed: usize },
501}
502
503pub struct DropPreview {
505 pub files: Vec<String>,
506 pub start_time: Instant,
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum ExportFocus {
511 ColorSpace,
512 TransferFunction,
513 CodecFamily,
514 Profile,
515 RateControl,
516}
517
518impl App {
519 fn favourites_file() -> Option<PathBuf> {
520 let mut dir = dirs::config_dir()?;
521 dir.push("mcraw-tui");
522 std::fs::create_dir_all(&dir).ok()?;
523 dir.push("favourites.json");
524 Some(dir)
525 }
526
527 fn load_favourites() -> Vec<PathBuf> {
528 let path = match Self::favourites_file() {
529 Some(p) => p,
530 None => return Vec::new(),
531 };
532 let data = match std::fs::read_to_string(&path) {
533 Ok(d) => d,
534 Err(_) => return Vec::new(),
535 };
536 serde_json::from_str(&data).unwrap_or_default()
537 }
538
539 fn save_favourites(&self) {
540 let path = match Self::favourites_file() {
541 Some(p) => p,
542 None => return,
543 };
544 if let Ok(data) = serde_json::to_string(&self.favourite_folders) {
545 let _ = std::fs::write(path, data);
546 }
547 }
548
549 pub fn new() -> Self {
550 let caps = probe_hardware();
551 App {
552 running: true,
553 screen: Screen::Browse,
554 file_path: None,
555 file_info: None,
556 frame_index: 0,
557 frame_count: 0,
558 encode_jobs: Vec::new(),
559 status_message: String::from("Ready | Drag-drop .mcraw files or press b to browse"),
560 show_help: false,
561 error: None,
562 browser: FileBrowser::new(),
563
564 is_exporting: false,
565 export_cancelled: false,
566 export_progress: 0.0,
567 export_rx: None,
568 cancel_token: None,
569 last_export_summary: None,
570 pending_export_summary: None,
571
572 export_color_space: ColorSpace::Rec709,
573 export_transfer_function: TransferFunction::Gamma24,
574 export_codec_family: CodecFamily::HEVC,
575 export_focus: ExportFocus::CodecFamily,
576 export_start_time: None,
577
578 prores_profile: ProResProfile::HQ,
579 dnxhr_profile: DnxhrProfile::HQX,
580 hevc_profile: HevcProfile::Main10_420,
581 h264_profile: H264Profile::Main8bit,
582 av1_profile: Av1Profile::Profile0_420_10bit,
583 vp9_profile: Vp9Profile::Profile2_420_10bit,
584
585 hardware_caps: caps,
586 active_rate_control: RateControl::Lossless,
587 is_editing_custom_rate: false,
588
589 imported_files: Vec::new(),
590 media_pool_index: 0,
591 queue: Vec::new(),
592 queue_index: 0,
593 show_browser: true,
594 current_rendering_index: None,
595 export_folder: None,
596 favourite_folders: Self::load_favourites(),
597 help_scroll: 0,
598 show_culling: false,
599 show_grade_screen: false,
600 import_popup: ImportPopupState::Hidden,
601 focus_target: FocusTarget::MediaPool,
602 show_full_info: false,
603 last_browser_click: None,
604 last_grade_click: None,
605 drop_highlight: None,
606 drop_import_rx: None,
607 drop_import_cancel: None,
608 drop_preview: None,
609 browser_scroll_offset: Cell::new(0),
610 show_favourites_bar: true,
611 last_clicked_favourite: None,
612 browsing_favourites: false,
613 favourites_scroll_offset: Cell::new(0),
614 presets: ExportPreset::load_all(),
615 active_preset: None,
616 preset_picker: PresetPickerState::default(),
617 preset_naming: None,
618
619 spinner_frame: 0,
620 progress_anim_offset: 0,
621 fps_counter: FPSCounter::new(),
622 shockwave_ticks_remaining: 0,
623 grade_sliders: GradeSliders::default(),
624 grade_focus: 0,
625 grade_dragging: None,
626 grade_strip_active: true,
627 grade_morph: None,
628 phosphor_trail: Vec::new(),
629 grade_before_snapshot: None,
630 grade_strip_idle_ticks: 0,
631 }
632 }
633
634 pub fn load_file(&mut self, path: String) {
639 tracing::info!("load_file: path={}", path);
640 self.error = None;
641 self.status_message = String::new();
642 match McrawFileInfo::from_path(&path) {
643 Ok(mut info) => {
644 tracing::debug!("file parsed: frames={} {}x{} fps={}", info.frame_count, info.width, info.height, info.fps);
645 if let Ok(decoder) = Decoder::new(&path) {
646 if let Ok(container_meta) = decoder.container_metadata() {
647 let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
648 let mut r = [0.0; 9];
649 for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
650 r
651 };
652 let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
653
654 info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
655 if non_zero(&container_meta.color_matrix2) {
656 info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
657 }
658 if non_zero(&container_meta.forward_matrix1) {
659 info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
660 }
661 if non_zero(&container_meta.forward_matrix2) {
662 info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
663 }
664 if container_meta.has_calibration_illuminants {
665 info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
666 info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
667 }
668
669 if container_meta.white_level > 0.0 {
670 info.white_level = container_meta.white_level;
671 }
672 if container_meta.black_level_count > 0 {
673 info.black_level = container_meta.black_level[0];
674 }
675 }
676 if let Ok(timestamps) = decoder.timestamps() {
677 info.frame_count = timestamps.len() as u32;
678 if timestamps.len() >= 2 {
679 let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
680 if duration_ns > 0 {
681 let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
682 info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
683 }
684 }
685 if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
686 info.width = first_frame_meta.width as u16;
687 info.height = first_frame_meta.height as u16;
688 }
689 if let Some(wb) = info.camera_metadata.wb_multipliers {
691 let r_gain = wb[0];
692 let b_gain = wb[2];
693 let ratio = (r_gain / b_gain.max(1e-6)).clamp(0.1, 10.0);
694 let temp = if ratio >= 1.0 {
695 5200.0 + (ratio - 1.0) * 3000.0
696 } else {
697 5200.0 - (1.0 - ratio) * 3000.0
698 };
699 self.grade_sliders.set(5, temp.clamp(2000.0, 10000.0));
700 } else {
701 self.grade_sliders.set(5, 5200.0);
702 }
703 }
704 }
705
706 self.file_info = Some(info.clone());
707 self.frame_count = info.frame_count as usize;
708 self.file_path = Some(path.clone());
709
710 let already = self.imported_files.iter().any(|f| f.path == path);
711 if !already {
712 self.imported_files.push(ImportedFile {
713 path: path.clone(),
714 info: info.clone(),
715 selected: true,
716 });
717 self.media_pool_index = self.imported_files.len() - 1;
718 tracing::info!("file added to media pool: index={}", self.media_pool_index);
719 } else {
720 tracing::debug!("file already in media pool, skipping");
721 }
722
723 self.status_message = format!("Imported: {}", path);
724 tracing::info!("file loaded successfully: {}", path);
725 }
726 Err(e) => {
727 tracing::error!("failed to load file {}: {}", path, e);
728 self.error = Some(format!("Failed to load file: {}", e));
729 self.status_message = format!("Error: {}", e);
730 }
731 }
732 }
733
734 pub fn load_files_batch(&mut self, paths: &[String]) -> (usize, usize) {
737 tracing::info!("load_files_batch: count={}", paths.len());
738 let mut imported = 0;
739 let mut failed = 0;
740 for path in paths {
741 self.error = None;
742 match McrawFileInfo::from_path(path) {
743 Ok(mut info) => {
744 if let Ok(decoder) = Decoder::new(path) {
745 if let Ok(container_meta) = decoder.container_metadata() {
746 let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
747 let mut r = [0.0; 9];
748 for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
749 r
750 };
751 let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
752 info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
753 if non_zero(&container_meta.color_matrix2) {
754 info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
755 }
756 if non_zero(&container_meta.forward_matrix1) {
757 info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
758 }
759 if non_zero(&container_meta.forward_matrix2) {
760 info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
761 }
762 if container_meta.has_calibration_illuminants {
763 info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
764 info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
765 }
766 if container_meta.white_level > 0.0 {
767 info.white_level = container_meta.white_level;
768 }
769 if container_meta.black_level_count > 0 {
770 info.black_level = container_meta.black_level[0];
771 }
772 }
773 if let Ok(timestamps) = decoder.timestamps() {
774 info.frame_count = timestamps.len() as u32;
775 if timestamps.len() >= 2 {
776 let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
777 if duration_ns > 0 {
778 let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
779 info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
780 }
781 }
782 if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
783 info.width = first_frame_meta.width as u16;
784 info.height = first_frame_meta.height as u16;
785 }
786 }
787 }
788
789 let already = self.imported_files.iter().any(|f| f.path == *path);
790 if !already {
791 self.imported_files.push(ImportedFile {
792 path: path.clone(),
793 info: info.clone(),
794 selected: true,
795 });
796 imported += 1;
797 tracing::debug!("batch imported: {} ({} total)", path, self.imported_files.len());
798 }
799 }
800 Err(e) => {
801 failed += 1;
802 tracing::warn!("batch import failed for {}: {}", path, e);
803 }
804 }
805 }
806 if imported > 0 && self.imported_files.len() > 0 {
808 self.media_pool_index = self.imported_files.len() - imported;
809 self.file_info = Some(self.imported_files[self.media_pool_index].info.clone());
810 self.file_path = Some(self.imported_files[self.media_pool_index].path.clone());
811 self.frame_count = self.imported_files[self.media_pool_index].info.frame_count as usize;
812 }
813 (imported, failed)
814 }
815
816 pub fn start_async_import(&mut self, paths: Vec<String>) {
819 if let Some(cancel) = self.drop_import_cancel.take() {
821 cancel.store(true, Ordering::Relaxed);
822 }
823
824 let (tx, rx) = mpsc::channel::<DropImportEvent>();
825 let cancel_flag = Arc::new(AtomicBool::new(false));
826 self.drop_import_cancel = Some(cancel_flag.clone());
827 self.drop_import_rx = Some(rx);
828
829 self.drop_preview = Some(DropPreview {
831 files: paths.iter()
832 .filter(|p| p.to_lowercase().ends_with(".mcraw"))
833 .map(|p| p.clone())
834 .collect(),
835 start_time: Instant::now(),
836 });
837
838 let total = paths.len();
839 self.status_message = format!("Importing {} file(s)...", total);
840
841 std::thread::spawn(move || {
842 let mut imported = 0;
843 let mut failed = 0;
844
845 for path in paths {
846 if cancel_flag.load(Ordering::Relaxed) {
847 tracing::info!("async drag-drop import cancelled");
848 break;
849 }
850
851 let path_clone = path.clone();
852 match McrawFileInfo::from_path(&path) {
853 Ok(mut info) => {
854 if let Ok(decoder) = Decoder::new(&path) {
856 if let Ok(container_meta) = decoder.container_metadata() {
857 let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
858 let mut r = [0.0; 9];
859 for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
860 r
861 };
862 let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
863 info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
864 if non_zero(&container_meta.color_matrix2) {
865 info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
866 }
867 if non_zero(&container_meta.forward_matrix1) {
868 info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
869 }
870 if non_zero(&container_meta.forward_matrix2) {
871 info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
872 }
873 if container_meta.has_calibration_illuminants {
874 info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
875 info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
876 }
877 if container_meta.white_level > 0.0 {
878 info.white_level = container_meta.white_level;
879 }
880 if container_meta.black_level_count > 0 {
881 info.black_level = container_meta.black_level[0];
882 }
883 }
884 if let Ok(timestamps) = decoder.timestamps() {
885 info.frame_count = timestamps.len() as u32;
886 if timestamps.len() >= 2 {
887 let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
888 if duration_ns > 0 {
889 let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
890 info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
891 }
892 }
893 if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
894 info.width = first_frame_meta.width as u16;
895 info.height = first_frame_meta.height as u16;
896 }
897 }
898 }
899
900 let _ = tx.send(DropImportEvent::FileReady { path: path_clone, info });
901 imported += 1;
902 }
903 Err(e) => {
904 let _ = tx.send(DropImportEvent::Failed {
905 path: path_clone,
906 error: e.to_string(),
907 });
908 failed += 1;
909 tracing::warn!("async drag-drop import failed: {}: {}", path, e);
910 }
911 }
912 }
913
914 let _ = tx.send(DropImportEvent::Complete { imported, failed });
915 });
916 }
917
918 pub fn poll_drop_import(&mut self) {
920 let rx = match self.drop_import_rx.take() {
921 Some(rx) => rx,
922 None => return,
923 };
924
925 let mut keep_rx = true;
926 while let Ok(event) = rx.try_recv() {
927 match event {
928 DropImportEvent::FileReady { path, info } => {
929 let already = self.imported_files.iter().any(|f| f.path == path);
930 if !already {
931 self.imported_files.push(ImportedFile {
932 path: path.clone(),
933 info: info.clone(),
934 selected: true,
935 });
936 if self.imported_files.len() == 1 {
938 self.media_pool_index = 0;
939 self.file_info = Some(info.clone());
940 self.file_path = Some(path.clone());
941 self.frame_count = info.frame_count as usize;
942 }
943 tracing::debug!("async imported: {} ({} total)", path, self.imported_files.len());
944 }
945 }
946 DropImportEvent::Failed { path, error } => {
947 tracing::warn!("async import failed: {}: {}", path, error);
948 }
949 DropImportEvent::Complete { imported, failed } => {
950 keep_rx = false;
951 self.drop_import_cancel = None;
952 if imported > 0 {
953 self.media_pool_index = self.imported_files.len().saturating_sub(imported);
954 if let Some(f) = self.imported_files.get(self.media_pool_index) {
955 self.file_info = Some(f.info.clone());
956 self.file_path = Some(f.path.clone());
957 self.frame_count = f.info.frame_count as usize;
958 }
959 }
960 if failed > 0 {
961 self.status_message = format!("Imported {} file(s), {} failed", imported, failed);
962 } else {
963 self.status_message = format!("Imported {} file(s)", imported);
964 }
965 tracing::info!("async drag-drop import complete: {} imported, {} failed", imported, failed);
966 }
967 }
968 }
969
970 if keep_rx {
971 self.drop_import_rx = Some(rx);
972 }
973 }
974
975 pub fn load_all_in_folder(&mut self, dir: &std::path::Path) {
976 if let Ok(entries) = std::fs::read_dir(dir) {
977 let mut mcraw_paths: Vec<String> = entries
978 .filter_map(|e| e.ok())
979 .map(|e| e.path())
980 .filter(|p| p.extension().map_or(false, |ext| ext == "mcraw"))
981 .map(|p| p.to_string_lossy().to_string())
982 .collect();
983 mcraw_paths.sort();
984 let count = mcraw_paths.len();
985 for path in mcraw_paths {
986 self.load_file(path);
987 }
988 if count > 0 {
989 self.status_message = format!("Imported {} .mcraw files from {}", count, dir.display());
990 } else {
991 self.status_message = format!("No .mcraw files found in {}", dir.display());
992 }
993 }
994 }
995
996 pub fn focused_file_info(&self) -> Option<&McrawFileInfo> {
1001 self.imported_files.get(self.media_pool_index).map(|f| &f.info)
1002 }
1003
1004 pub fn toggle_media_pool_selection(&mut self) {
1005 if let Some(f) = self.imported_files.get_mut(self.media_pool_index) {
1006 f.selected = !f.selected;
1007 }
1008 }
1009
1010 pub fn add_selected_to_queue(&mut self) {
1011 let selected: Vec<ImportedFile> = self.imported_files.iter()
1012 .filter(|f| f.selected)
1013 .cloned()
1014 .collect();
1015 if selected.is_empty() {
1016 self.status_message = "No files selected - use Space to select, then a to add".to_string();
1017 return;
1018 }
1019 let count = selected.len();
1020 for imp in &selected {
1021 let already = self.queue.iter().any(|q| q.path == imp.path);
1022 if !already {
1023 self.queue.push(QueuedFile {
1024 path: imp.path.clone(),
1025 info: imp.info.clone(),
1026 selected: true,
1027 status: QueueStatus::Waiting,
1028 progress: 0.0,
1029 });
1030 }
1031 }
1032 self.status_message = format!("Added {} file(s) to render queue", count);
1033 }
1034
1035 pub fn add_all_to_queue(&mut self) {
1036 if self.imported_files.is_empty() {
1037 self.status_message = "No files in media pool".to_string();
1038 return;
1039 }
1040 let count = self.imported_files.len();
1041 for imp in &self.imported_files {
1042 let already = self.queue.iter().any(|q| q.path == imp.path);
1043 if !already {
1044 self.queue.push(QueuedFile {
1045 path: imp.path.clone(),
1046 info: imp.info.clone(),
1047 selected: true,
1048 status: QueueStatus::Waiting,
1049 progress: 0.0,
1050 });
1051 }
1052 }
1053 self.status_message = format!("Added all {} file(s) to render queue", count);
1054 }
1055
1056 pub fn remove_from_media_pool(&mut self) {
1057 if self.imported_files.is_empty() {
1058 return;
1059 }
1060 let name = self.imported_files[self.media_pool_index]
1061 .path
1062 .split(std::path::MAIN_SEPARATOR)
1063 .last()
1064 .unwrap_or("unknown")
1065 .to_string();
1066 self.imported_files.remove(self.media_pool_index);
1067 if self.media_pool_index >= self.imported_files.len() && self.imported_files.len() > 0 {
1068 self.media_pool_index = self.imported_files.len() - 1;
1069 }
1070 self.status_message = format!("Removed {} from media pool", name);
1071 }
1072
1073 pub fn toggle_queue_selection(&mut self) {
1078 if let Some(q) = self.queue.get_mut(self.queue_index) {
1079 q.selected = !q.selected;
1080 }
1081 }
1082
1083 pub fn remove_from_queue(&mut self) {
1084 if self.queue.is_empty() {
1085 return;
1086 }
1087 let has_selected = self.queue.iter().any(|q| q.selected);
1088 if has_selected {
1089 self.queue.retain(|q| !q.selected);
1090 self.status_message = "Removed selected items from queue".to_string();
1091 } else {
1092 let name = self.queue[self.queue_index]
1093 .path
1094 .split(std::path::MAIN_SEPARATOR)
1095 .last()
1096 .unwrap_or("unknown")
1097 .to_string();
1098 self.queue.remove(self.queue_index);
1099 if self.queue_index >= self.queue.len() && self.queue.len() > 0 {
1100 self.queue_index = self.queue.len() - 1;
1101 }
1102 self.status_message = format!("Removed {} from queue", name);
1103 }
1104 if self.queue_index >= self.queue.len() && !self.queue.is_empty() {
1105 self.queue_index = self.queue.len() - 1;
1106 }
1107 }
1108
1109 pub fn clear_completed_queue(&mut self) {
1110 let before = self.queue.len();
1111 self.queue.retain(|q| !matches!(q.status, QueueStatus::Completed | QueueStatus::Failed(_)));
1112 let removed = before - self.queue.len();
1113 if removed > 0 {
1114 self.status_message = format!("Cleared {} completed/failed item(s)", removed);
1115 } else {
1116 self.status_message = "No completed/failed items to clear".to_string();
1117 }
1118 if self.queue_index >= self.queue.len() && !self.queue.is_empty() {
1119 self.queue_index = self.queue.len() - 1;
1120 }
1121 }
1122
1123 pub fn render_selected(&mut self) {
1124 let selected_indices: Vec<usize> = self.queue.iter()
1125 .enumerate()
1126 .filter(|(_, q)| q.selected)
1127 .map(|(i, _)| i)
1128 .collect();
1129 if selected_indices.is_empty() {
1130 self.status_message = "No items selected in queue - use Space to select".to_string();
1131 return;
1132 }
1133 self.status_message = format!("Starting render of {} selected file(s)...", selected_indices.len());
1134 if let Some(&first_idx) = selected_indices.first() {
1136 self.current_rendering_index = Some(first_idx);
1137 let q = &self.queue[first_idx];
1138 self.file_info = Some(q.info.clone());
1139 self.file_path = Some(q.path.clone());
1140 self.frame_count = q.info.frame_count as usize;
1141 self.start_export();
1142 }
1143 }
1144
1145 pub fn render_all(&mut self) {
1146 if self.queue.is_empty() {
1147 self.status_message = "Queue is empty".to_string();
1148 return;
1149 }
1150 self.status_message = format!("Starting render of all {} file(s)...", self.queue.len());
1151 for q in &mut self.queue {
1152 q.selected = true;
1153 }
1154 self.current_rendering_index = Some(0);
1156 if let Some(q) = self.queue.first() {
1157 self.file_info = Some(q.info.clone());
1158 self.file_path = Some(q.path.clone());
1159 self.frame_count = q.info.frame_count as usize;
1160 self.start_export();
1161 }
1162 }
1163
1164 fn start_next_queued_render(&mut self) {
1165 if let Some(current) = self.current_rendering_index {
1167 let next_idx = (current + 1..self.queue.len())
1168 .find(|&i| self.queue[i].selected && self.queue[i].status == QueueStatus::Waiting);
1169 if let Some(idx) = next_idx {
1170 self.current_rendering_index = Some(idx);
1171 self.queue[idx].status = QueueStatus::Rendering;
1172 let q = &self.queue[idx];
1173 self.file_info = Some(q.info.clone());
1174 self.file_path = Some(q.path.clone());
1175 self.frame_count = q.info.frame_count as usize;
1176 self.start_export();
1177 } else {
1178 self.current_rendering_index = None;
1180 let done = self.queue.iter().filter(|q| q.selected && q.status == QueueStatus::Completed).count();
1181 let total = self.queue.iter().filter(|q| q.selected).count();
1182 self.status_message = format!("Batch render complete: {}/{} done", done, total);
1183 }
1184 }
1185 }
1186
1187 pub fn active_profile_is_8bit(&self) -> bool {
1192 match self.export_codec_family {
1193 CodecFamily::ProRes => false,
1194 CodecFamily::DNxHR => false,
1195 CodecFamily::HEVC => self.hevc_profile.is_8bit(),
1196 CodecFamily::H264 => self.h264_profile.is_8bit(),
1197 CodecFamily::AV1 => self.av1_profile.is_8bit(),
1198 CodecFamily::VP9 => self.vp9_profile.is_8bit(),
1199 }
1200 }
1201
1202 pub fn active_profile_name(&self) -> &'static str {
1203 match self.export_codec_family {
1204 CodecFamily::ProRes => self.prores_profile.name(),
1205 CodecFamily::DNxHR => self.dnxhr_profile.name(),
1206 CodecFamily::HEVC => self.hevc_profile.name(),
1207 CodecFamily::H264 => self.h264_profile.name(),
1208 CodecFamily::AV1 => self.av1_profile.name(),
1209 CodecFamily::VP9 => self.vp9_profile.name(),
1210 }
1211 }
1212
1213 pub fn cycle_rate_control(&mut self) {
1214 self.active_rate_control = self.active_rate_control.next();
1215 self.is_editing_custom_rate = false;
1216 self.status_message = format!("Rate: {}", self.active_rate_control.name());
1217 }
1218
1219 pub fn cycle_codec(&mut self, forward: bool) {
1220 self.export_codec_family = if forward {
1221 self.export_codec_family.next()
1222 } else {
1223 self.export_codec_family.prev()
1224 };
1225 self.export_focus = ExportFocus::CodecFamily;
1226 self.status_message = format!("Codec: {}", self.export_codec_family.name());
1227 }
1228
1229 pub fn cycle_profile(&mut self, forward: bool) {
1230 match self.export_codec_family {
1231 CodecFamily::ProRes => {
1232 self.prores_profile = if forward { self.prores_profile.next() } else { self.prores_profile.prev() };
1233 self.status_message = format!("Profile: {}", self.prores_profile.name());
1234 }
1235 CodecFamily::DNxHR => {
1236 self.dnxhr_profile = if forward { self.dnxhr_profile.next() } else { self.dnxhr_profile.prev() };
1237 self.status_message = format!("Profile: {}", self.dnxhr_profile.name());
1238 }
1239 CodecFamily::HEVC => {
1240 self.hevc_profile = if forward { self.hevc_profile.next() } else { self.hevc_profile.prev() };
1241 self.status_message = format!("Profile: {}", self.hevc_profile.name());
1242 }
1243 CodecFamily::H264 => {
1244 self.h264_profile = if forward { self.h264_profile.next() } else { self.h264_profile.prev() };
1245 self.status_message = format!("Profile: {}", self.h264_profile.name());
1246 }
1247 CodecFamily::AV1 => {
1248 self.av1_profile = if forward { self.av1_profile.next() } else { self.av1_profile.prev() };
1249 self.status_message = format!("Profile: {}", self.av1_profile.name());
1250 }
1251 CodecFamily::VP9 => {
1252 self.vp9_profile = if forward { self.vp9_profile.next() } else { self.vp9_profile.prev() };
1253 self.status_message = format!("Profile: {}", self.vp9_profile.name());
1254 }
1255 }
1256 self.export_focus = ExportFocus::Profile;
1257 }
1258
1259 pub fn start_export(&mut self) {
1260 if self.is_exporting {
1261 tracing::info!("export cancelled by user (was already exporting)");
1262 self.cancel_export();
1263 self.status_message = "Export cancelled. Press V again to restart.".to_string();
1264 return;
1265 }
1266 let info = match self.file_info.clone() {
1267 Some(i) => i,
1268 None => {
1269 tracing::warn!("start_export called with no file loaded");
1270 self.status_message = "No file loaded".to_string();
1271 return;
1272 }
1273 };
1274
1275 if self.export_transfer_function.requires_10bit() && self.active_profile_is_8bit() {
1276 tracing::warn!("export blocked: log/HDR to 8-bit codec not supported");
1277 self.status_message = "Cannot export Log/HDR to 8-bit codec".to_string();
1278 return;
1279 }
1280
1281 let input_path = std::path::Path::new(&info.path);
1282 let parent = self.export_folder.clone().unwrap_or_else(|| {
1283 input_path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf()
1284 });
1285 let stem = input_path.file_stem().and_then(|s| s.to_str()).unwrap_or("output");
1286
1287 let ext = match self.export_codec_family {
1288 CodecFamily::ProRes | CodecFamily::DNxHR => "mov",
1289 CodecFamily::VP9 => "webm",
1290 _ => "mp4",
1291 };
1292 let tf_label = self.export_transfer_function.name().replace([' ', '(', ')', '.'], "");
1293 let cs_label = self.export_color_space.name().replace([' ', '(', ')', '.'], "");
1294 let filename = format!("{}_{}_{}.{}", stem, tf_label, cs_label, ext);
1295 let mut file = parent.join(&filename);
1296 let mut suffix = 1;
1297 while file.exists() {
1298 let base = format!("{}_{}_{}_{}", stem, tf_label, cs_label, suffix);
1299 file = parent.join(&base).with_extension(ext);
1300 suffix += 1;
1301 }
1302 let output_path = file.to_string_lossy().to_string();
1303 tracing::info!("export starting: output={} codec={} profile={} rate={}",
1304 output_path, self.export_codec_family.name(),
1305 self.active_profile_name(), self.active_rate_control.name());
1306 let cs = self.export_color_space;
1307 let tf = self.export_transfer_function;
1308 let cf = self.export_codec_family;
1309 let pp = self.prores_profile;
1310 let dp = self.dnxhr_profile;
1311 let hp = self.hevc_profile;
1312 let h4p = self.h264_profile;
1313 let ap = self.av1_profile;
1314 let vp = self.vp9_profile;
1315 let hevc_enc = self.hardware_caps.best_hevc_encoder.clone();
1316 let h264_enc = self.hardware_caps.best_h264_encoder.clone();
1317 let av1_enc = self.hardware_caps.best_av1_encoder.clone();
1318 let prores_enc = self.hardware_caps.best_prores_encoder.clone();
1319
1320 self.is_exporting = true;
1321 self.export_cancelled = false;
1322 self.export_progress = 0.0;
1323 self.export_start_time = Some(Instant::now());
1324 self.last_export_summary = None;
1328 self.pending_export_summary = Some(ExportSummary {
1332 output_path: output_path.clone(),
1333 codec_label: cf.name().to_string(),
1334 profile_label: self.active_profile_name().to_string(),
1335 color_space: cs.name().to_string(),
1336 transfer: tf.name().to_string(),
1337 rate_control: self.active_rate_control.name(),
1338 frame_count: info.frame_count as usize,
1339 elapsed: Duration::default(),
1340 result: Ok(()),
1341 });
1342 if let Some(idx) = self.current_rendering_index {
1344 if idx < self.queue.len() {
1345 self.queue[idx].status = QueueStatus::Rendering;
1346 }
1347 }
1348 let cancel_flag = Arc::new(AtomicBool::new(false));
1349 self.cancel_token = Some(cancel_flag.clone());
1350 let (tx, rx) = mpsc::channel::<ExportEvent>();
1351 self.export_rx = Some(rx);
1352 self.status_message = format!(
1353 "Starting export: {} / {} via {} {} ...",
1354 cs.name(),
1355 tf.name(),
1356 cf.name(),
1357 self.active_profile_name(),
1358 );
1359
1360 let progress_cb = {
1361 let prog_tx = tx.clone();
1362 Arc::new(move |pct: f64| { let _ = prog_tx.send(ExportEvent::Progress(pct)); })
1363 };
1364
1365 let rate_control = self.active_rate_control.clone();
1366 let stats = Arc::new(PipelineStats::new());
1367 let stats_for_event = Arc::clone(&stats);
1368
1369 std::thread::spawn(move || {
1370 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1371 crate::pipeline::run_export(
1372 info, output_path, progress_cb, cancel_flag, stats,
1373 cs, tf, cf, pp, dp, hp, h4p, ap, vp,
1374 hevc_enc, h264_enc, av1_enc, prores_enc,
1375 rate_control,
1376 )
1377 }));
1378 let _ = tx.send(ExportEvent::Stats(stats_for_event));
1381 match result {
1382 Ok(export_result) => {
1383 let _ = tx.send(ExportEvent::Done(export_result));
1384 }
1385 Err(panic) => {
1386 tracing::error!("export thread panicked: {:?}", panic);
1387 let _ = tx.send(ExportEvent::Done(Err(anyhow::anyhow!("Export thread panicked"))));
1388 }
1389 }
1390 });
1391 }
1392
1393 pub fn remove_selected_from_media_pool(&mut self) {
1394 let has_selected = self.imported_files.iter().any(|f| f.selected);
1395 if has_selected {
1396 let count = self.imported_files.iter().filter(|f| f.selected).count();
1397 self.imported_files.retain(|f| !f.selected);
1398 if self.media_pool_index >= self.imported_files.len() && !self.imported_files.is_empty() {
1399 self.media_pool_index = self.imported_files.len() - 1;
1400 }
1401 self.status_message = format!("Removed {} selected file(s) from media pool", count);
1402 } else {
1403 self.status_message = "No files selected - use Space to select".to_string();
1404 }
1405 }
1406
1407 pub fn set_export_folder(&mut self, folder: std::path::PathBuf) {
1408 self.export_folder = Some(folder);
1409 self.status_message = format!("Export folder set");
1410 }
1411
1412 pub fn toggle_favourite_folder(&mut self, folder: PathBuf) {
1413 if let Some(pos) = self.favourite_folders.iter().position(|f| f == &folder) {
1414 self.favourite_folders.remove(pos);
1415 self.status_message = "Removed from favourites".to_string();
1416 } else {
1417 self.favourite_folders.push(folder);
1418 self.status_message = "Added to favourites".to_string();
1419 }
1420 self.save_favourites();
1421 }
1422
1423 pub fn save_current_as_preset(&mut self, name: String) {
1431 let name = name.trim().to_string();
1432 if name.is_empty() {
1433 self.status_message = "Preset name cannot be empty".to_string();
1434 return;
1435 }
1436 let preset = ExportPreset::snapshot(
1437 name.clone(),
1438 self.export_color_space,
1439 self.export_transfer_function,
1440 self.export_codec_family,
1441 self.prores_profile,
1442 self.dnxhr_profile,
1443 self.hevc_profile,
1444 self.h264_profile,
1445 self.av1_profile,
1446 self.vp9_profile,
1447 self.active_rate_control.clone(),
1448 self.export_folder.clone(),
1449 );
1450 ExportPreset::upsert(&mut self.presets, preset);
1451 ExportPreset::save_all(&self.presets);
1452 self.active_preset = Some(name.clone());
1453 self.status_message = format!("Saved preset: {}", name);
1454 }
1455
1456 pub fn apply_preset(&mut self, index: usize) {
1459 if index >= self.presets.len() {
1460 return;
1461 }
1462 let p = self.presets[index].clone();
1463 self.export_color_space = p.color_space;
1464 self.export_transfer_function = p.transfer_function;
1465 self.export_codec_family = p.codec_family;
1466 self.prores_profile = p.prores_profile;
1467 self.dnxhr_profile = p.dnxhr_profile;
1468 self.hevc_profile = p.hevc_profile;
1469 self.h264_profile = p.h264_profile;
1470 self.av1_profile = p.av1_profile;
1471 self.vp9_profile = p.vp9_profile;
1472 self.active_rate_control = p.rate_control;
1473 self.export_folder = p.export_folder;
1474 if !matches!(self.active_rate_control, RateControl::Custom(_)) {
1476 self.is_editing_custom_rate = false;
1477 }
1478 self.active_preset = Some(p.name.clone());
1479 self.status_message = format!("Applied preset: {}", p.name);
1480 }
1481
1482 pub fn delete_preset(&mut self, index: usize) {
1485 if index >= self.presets.len() {
1486 return;
1487 }
1488 let removed_name = self.presets[index].name.clone();
1489 self.presets.remove(index);
1490 ExportPreset::save_all(&self.presets);
1491 if self.active_preset.as_deref() == Some(removed_name.as_str()) {
1492 self.active_preset = None;
1493 }
1494 if !self.presets.is_empty() && self.preset_picker.index >= self.presets.len() {
1496 self.preset_picker.index = self.presets.len() - 1;
1497 }
1498 self.preset_picker.message = Some(format!("Deleted preset: {}", removed_name));
1499 self.status_message = format!("Deleted preset: {}", removed_name);
1500 }
1501
1502 pub fn open_preset_picker(&mut self) {
1505 if self.presets.is_empty() {
1506 self.status_message = "No presets yet — press [p] to save the current settings".to_string();
1507 return;
1508 }
1509 self.preset_picker.open = true;
1510 self.preset_picker.index = self.presets.len().saturating_sub(1).min(self.preset_picker.index);
1511 self.preset_picker.message = None;
1512 }
1513
1514 pub fn close_preset_picker(&mut self) {
1515 self.preset_picker.open = false;
1516 self.preset_picker.message = None;
1517 }
1518
1519 pub fn begin_naming_preset(&mut self) {
1522 let default_name = match &self.active_preset {
1523 Some(n) => format!("{} (copy)", n),
1524 None => "My Preset".to_string(),
1525 };
1526 self.preset_naming = Some(PresetNamingState { name: default_name, message: None });
1527 self.preset_picker.open = false;
1528 }
1529
1530 pub fn cancel_naming_preset(&mut self) {
1531 self.preset_naming = None;
1532 }
1533
1534 pub fn commit_naming_preset(&mut self) {
1536 let name = match self.preset_naming.as_ref() {
1537 Some(s) => s.name.clone(),
1538 None => return,
1539 };
1540 self.preset_naming = None;
1541 self.save_current_as_preset(name);
1542 }
1543
1544 pub fn current_matches_preset(&self, name: &str) -> bool {
1547 if let Some(p) = self.presets.iter().find(|p| p.name == name) {
1548 p.color_space == self.export_color_space
1549 && p.transfer_function == self.export_transfer_function
1550 && p.codec_family == self.export_codec_family
1551 && p.prores_profile == self.prores_profile
1552 && p.dnxhr_profile == self.dnxhr_profile
1553 && p.hevc_profile == self.hevc_profile
1554 && p.h264_profile == self.h264_profile
1555 && p.av1_profile == self.av1_profile
1556 && p.vp9_profile == self.vp9_profile
1557 && p.rate_control.name() == self.active_rate_control.name()
1558 && p.export_folder == self.export_folder
1559 } else {
1560 false
1561 }
1562 }
1563
1564 pub fn import_selected_from_browser(&mut self) {
1565 let paths = self.browser.selected_mcraw_paths();
1566 if paths.is_empty() {
1567 self.status_message = "No .mcraw files selected in browser".to_string();
1568 return;
1569 }
1570 let count = paths.len();
1571 let (imported, failed) = self.load_files_batch(&paths);
1572 let msg = if failed > 0 {
1573 format!("Imported {} file(s), {} failed", imported, failed)
1574 } else {
1575 format!("Imported {} file(s)", imported)
1576 };
1577 self.status_message = msg;
1578 for entry in self.browser.entries.iter_mut() {
1580 if entry.selected && entry.name.to_lowercase().ends_with(".mcraw") {
1581 entry.selected = false;
1582 }
1583 }
1584 if count > 0 {
1585 self.show_browser = false;
1586 }
1587 }
1588
1589 pub fn cancel_export(&mut self) {
1590 if let Some(ref token) = self.cancel_token {
1591 tracing::info!("export cancellation requested");
1592 token.store(true, Ordering::Relaxed);
1593 self.export_cancelled = true;
1594 self.status_message = "Cancelling export...".to_string();
1595 }
1596 }
1597
1598 pub fn poll_export(&mut self) {
1599 let rx = match self.export_rx.take() {
1600 Some(rx) => rx,
1601 None => return,
1602 };
1603 let mut keep_rx = true;
1604 while let Ok(event) = rx.try_recv() {
1605 match event {
1606 ExportEvent::Progress(pct) => {
1607 self.export_progress = pct;
1608 if let Some(q) = self.queue.iter_mut().find(|q| matches!(q.status, QueueStatus::Rendering)) {
1609 q.progress = pct;
1610 }
1611 }
1612 ExportEvent::Stats(_stats) => {
1613 }
1616 ExportEvent::Done(result) => {
1617 self.is_exporting = false;
1618 keep_rx = false;
1619 self.cancel_token = None;
1620 let elapsed = self.export_start_time
1621 .take()
1622 .map(|t| t.elapsed())
1623 .unwrap_or_default();
1624 if let Some(idx) = self.current_rendering_index {
1626 if idx < self.queue.len() {
1627 self.queue[idx].progress = 100.0;
1628 if self.export_cancelled {
1629 self.queue[idx].status = QueueStatus::Waiting;
1630 } else {
1631 match &result {
1632 Ok(()) => {
1633 self.queue[idx].status = QueueStatus::Completed;
1634 }
1635 Err(e) => {
1636 self.queue[idx].status = QueueStatus::Failed(e.to_string());
1637 }
1638 }
1639 }
1640 }
1641 }
1642 if let Some(mut summary) = self.pending_export_summary.take() {
1646 summary.elapsed = elapsed;
1647 summary.result = if self.export_cancelled {
1648 Err("Cancelled by user".to_string())
1649 } else {
1650 match &result {
1651 Ok(()) => Ok(()),
1652 Err(e) => Err(e.to_string()),
1653 }
1654 };
1655 self.last_export_summary = Some(summary);
1656 }
1657 if self.export_cancelled {
1658 self.status_message = "Export cancelled".to_string();
1659 self.export_cancelled = false;
1660 self.current_rendering_index = None;
1661 } else {
1662 let mins = elapsed.as_secs() / 60;
1663 let secs = elapsed.as_secs() % 60;
1664 match result {
1665 Ok(()) => {
1666 tracing::info!("export completed in {:02}m {:02}s", mins, secs);
1667 self.status_message = format!(
1668 "Video export completed ({:02}m {:02}s)", mins, secs
1669 );
1670 self.shockwave_ticks_remaining = 30;
1671 }
1672 Err(e) => {
1673 tracing::error!("export failed: {}", e);
1674 self.status_message = format!("Export failed: {}", e);
1675 }
1676 }
1677 self.start_next_queued_render();
1679 }
1680 self.export_start_time = None;
1681 }
1682 }
1683 }
1684 if keep_rx {
1685 self.export_rx = Some(rx);
1686 }
1687 }
1688
1689 pub fn add_encode_job(&mut self, format: OutputFormat) {
1690 let job = EncodeJob::new(uuid::Uuid::new_v4().to_string()[..8].to_string(), format);
1691 self.encode_jobs.push(job);
1692 self.status_message = "Export job added".to_string();
1693 }
1694
1695 pub fn select_file(&mut self) {
1700 let entry_data = self.browser.selected_entry().map(|e| (e.is_dir, e.name.clone(), e.path.clone()));
1701 if let Some((is_dir, name, path)) = entry_data {
1702 if is_dir {
1703 self.browser.enter();
1704 self.status_message = format!("Entered: {}", name);
1705 self.show_favourites_bar = false;
1706 } else if name.ends_with(".mcraw") {
1707 let path_str = path.to_string_lossy().to_string();
1708 self.load_file(path_str);
1709 self.show_browser = false;
1710 } else {
1711 self.status_message = format!("Cannot open: {} (not a .mcraw file)", name);
1712 }
1713 }
1714 }
1715
1716 pub fn scan_mcraw_files_in_folder(&self, folder: &str) -> Vec<String> {
1718 if let Ok(entries) = std::fs::read_dir(folder) {
1719 let mut files: Vec<String> = entries
1720 .filter_map(|e| e.ok())
1721 .map(|e| e.path())
1722 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
1723 .map(|p| p.to_string_lossy().to_string())
1724 .collect();
1725 files.sort();
1726 files
1727 } else {
1728 Vec::new()
1729 }
1730 }
1731
1732 pub fn navigate_browser(&mut self, direction: BrowserDirection) {
1733 match direction {
1734 BrowserDirection::Up => {
1735 self.browser.navigate_up();
1736 }
1737 BrowserDirection::Down => {
1738 self.browser.navigate_down();
1739 }
1740 BrowserDirection::Enter => self.select_file(),
1741 BrowserDirection::GoUp => {
1742 self.browser.go_up();
1743 self.show_favourites_bar = false;
1744 }
1745 BrowserDirection::ToggleHidden => self.browser.toggle_hidden(),
1746 }
1747 }
1748
1749 pub fn navigate_favourites(&mut self, delta: i64) {
1751 if self.favourite_folders.is_empty() {
1752 return;
1753 }
1754 let cur = self.favourites_scroll_offset.get() as i64;
1755 let max = (self.favourite_folders.len() as i64) - 1;
1756 let next = (cur + delta).clamp(0, max);
1757 self.favourites_scroll_offset.set(next as usize);
1758 }
1759
1760 pub fn open_selected_favourite(&mut self) {
1762 let idx = self.favourites_scroll_offset.get();
1763 if let Some(path) = self.favourite_folders.get(idx).cloned() {
1764 self.status_message = format!("Navigated to favourite: {}", path.display());
1765 self.browser = FileBrowser::from_path(path);
1766 self.browser_scroll_offset = Cell::new(0);
1767 self.browsing_favourites = false;
1768 self.show_favourites_bar = false;
1769 }
1770 }
1771
1772 pub fn delete_selected_favourite(&mut self) {
1774 let idx = self.favourites_scroll_offset.get();
1775 if idx < self.favourite_folders.len() {
1776 let name = self.favourite_folders[idx].display().to_string();
1777 self.favourite_folders.remove(idx);
1778 self.save_favourites();
1779 if self.favourite_folders.is_empty() {
1780 self.browsing_favourites = false;
1781 } else if self.favourites_scroll_offset.get() >= self.favourite_folders.len() {
1782 self.favourites_scroll_offset.set(self.favourite_folders.len() - 1);
1783 }
1784 self.status_message = format!("Removed favourite: {}", name);
1785 }
1786 }
1787
1788 pub fn cycle_focus(&mut self) {
1793 self.focus_target = match self.focus_target {
1794 FocusTarget::MediaPool => FocusTarget::Preview,
1795 FocusTarget::Preview => FocusTarget::Grade,
1796 FocusTarget::Grade => FocusTarget::ExportSettings,
1797 FocusTarget::ExportSettings => FocusTarget::Queue,
1798 FocusTarget::Queue => FocusTarget::MediaPool,
1799 };
1800 let label = match self.focus_target {
1801 FocusTarget::MediaPool => "Media Pool",
1802 FocusTarget::Preview => "Preview",
1803 FocusTarget::Grade => "Grade",
1804 FocusTarget::ExportSettings => "Export Settings",
1805 FocusTarget::Queue => "Render Queue",
1806 };
1807 self.status_message = format!("Focus: {}", label);
1808 }
1809
1810 pub fn set_focus(&mut self, target: FocusTarget) {
1811 self.focus_target = target;
1812 let label = match target {
1813 FocusTarget::MediaPool => "Media Pool",
1814 FocusTarget::Preview => "Preview",
1815 FocusTarget::Grade => "Grade",
1816 FocusTarget::ExportSettings => "Export Settings",
1817 FocusTarget::Queue => "Render Queue",
1818 };
1819 self.status_message = format!("Focus: {}", label);
1820 }
1821
1822}
1823
1824fn execute_click_action(app: &mut App, action: ClickAction) {
1825 match action {
1826 ClickAction::ToggleBrowser => {
1827 app.show_browser = !app.show_browser;
1828 app.status_message = if app.show_browser { "Browser shown" } else { "Browser hidden" }.to_string();
1829 }
1830 ClickAction::ToggleFileSelection(i) => {
1831 if let Some(f) = app.imported_files.get_mut(i) {
1832 f.selected = !f.selected;
1833 }
1834 }
1835 ClickAction::ToggleQueueSelection(i) => {
1836 if let Some(q) = app.queue.get_mut(i) {
1837 q.selected = !q.selected;
1838 }
1839 }
1840 ClickAction::SelectMediaPoolItem(i) => {
1841 if i < app.imported_files.len() {
1842 app.media_pool_index = i;
1843 app.set_focus(FocusTarget::MediaPool);
1844 }
1845 }
1846 ClickAction::SelectQueueItem(i) => {
1847 if i < app.queue.len() {
1848 app.queue_index = i;
1849 app.set_focus(FocusTarget::Queue);
1850 }
1851 }
1852 ClickAction::FocusMediaPool => {
1853 app.set_focus(FocusTarget::MediaPool);
1854 }
1855 ClickAction::FocusQueue => {
1856 app.set_focus(FocusTarget::Queue);
1857 }
1858 ClickAction::FocusExport => {
1859 app.set_focus(FocusTarget::ExportSettings);
1860 }
1861 ClickAction::FocusPreview => {
1862 app.set_focus(FocusTarget::Preview);
1863 }
1864 ClickAction::FocusGrade => {
1865 app.show_grade_screen = !app.show_grade_screen;
1866 if app.show_grade_screen {
1867 app.set_focus(FocusTarget::Grade);
1868 app.status_message = "Grade screen — Esc to exit".to_string();
1869 } else {
1870 app.grade_dragging = None;
1871 app.set_focus(FocusTarget::Preview);
1872 app.status_message = "Normal view".to_string();
1873 }
1874 }
1875 ClickAction::AddSelectedToQueue => app.add_selected_to_queue(),
1876 ClickAction::AddAllToQueue => app.add_all_to_queue(),
1877 ClickAction::RemoveSelectedFromMediaPool => app.remove_selected_from_media_pool(),
1878 ClickAction::ToggleBrowserSelection(i) => {
1879 if let Some(entry) = app.browser.entries.get_mut(i) {
1880 if entry.name.to_lowercase().ends_with(".mcraw") {
1881 entry.selected = !entry.selected;
1882 }
1883 }
1884 }
1885 ClickAction::RenderSelected => app.render_selected(),
1886 ClickAction::RenderAll => app.render_all(),
1887 ClickAction::ClearQueue => app.clear_completed_queue(),
1888 ClickAction::CycleCodec => {
1889 app.set_focus(FocusTarget::ExportSettings);
1890 app.cycle_codec(true);
1891 }
1892 ClickAction::CycleGamut => {
1893 app.set_focus(FocusTarget::ExportSettings);
1894 app.export_focus = ExportFocus::ColorSpace;
1895 app.export_color_space = app.export_color_space.next();
1896 app.status_message = format!("Gamut: {}", app.export_color_space.name());
1897 }
1898 ClickAction::CycleTransfer => {
1899 app.set_focus(FocusTarget::ExportSettings);
1900 app.export_focus = ExportFocus::TransferFunction;
1901 app.export_transfer_function = app.export_transfer_function.next();
1902 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
1903 }
1904 ClickAction::CycleProfile => {
1905 app.set_focus(FocusTarget::ExportSettings);
1906 app.cycle_profile(true);
1907 }
1908 ClickAction::CycleRate => {
1909 app.set_focus(FocusTarget::ExportSettings);
1910 app.export_focus = ExportFocus::RateControl;
1911 app.cycle_rate_control();
1912 }
1913 ClickAction::ImportOption1 => {
1914 if app.import_popup != ImportPopupState::Hidden {
1915 if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
1916 let files = files.clone();
1917 if !files.is_empty() {
1918 let count = files.len();
1919 app.status_message = format!("Importing {} file(s)...", count);
1920 let (imported, failed) = app.load_files_batch(&files);
1921 if failed > 0 {
1922 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
1923 } else {
1924 app.status_message = format!("Imported {} file(s)", imported);
1925 }
1926 }
1927 app.import_popup = ImportPopupState::Hidden;
1928 app.show_browser = false;
1929 }
1930 } else if app.show_browser {
1931 app.import_selected_from_browser();
1932 }
1933 }
1934 ClickAction::ImportOption2 => {
1935 if app.import_popup != ImportPopupState::Hidden {
1936 if let ImportPopupState::DroppedFiles { all_in_folder, .. } = &app.import_popup {
1937 let all_in_folder = all_in_folder.clone();
1938 if !all_in_folder.is_empty() {
1939 let count = all_in_folder.len();
1940 app.status_message = format!("Importing all {} file(s) from folder...", count);
1941 let (imported, failed) = app.load_files_batch(&all_in_folder);
1942 if failed > 0 {
1943 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
1944 } else {
1945 app.status_message = format!("Imported all {} file(s)", imported);
1946 }
1947 }
1948 app.import_popup = ImportPopupState::Hidden;
1949 app.show_browser = false;
1950 }
1951 } else if app.show_browser {
1952 let folder = app.browser.current_path.clone();
1953 app.load_all_in_folder(&folder);
1954 app.show_browser = false;
1955 }
1956 }
1957 ClickAction::ClosePopup => { app.import_popup = ImportPopupState::Hidden; }
1958 ClickAction::ToggleHelp => { app.show_help = !app.show_help; }
1959 ClickAction::BrowserNavigate(i) => {
1960 let now = Instant::now();
1961 let was_same = app.last_browser_click.as_ref().map(|&(_, idx)| idx == i).unwrap_or(false);
1962 let is_double = app.last_browser_click.as_ref().map(|&(t, _)| now.duration_since(t).as_millis() < 400).unwrap_or(false);
1963
1964 app.browser.selected_index = i;
1965
1966 if was_same && is_double {
1967 app.select_file();
1968 app.last_browser_click = None;
1969 } else {
1970 app.last_browser_click = Some((now, i));
1971 }
1972 }
1973 ClickAction::BrowserSelectAndEnter(i) => {
1974 let now = Instant::now();
1975 let was_same = app.last_browser_click.as_ref().map(|&(_, idx)| idx == i).unwrap_or(false);
1976 let is_double = app.last_browser_click.as_ref().map(|&(t, _)| now.duration_since(t).as_millis() < 400).unwrap_or(false);
1977
1978 app.browser.selected_index = i;
1979
1980 if was_same && is_double {
1981 app.select_file();
1982 app.last_browser_click = None;
1983 } else {
1984 app.last_browser_click = Some((now, i));
1985 }
1986 }
1987 ClickAction::BrowserEnter => {
1988 app.navigate_browser(BrowserDirection::Enter);
1989 }
1990 ClickAction::BrowserGoUp => {
1991 app.navigate_browser(BrowserDirection::GoUp);
1992 }
1993 ClickAction::FavouriteNavigate(i) => {
1994 if i < app.favourite_folders.len() {
1995 let path = app.favourite_folders[i].clone();
1996 app.browser = FileBrowser::from_path(path);
1997 app.browser_scroll_offset = Cell::new(0);
1998 app.show_favourites_bar = false;
1999 app.last_clicked_favourite = Some((Instant::now(), i));
2000 app.status_message = "Navigated to favourite folder".to_string();
2001 }
2002 }
2003 ClickAction::OpenPresetPicker => {
2004 app.open_preset_picker();
2005 }
2006 ClickAction::GradeSlider(i) => {
2007 app.grade_focus = i;
2008 app.set_focus(FocusTarget::Grade);
2009 }
2010 }
2011}
2012
2013pub enum BrowserDirection {
2014 Up,
2015 Down,
2016 Enter,
2017 GoUp,
2018 ToggleHidden,
2019}
2020
2021pub async fn run(args: Cli) -> Result<()> {
2022 let mut app = App::new();
2023 tracing::info!("app initialized: hardware_caps={:?}", app.hardware_caps);
2024
2025 match args.resolve() {
2026 ResolvedCli::Command(CliCommands::Open { file }) => {
2027 if let Some(path) = file {
2028 app.load_file(path);
2029 }
2030 }
2031 ResolvedCli::Command(CliCommands::Info { file }) => {
2032 let path = match file {
2033 Some(p) => p,
2034 None => return Err(anyhow::anyhow!("No file specified")),
2035 };
2036 match McrawFileInfo::from_path(&path) {
2037 Ok(mut info) => {
2038 info.enhance_with_decoder();
2039 return Ok(());
2040 }
2041 Err(e) => return Err(e),
2042 }
2043 }
2044 ResolvedCli::Command(CliCommands::Export { file, format, output }) => {
2045 if file.is_none() {
2046 return Err(anyhow::anyhow!("No file specified"));
2047 }
2048 if let Err(e) = Cli::validate_export_format(&format) {
2049 anyhow::bail!("{}", e);
2050 }
2051 let format = match format.to_lowercase().as_str() {
2052 "dng" => OutputFormat::DNG { output_path: std::path::PathBuf::from(&output) },
2053 "prores" => OutputFormat::ProRes { output_path: std::path::PathBuf::from(&output) },
2054 "h264" => OutputFormat::H264 { output_path: std::path::PathBuf::from(&output) },
2055 "hevc" => OutputFormat::HEVC { output_path: std::path::PathBuf::from(&output) },
2056 _ => anyhow::bail!("Invalid format: {}", format),
2057 };
2058
2059 let encoder = Encoder::new();
2060 let mut job = EncodeJob::new("cli-export".to_string(), format.clone());
2061 job.status = EncodeStatus::Running;
2062
2063 match encoder.start_job(job.clone()).await {
2064 Ok(()) => { job.status = EncodeStatus::Completed; }
2065 Err(e) => { job.status = EncodeStatus::Failed(e.to_string()); }
2066 }
2067 return Ok(());
2068 }
2069 ResolvedCli::NoFile => {
2070 app.status_message = "No file specified. Use: mcraw-tui -f <path>".to_string();
2071 }
2072 }
2073
2074 let stdout = std::io::stdout();
2075 let backend = CrosstermBackend::new(stdout);
2076 let mut terminal = ratatui::Terminal::new(backend)?;
2077 terminal.clear()?;
2078 crossterm::execute!(
2079 std::io::stdout(),
2080 EnterAlternateScreen,
2081 EnableBracketedPaste,
2082 EnableMouseCapture,
2083 )?;
2084 terminal.hide_cursor()?;
2085
2086 enable_raw_mode()?;
2087 tracing::info!("terminal initialized: alternate_screen, bracketed_paste, mouse_capture enabled");
2088
2089 let event_loop_running = Arc::new(AtomicBool::new(true));
2090 let elr = event_loop_running.clone();
2091
2092 let (tx, rx) = mpsc::channel();
2093 tokio::spawn(async move {
2094 event_loop(tx, elr).await;
2095 });
2096
2097 let encoder = Encoder::new();
2098 tracing::info!("entering main event loop");
2099
2100 while app.running {
2101 app.poll_export();
2102 app.poll_drop_import();
2103 app.browser.try_refresh();
2104
2105 app.fps_counter.tick();
2109
2110 let mut click_regions = Vec::new();
2111 terminal.draw(|frame| ui::render(frame, &app, &mut click_regions))?;
2112
2113 app.spinner_frame = app.spinner_frame.wrapping_add(1);
2115 if app.spinner_frame % 4 == 0 {
2117 app.progress_anim_offset = app.progress_anim_offset.wrapping_add(1);
2118 }
2119 if app.shockwave_ticks_remaining > 0 {
2120 app.shockwave_ticks_remaining -= 1;
2121 }
2122 if let Some((_, ref mut t)) = app.grade_morph {
2124 *t = t.saturating_sub(1);
2125 if *t == 0 { app.grade_morph = None; }
2126 }
2127 app.phosphor_trail.iter_mut().for_each(|(_, t)| *t = t.saturating_sub(1));
2129 app.phosphor_trail.retain(|(_, t)| *t > 0);
2130 if app.grade_strip_idle_ticks > 0 {
2132 app.grade_strip_idle_ticks = app.grade_strip_idle_ticks.saturating_sub(1);
2133 } else if app.show_grade_screen {
2134 app.grade_strip_active = false;
2135 }
2136
2137 while let Ok(event) = rx.try_recv() {
2142 handle_event(&mut app, event, &encoder, &click_regions).await;
2143 }
2144
2145 time::sleep(Duration::from_millis(16)).await;
2146 }
2147
2148 event_loop_running.store(false, Ordering::Relaxed);
2149 drop(rx);
2150 tokio::task::yield_now().await;
2151
2152 disable_raw_mode()?;
2153 terminal.show_cursor()?;
2154 crossterm::execute!(
2155 std::io::stdout(),
2156 DisableMouseCapture,
2157 DisableBracketedPaste,
2158 LeaveAlternateScreen,
2159 )?;
2160 tracing::info!("terminal shutdown: raw_mode disabled, screen restored");
2161
2162 Ok(())
2163}
2164
2165async fn event_loop(tx: mpsc::Sender<Event>, running: Arc<AtomicBool>) {
2166 tracing::debug!("event_loop started");
2167 while running.load(Ordering::Relaxed) {
2168 if crossterm::event::poll(Duration::from_millis(8)).unwrap() {
2169 if let Ok(event) = crossterm::event::read() {
2170 if tx.send(event).is_err() {
2171 break;
2172 }
2173 }
2174 }
2175 }
2176}
2177
2178fn strip_surrounding_quotes(s: &str) -> String {
2184 let s = s.trim();
2185 if s.len() >= 2 {
2186 let first = s.chars().next().unwrap();
2187 let last = s.chars().last().unwrap();
2188 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
2189 return s[1..s.len() - 1].to_string();
2190 }
2191 }
2192 s.to_string()
2193}
2194
2195fn expand_tilde(s: &str) -> String {
2197 if s == "~" {
2198 if let Some(home) = dirs::home_dir() {
2199 return home.to_string_lossy().to_string();
2200 }
2201 }
2202 if let Some(rest) = s.strip_prefix("~/") {
2203 if let Some(home) = dirs::home_dir() {
2204 return home.join(rest).to_string_lossy().to_string();
2205 }
2206 }
2207 s.to_string()
2208}
2209
2210fn decode_file_uri(s: &str) -> String {
2213 if let Some(rest) = s.strip_prefix("file:///") {
2214 if cfg!(windows) && rest.len() >= 2 {
2216 let chars: Vec<char> = rest.chars().collect();
2217 if chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':' {
2218 return rest.to_string();
2219 }
2220 }
2221 return format!("/{}", rest);
2223 }
2224 if let Some(rest) = s.strip_prefix("file://") {
2225 if let Some(slash_pos) = rest.find('/') {
2227 return rest[slash_pos..].to_string();
2228 }
2229 return rest.to_string();
2230 }
2231 s.to_string()
2232}
2233
2234fn percent_decode_path(s: &str) -> String {
2236 if !s.contains('%') {
2237 return s.to_string();
2238 }
2239 match percent_decode_str(s).decode_utf8() {
2240 Ok(decoded) => decoded.into_owned(),
2241 Err(_) => s.to_string(), }
2243}
2244
2245fn normalize_path(s: &str) -> String {
2247 if cfg!(windows) {
2248 if s.starts_with("\\\\") {
2250 return s.to_string();
2251 }
2252 s.replace('/', "\\")
2254 } else {
2255 s.to_string()
2256 }
2257}
2258
2259fn validate_path(s: &str) -> Option<String> {
2261 let path = std::path::Path::new(s);
2262
2263 if !path.exists() {
2265 tracing::debug!("path validation: does not exist: {}", s);
2266 return None;
2267 }
2268
2269 match path.canonicalize() {
2272 Ok(canonical) => Some(canonical.to_string_lossy().to_string()),
2273 Err(_) => {
2274 tracing::debug!("path validation: canonicalize failed, using original: {}", s);
2275 Some(s.to_string())
2276 }
2277 }
2278}
2279
2280async fn handle_event(app: &mut App, event: Event, _encoder: &Encoder, click_regions: &[ui::ClickRegion]) {
2281 match event {
2282 Event::Paste(pasted) => {
2286 tracing::trace!("drag-drop: raw pasted bytes={:?} len={}", pasted.as_bytes(), pasted.len());
2287
2288 let paths: Vec<String> = pasted
2289 .lines()
2290 .filter_map(|line| {
2291 let line = line.trim();
2292 if line.is_empty() {
2293 return None;
2294 }
2295
2296 let stripped = strip_surrounding_quotes(line);
2298
2299 let expanded = expand_tilde(&stripped);
2301
2302 let decoded = decode_file_uri(&expanded);
2304
2305 let percent_decoded = percent_decode_path(&decoded);
2307
2308 let normalized = normalize_path(&percent_decoded);
2310
2311 validate_path(&normalized)
2313 })
2314 .collect();
2315
2316 tracing::trace!("drag-drop: parsed {} paths: {:?}", paths.len(), paths);
2317
2318 if paths.is_empty() {
2319 app.status_message = "Drag-drop: no valid paths received".to_string();
2320 return;
2321 }
2322
2323 let mut mcraw_files: Vec<String> = Vec::new();
2325 let mut folders: Vec<String> = Vec::new();
2326
2327 for p in &paths {
2328 let path = std::path::Path::new(p);
2329 if path.is_dir() {
2330 folders.push(p.clone());
2331 } else if p.to_lowercase().ends_with(".mcraw") {
2332 mcraw_files.push(p.clone());
2333 }
2334 }
2335
2336 for folder in &folders {
2338 if let Ok(entries) = std::fs::read_dir(folder) {
2339 let mut files: Vec<String> = entries
2340 .filter_map(|e| e.ok())
2341 .map(|e| e.path())
2342 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2343 .map(|p| p.to_string_lossy().to_string())
2344 .collect();
2345 files.sort();
2346 mcraw_files.extend(files);
2347 }
2348 }
2349
2350 let mut seen = std::collections::HashSet::new();
2352 mcraw_files.retain(|f| seen.insert(f.clone()));
2353
2354 tracing::info!("drag-drop: {} .mcraw files, {} folders", mcraw_files.len(), folders.len());
2355
2356 if mcraw_files.is_empty() {
2357 app.status_message = "Drag-drop: no .mcraw files found in dropped items".to_string();
2358 return;
2359 }
2360
2361 app.drop_highlight = Some(Instant::now());
2363
2364 const ASYNC_THRESHOLD: usize = 3;
2367
2368 if mcraw_files.len() <= ASYNC_THRESHOLD && folders.is_empty() {
2369 app.start_async_import(mcraw_files);
2371 } else {
2372 if mcraw_files.len() == 1 {
2375 let file = &mcraw_files[0];
2376 let folder = std::path::Path::new(file)
2377 .parent()
2378 .map(|p| p.to_string_lossy().to_string())
2379 .unwrap_or_else(|| ".".to_string());
2380
2381 let all_in_folder: Vec<String> = if let Ok(entries) = std::fs::read_dir(&folder) {
2382 let mut files: Vec<String> = entries
2383 .filter_map(|e| e.ok())
2384 .map(|e| e.path())
2385 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2386 .map(|p| p.to_string_lossy().to_string())
2387 .collect();
2388 files.sort();
2389 files
2390 } else {
2391 Vec::new()
2392 };
2393
2394 if all_in_folder.len() == 1 {
2396 app.start_async_import(mcraw_files);
2397 return;
2398 }
2399 }
2400
2401 let folder = if !folders.is_empty() {
2403 folders[0].clone()
2404 } else {
2405 std::path::Path::new(&mcraw_files[0])
2406 .parent()
2407 .map(|p| p.to_string_lossy().to_string())
2408 .unwrap_or_else(|| ".".to_string())
2409 };
2410
2411 let all_in_folder: Vec<String> = if let Ok(entries) = std::fs::read_dir(&folder) {
2413 let mut files: Vec<String> = entries
2414 .filter_map(|e| e.ok())
2415 .map(|e| e.path())
2416 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2417 .map(|p| p.to_string_lossy().to_string())
2418 .collect();
2419 files.sort();
2420 files
2421 } else {
2422 Vec::new()
2423 };
2424
2425 app.import_popup = ImportPopupState::DroppedFiles {
2427 files: mcraw_files,
2428 folder,
2429 all_in_folder,
2430 };
2431 }
2432 }
2433
2434 Event::Mouse(mouse_event) => {
2438 use crossterm::event::{MouseEventKind, MouseButton};
2439
2440 if app.import_popup != ImportPopupState::Hidden {
2442 let col = mouse_event.column;
2443 let row = mouse_event.row;
2444 match mouse_event.kind {
2445 MouseEventKind::Down(MouseButton::Left) => {
2446 for region in click_regions.iter().rev() {
2447 if col >= region.area.x && col < region.area.x + region.area.width
2448 && row >= region.area.y && row < region.area.y + region.area.height {
2449 match ®ion.action {
2450 ClickAction::ImportOption1 | ClickAction::ImportOption2 => {
2451 execute_click_action(app, region.action.clone());
2452 }
2453 _ => {}
2454 }
2455 break;
2456 }
2457 }
2458 }
2459 _ => {}
2460 }
2461 return;
2462 }
2463
2464 if app.show_full_info {
2466 return;
2467 }
2468
2469 match mouse_event.kind {
2470 MouseEventKind::ScrollUp => {
2471 if app.show_help {
2472 app.help_scroll = app.help_scroll.saturating_sub(1);
2473 } else if app.show_browser {
2474 if app.browsing_favourites {
2475 app.navigate_favourites(-1);
2476 } else if app.browser.selected_index > 0 {
2477 app.browser.selected_index -= 1;
2478 }
2479 } else {
2480 match app.focus_target {
2481 FocusTarget::MediaPool => { if app.media_pool_index > 0 { app.media_pool_index -= 1; } }
2482 FocusTarget::Queue => { if app.queue_index > 0 { app.queue_index -= 1; } }
2483 FocusTarget::ExportSettings => {
2484 match app.export_focus {
2486 ExportFocus::CodecFamily => app.cycle_codec(false),
2487 ExportFocus::ColorSpace => {
2488 app.export_color_space = app.export_color_space.prev();
2489 app.status_message = format!("Gamut: {}", app.export_color_space.name());
2490 }
2491 ExportFocus::TransferFunction => {
2492 app.export_transfer_function = app.export_transfer_function.prev();
2493 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
2494 }
2495 ExportFocus::Profile => app.cycle_profile(false),
2496 ExportFocus::RateControl => {
2497 app.active_rate_control = app.active_rate_control.prev();
2498 app.status_message = format!("Rate: {}", app.active_rate_control.name());
2499 }
2500 }
2501 }
2502 FocusTarget::Preview => {}
2503 FocusTarget::Grade => {
2504 let step = if mouse_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
2505 GradeSliders::step_large(app.grade_focus)
2506 } else {
2507 GradeSliders::step_small(app.grade_focus)
2508 };
2509 app.grade_sliders.apply_delta(app.grade_focus, step);
2510 }
2511 }
2512 }
2513 }
2514 MouseEventKind::ScrollDown => {
2515 if app.show_help {
2516 app.help_scroll = app.help_scroll.saturating_add(1);
2517 } else if app.show_browser {
2518 if app.browsing_favourites {
2519 app.navigate_favourites(1);
2520 } else {
2521 let len = app.browser.entries.len();
2522 if len > 0 { app.browser.selected_index = (app.browser.selected_index + 1).min(len - 1); }
2523 }
2524 } else {
2525 match app.focus_target {
2526 FocusTarget::MediaPool => {
2527 let len = app.imported_files.len();
2528 if len > 0 { app.media_pool_index = (app.media_pool_index + 1).min(len - 1); }
2529 }
2530 FocusTarget::Queue => {
2531 let len = app.queue.len();
2532 if len > 0 { app.queue_index = (app.queue_index + 1).min(len - 1); }
2533 }
2534 FocusTarget::ExportSettings => {
2535 match app.export_focus {
2536 ExportFocus::CodecFamily => app.cycle_codec(true),
2537 ExportFocus::ColorSpace => {
2538 app.export_color_space = app.export_color_space.next();
2539 app.status_message = format!("Gamut: {}", app.export_color_space.name());
2540 }
2541 ExportFocus::TransferFunction => {
2542 app.export_transfer_function = app.export_transfer_function.next();
2543 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
2544 }
2545 ExportFocus::Profile => app.cycle_profile(true),
2546 ExportFocus::RateControl => app.cycle_rate_control(),
2547 }
2548 }
2549 FocusTarget::Preview => {}
2550 FocusTarget::Grade => {
2551 let step = if mouse_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
2552 GradeSliders::step_large(app.grade_focus)
2553 } else {
2554 GradeSliders::step_small(app.grade_focus)
2555 };
2556 app.grade_sliders.apply_delta(app.grade_focus, -step);
2557 }
2558 }
2559 }
2560 }
2561 MouseEventKind::Down(MouseButton::Left) => {
2562 let col = mouse_event.column;
2563 let row = mouse_event.row;
2564 for region in click_regions.iter().rev() {
2565 if col >= region.area.x && col < region.area.x + region.area.width
2566 && row >= region.area.y && row < region.area.y + region.area.height {
2567 match ®ion.action {
2568 ClickAction::GradeSlider(i) => {
2569 let now = Instant::now();
2570 let is_double = app.last_grade_click.as_ref()
2571 .map(|&(t, idx)| idx == *i && now.duration_since(t).as_millis() < 400)
2572 .unwrap_or(false);
2573 if is_double {
2574 let def = GradeSliders::default_val(*i);
2576 app.grade_sliders.set(*i, def);
2577 app.last_grade_click = None;
2578 app.status_message = format!("Reset {} to default", GradeSliders::name(*i));
2579 } else {
2580 let x_offset = col.saturating_sub(region.area.x);
2582 let norm = (x_offset as f32 / region.area.width.max(1) as f32).clamp(0.0, 1.0);
2583 let lo = GradeSliders::min(*i);
2584 let hi = GradeSliders::max(*i);
2585 app.grade_sliders.set(*i, lo + norm * (hi - lo));
2586 app.grade_focus = *i;
2587 app.grade_dragging = Some((*i, region.area.x, region.area.width));
2588 app.last_grade_click = Some((now, *i));
2589 }
2590 }
2591 _ => execute_click_action(app, region.action.clone()),
2592 }
2593 break;
2594 }
2595 }
2596 }
2597 MouseEventKind::Drag(MouseButton::Left) => {
2598 if let Some((i, track_x, track_w)) = app.grade_dragging {
2599 let col = mouse_event.column;
2600 let x_offset = col.saturating_sub(track_x);
2601 let norm = (x_offset as f32 / track_w.max(1) as f32).clamp(0.0, 1.0);
2602 let lo = GradeSliders::min(i);
2603 let hi = GradeSliders::max(i);
2604 app.grade_sliders.set(i, lo + norm * (hi - lo));
2605 }
2606 }
2607 MouseEventKind::Up(MouseButton::Left) => {
2608 app.grade_dragging = None;
2609 }
2610 _ => {}
2611 }
2612 }
2613
2614 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
2618 if let crossterm::event::KeyCode::Char('c') = key_event.code {
2619 if key_event.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
2620 tracing::info!("ctrl+c received, quitting");
2621 app.running = false;
2622 return;
2623 }
2624 }
2625 if let crossterm::event::KeyCode::Char('x') = key_event.code {
2628 if key_event.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
2629 if app.is_exporting {
2630 tracing::info!("ctrl+x received, cancelling export");
2631 app.cancel_export();
2632 }
2633 return;
2634 }
2635 }
2636
2637 tracing::debug!("key event: code={:?} modifiers={:?}", key_event.code, key_event.modifiers);
2638
2639 if app.preset_naming.is_some() {
2643 let naming = app.preset_naming.clone().unwrap();
2644 match key_event.code {
2645 crossterm::event::KeyCode::Char(c) => {
2646 if let Some(state) = app.preset_naming.as_mut() {
2647 state.name.push(c);
2648 }
2649 }
2650 crossterm::event::KeyCode::Backspace => {
2651 if let Some(state) = app.preset_naming.as_mut() {
2652 state.name.pop();
2653 }
2654 }
2655 crossterm::event::KeyCode::Enter => {
2656 app.commit_naming_preset();
2657 }
2658 crossterm::event::KeyCode::Esc => {
2659 app.cancel_naming_preset();
2660 app.status_message = "Preset save cancelled".to_string();
2661 }
2662 _ => {}
2663 }
2664 let _ = naming; return;
2666 }
2667
2668 if app.preset_picker.open {
2672 match key_event.code {
2673 crossterm::event::KeyCode::Esc => app.close_preset_picker(),
2674 crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
2675 if app.preset_picker.index > 0 {
2676 app.preset_picker.index -= 1;
2677 }
2678 app.preset_picker.message = None;
2679 }
2680 crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
2681 if app.preset_picker.index + 1 < app.presets.len() {
2682 app.preset_picker.index += 1;
2683 }
2684 app.preset_picker.message = None;
2685 }
2686 crossterm::event::KeyCode::Enter => {
2687 let idx = app.preset_picker.index;
2688 app.close_preset_picker();
2689 app.apply_preset(idx);
2690 }
2691 crossterm::event::KeyCode::Delete | crossterm::event::KeyCode::Backspace => {
2692 let idx = app.preset_picker.index;
2693 app.delete_preset(idx);
2694 }
2695 _ => {}
2696 }
2697 return;
2698 }
2699
2700 if app.import_popup != ImportPopupState::Hidden {
2704 let has_option2 = if let ImportPopupState::DroppedFiles { files, all_in_folder, .. } = &app.import_popup {
2705 all_in_folder.len() > files.len()
2706 } else {
2707 false
2708 };
2709
2710 match key_event.code {
2711 crossterm::event::KeyCode::Char('1') => {
2712 let files = if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
2713 files.clone()
2714 } else {
2715 Vec::new()
2716 };
2717 if !files.is_empty() {
2718 let count = files.len();
2719 app.status_message = format!("Importing {} file(s)...", count);
2720 let (imported, failed) = app.load_files_batch(&files);
2721 if failed > 0 {
2722 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2723 } else {
2724 app.status_message = format!("Imported {} file(s)", imported);
2725 }
2726 }
2727 app.import_popup = ImportPopupState::Hidden;
2728 app.show_browser = false;
2729 }
2730 crossterm::event::KeyCode::Char('2') if has_option2 => {
2731 let all_in_folder = if let ImportPopupState::DroppedFiles { all_in_folder, .. } = &app.import_popup {
2732 all_in_folder.clone()
2733 } else {
2734 Vec::new()
2735 };
2736 if !all_in_folder.is_empty() {
2737 let count = all_in_folder.len();
2738 app.status_message = format!("Importing all {} file(s) from folder...", count);
2739 let (imported, failed) = app.load_files_batch(&all_in_folder);
2740 if failed > 0 {
2741 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2742 } else {
2743 app.status_message = format!("Imported all {} file(s)", imported);
2744 }
2745 }
2746 app.import_popup = ImportPopupState::Hidden;
2747 app.show_browser = false;
2748 }
2749 crossterm::event::KeyCode::Enter => {
2750 let files = if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
2751 files.clone()
2752 } else {
2753 Vec::new()
2754 };
2755 if !files.is_empty() {
2756 let count = files.len();
2757 app.status_message = format!("Importing {} file(s)...", count);
2758 let (imported, failed) = app.load_files_batch(&files);
2759 if failed > 0 {
2760 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2761 } else {
2762 app.status_message = format!("Imported {} file(s)", imported);
2763 }
2764 }
2765 app.import_popup = ImportPopupState::Hidden;
2766 app.show_browser = false;
2767 }
2768 crossterm::event::KeyCode::Esc => {
2769 app.import_popup = ImportPopupState::Hidden;
2770 }
2771 _ => {}
2772 }
2773 return;
2774 }
2775
2776 if app.is_editing_custom_rate {
2780 match key_event.code {
2781 crossterm::event::KeyCode::Char(c) => {
2782 if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == 'M' || c == 'k' || c == 'm' {
2783 if let RateControl::Custom(ref mut val) = app.active_rate_control {
2784 val.push(c);
2785 }
2786 }
2787 }
2788 crossterm::event::KeyCode::Backspace => {
2789 if let RateControl::Custom(ref mut val) = app.active_rate_control {
2790 val.pop();
2791 }
2792 }
2793 crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Esc => {
2794 app.is_editing_custom_rate = false;
2795 app.status_message = format!("Rate: {}", app.active_rate_control.name());
2796 }
2797 _ => {}
2798 }
2799 return;
2800 }
2801
2802 if let crossterm::event::KeyCode::Char(c) = key_event.code {
2806 match c {
2807 'q' => {
2808 app.running = false;
2809 }
2810 '?' => {
2811 app.show_help = !app.show_help;
2812 }
2813 'b' => {
2814 if app.show_grade_screen || app.focus_target == FocusTarget::Grade {
2816 if app.grade_before_snapshot.is_none() {
2817 app.grade_before_snapshot = Some(app.grade_sliders);
2818 app.grade_sliders = GradeSliders::default();
2819 app.shockwave_ticks_remaining = 8;
2820 app.status_message = "BEFORE — holding original values".to_string();
2821 }
2822 } else {
2823 app.show_browser = !app.show_browser;
2824 app.status_message = if app.show_browser {
2825 "Browser shown"
2826 } else {
2827 "Browser hidden"
2828 }.to_string();
2829 }
2830 }
2831 'B' => {
2832 if let Some(snap) = app.grade_before_snapshot.take() {
2834 app.grade_sliders = snap;
2835 app.shockwave_ticks_remaining = 5;
2836 app.status_message = "AFTER — restored grade".to_string();
2837 }
2838 }
2839 'e' => {
2840 app.set_focus(FocusTarget::ExportSettings);
2841 }
2842 'a' => {
2843 app.add_selected_to_queue();
2844 }
2845 'A' => {
2846 app.add_all_to_queue();
2847 }
2848 'D' => {
2849 if app.focus_target == FocusTarget::MediaPool {
2850 app.remove_selected_from_media_pool();
2851 }
2852 }
2853 'd' => {
2854 if app.show_browser && app.show_favourites_bar {
2856 if let Some((ts, idx)) = app.last_clicked_favourite.take() {
2857 if ts.elapsed() < Duration::from_secs(2) && idx < app.favourite_folders.len() {
2858 app.favourite_folders.remove(idx);
2859 app.status_message = "Removed from favourites".to_string();
2860 app.save_favourites();
2861 return;
2862 }
2863 }
2864 }
2865 match app.focus_target {
2866 FocusTarget::MediaPool => app.remove_from_media_pool(),
2867 FocusTarget::Queue => app.remove_from_queue(),
2868 FocusTarget::ExportSettings => {}
2869 FocusTarget::Preview => {}
2870 FocusTarget::Grade => {}
2871 }
2872 }
2873 'x' => {
2874 if app.is_exporting {
2877 app.cancel_export();
2878 } else {
2879 app.clear_completed_queue();
2880 }
2881 }
2882 'X' => {
2883 if app.is_exporting {
2884 app.cancel_export();
2885 } else {
2886 app.clear_completed_queue();
2887 }
2888 }
2889 'v' => {
2890 app.render_selected();
2891 }
2892 'R' => {
2893 app.render_all();
2894 }
2895 'r' => {
2896 if app.show_grade_screen || app.focus_target == FocusTarget::Grade {
2897 let def = GradeSliders::default_val(app.grade_focus);
2898 app.grade_sliders.set(app.grade_focus, def);
2899 app.status_message = format!("Reset {} to default", GradeSliders::name(app.grade_focus));
2900 app.grade_strip_active = true;
2901 app.grade_strip_idle_ticks = 15;
2902 } else if app.focus_target == FocusTarget::ExportSettings {
2903 app.export_focus = ExportFocus::RateControl;
2904 app.cycle_rate_control();
2905 }
2906 }
2907 't' => {
2908 if app.focus_target == FocusTarget::ExportSettings {
2909 app.export_focus = ExportFocus::TransferFunction;
2910 app.export_transfer_function = app.export_transfer_function.next();
2911 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
2912 }
2913 }
2914 'g' => {
2915 if app.focus_target == FocusTarget::ExportSettings {
2916 app.export_focus = ExportFocus::ColorSpace;
2917 app.export_color_space = app.export_color_space.next();
2918 app.status_message = format!("Gamut: {}", app.export_color_space.name());
2919 }
2920 }
2921 'c' => {
2922 if app.focus_target == FocusTarget::ExportSettings {
2923 app.cycle_codec(true);
2924 }
2925 }
2926 'o' => {
2927 if app.show_browser {
2928 app.set_export_folder(app.browser.current_path.clone());
2929 }
2930 }
2931 'f' => {
2932 if app.show_browser {
2933 if app.browsing_favourites {
2941 app.browsing_favourites = false;
2942 app.status_message = "Folder view".to_string();
2943 } else if app.favourite_folders.is_empty() {
2944 app.status_message = "No favourites yet — press [F] to add the current folder".to_string();
2945 } else {
2946 app.browsing_favourites = true;
2947 app.favourites_scroll_offset = Cell::new(0);
2948 app.status_message = "Favourites view (press [f] or [Esc] to return)".to_string();
2949 }
2950 }
2951 }
2952 'F' => {
2953 if app.show_browser {
2954 app.toggle_favourite_folder(app.browser.current_path.clone());
2955 }
2956 }
2957 'i' => {
2958 if app.focus_target == FocusTarget::ExportSettings
2959 && matches!(app.active_rate_control, RateControl::Custom(_))
2960 {
2961 app.is_editing_custom_rate = !app.is_editing_custom_rate;
2962 if app.is_editing_custom_rate {
2963 app.status_message = "Type a rate value (e.g. 20, 400M, 50000k). Press Enter to confirm, Esc to cancel.".to_string();
2964 }
2965 } else {
2966 app.show_full_info = !app.show_full_info;
2967 if app.show_full_info {
2968 app.status_message = "Full file info shown (press i or Esc to close)".to_string();
2969 }
2970 }
2971 }
2972 'p' => {
2973 if app.focus_target == FocusTarget::ExportSettings {
2974 app.begin_naming_preset();
2976 } else {
2977 app.cycle_profile(true);
2978 }
2979 }
2980 'P' => {
2981 app.open_preset_picker();
2985 }
2986 's' => {
2987 app.status_message = "Settings (coming soon)".to_string();
2988 }
2989 'n' => {
2990 if let Some(info) = app.focused_file_info().cloned().or_else(|| app.file_info.clone()) {
2991 let output_path = "naked_dump.raw";
2992 app.status_message = "Starting naked raw dump...".to_string();
2993 match crate::pipeline::run_naked(&info, output_path) {
2994 Ok(_) => {
2995 app.status_message = format!("Naked dump done: {}", output_path);
2996 }
2997 Err(e) => {
2998 app.status_message = format!("Naked dump failed: {}", e);
2999 }
3000 }
3001 }
3002 }
3003 '.' => {
3004 if app.show_browser {
3005 app.browser.toggle_hidden();
3006 app.status_message = if app.browser.show_hidden {
3007 "Showing hidden files"
3008 } else {
3009 "Hiding hidden files"
3010 }.to_string();
3011 }
3012 }
3013 'L' => {
3014 let folder = app.browser.current_path.clone();
3015 app.load_all_in_folder(&folder);
3016 app.show_browser = false;
3017 }
3018 'I' => {
3019 if app.show_browser {
3020 app.import_selected_from_browser();
3021 }
3022 }
3023 'C' => {
3024 if !app.imported_files.is_empty() {
3025 app.show_culling = !app.show_culling;
3026 app.status_message = if app.show_culling { "Culling mode" } else { "Normal mode" }.to_string();
3027 }
3028 }
3029 'G' => {
3030 app.show_grade_screen = !app.show_grade_screen;
3031 if app.show_grade_screen {
3032 app.set_focus(FocusTarget::Grade);
3033 app.status_message = "Grade screen — Esc to exit".to_string();
3034 } else {
3035 app.grade_dragging = None;
3036 app.set_focus(FocusTarget::Preview);
3037 app.status_message = "Normal view".to_string();
3038 }
3039 }
3040 _ => {}
3041 }
3042 }
3043
3044 match key_event.code {
3048 crossterm::event::KeyCode::Esc => {
3049 if app.import_popup != ImportPopupState::Hidden {
3050 app.import_popup = ImportPopupState::Hidden;
3051 } else if app.show_full_info {
3052 app.show_full_info = false;
3053 } else if app.browsing_favourites {
3054 app.browsing_favourites = false;
3055 app.status_message = "Folder view".to_string();
3056 } else if app.show_browser {
3057 app.show_browser = false;
3058 } else if app.show_grade_screen {
3059 app.show_grade_screen = false;
3060 app.grade_dragging = None;
3061 app.set_focus(FocusTarget::Preview);
3062 app.status_message = "Normal view".to_string();
3063 } else if app.show_help {
3064 app.show_help = false;
3065 } else {
3066 app.running = false;
3067 }
3068 }
3069 crossterm::event::KeyCode::Delete => {
3070 if app.browsing_favourites {
3071 app.delete_selected_favourite();
3072 }
3073 }
3074 crossterm::event::KeyCode::Tab => {
3075 app.cycle_focus();
3076 }
3077 crossterm::event::KeyCode::Enter => {
3078 if app.focus_target == FocusTarget::ExportSettings
3079 && matches!(app.active_rate_control, RateControl::Custom(_))
3080 {
3081 app.is_editing_custom_rate = !app.is_editing_custom_rate;
3082 if app.is_editing_custom_rate {
3083 app.status_message = "Type a rate value. Enter to confirm, Esc to cancel.".to_string();
3084 }
3085 } else if app.browsing_favourites {
3086 app.open_selected_favourite();
3087 } else if app.show_browser {
3088 app.navigate_browser(BrowserDirection::Enter);
3089 }
3090 }
3091 crossterm::event::KeyCode::Right | crossterm::event::KeyCode::Char('l') => {
3092 if app.focus_target == FocusTarget::Grade {
3093 let step = if key_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3094 GradeSliders::step_large(app.grade_focus)
3095 } else {
3096 GradeSliders::step_small(app.grade_focus)
3097 };
3098 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3099 app.grade_sliders.apply_delta(app.grade_focus, step);
3100 app.phosphor_trail.push((old_norm, 4));
3101 app.grade_strip_active = true;
3102 app.grade_strip_idle_ticks = 15;
3103 } else if app.frame_index < app.frame_count.saturating_sub(1) {
3104 app.frame_index += 1;
3105 }
3106 }
3107 crossterm::event::KeyCode::Left | crossterm::event::KeyCode::Char('h') => {
3108 if app.focus_target == FocusTarget::Grade {
3109 let step = if key_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3110 GradeSliders::step_large(app.grade_focus)
3111 } else {
3112 GradeSliders::step_small(app.grade_focus)
3113 };
3114 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3115 app.grade_sliders.apply_delta(app.grade_focus, -step);
3116 app.phosphor_trail.push((old_norm, 4));
3117 app.grade_strip_active = true;
3118 app.grade_strip_idle_ticks = 15;
3119 } else if app.frame_index > 0 {
3120 app.frame_index -= 1;
3121 }
3122 }
3123 crossterm::event::KeyCode::Char('L') => {
3124 if app.focus_target == FocusTarget::Grade {
3125 let step = GradeSliders::step_large(app.grade_focus);
3126 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3127 app.grade_sliders.apply_delta(app.grade_focus, step);
3128 app.phosphor_trail.push((old_norm, 4));
3129 app.grade_strip_active = true;
3130 app.grade_strip_idle_ticks = 15;
3131 } else {
3132 let jump = 10.min(app.frame_count.saturating_sub(app.frame_index + 1));
3133 app.frame_index = app.frame_index.saturating_add(jump);
3134 }
3135 }
3136 crossterm::event::KeyCode::Char('H') => {
3137 if app.focus_target == FocusTarget::Grade {
3138 let step = GradeSliders::step_large(app.grade_focus);
3139 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3140 app.grade_sliders.apply_delta(app.grade_focus, -step);
3141 app.phosphor_trail.push((old_norm, 4));
3142 app.grade_strip_active = true;
3143 app.grade_strip_idle_ticks = 15;
3144 } else {
3145 app.frame_index = app.frame_index.saturating_sub(10);
3146 }
3147 }
3148 crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
3149 if app.show_help {
3150 app.help_scroll = app.help_scroll.saturating_sub(1);
3151 } else if app.browsing_favourites {
3152 app.navigate_favourites(-1);
3153 } else if app.show_browser {
3154 app.navigate_browser(BrowserDirection::Up);
3155 } else {
3156 match app.focus_target {
3157 FocusTarget::MediaPool => {
3158 if app.media_pool_index > 0 {
3159 app.media_pool_index -= 1;
3160 }
3161 }
3162 FocusTarget::Queue => {
3163 if app.queue_index > 0 {
3164 app.queue_index -= 1;
3165 }
3166 }
3167 FocusTarget::ExportSettings => {
3168 app.export_focus = match app.export_focus {
3169 ExportFocus::ColorSpace => ExportFocus::RateControl,
3170 ExportFocus::TransferFunction => ExportFocus::ColorSpace,
3171 ExportFocus::CodecFamily => ExportFocus::TransferFunction,
3172 ExportFocus::Profile => ExportFocus::CodecFamily,
3173 ExportFocus::RateControl => ExportFocus::Profile,
3174 };
3175 }
3176 FocusTarget::Preview => {}
3177 FocusTarget::Grade => {
3178 if app.grade_focus > 0 {
3179 app.grade_morph = Some((app.grade_focus, 4));
3180 app.grade_focus -= 1;
3181 app.grade_strip_active = true;
3182 app.grade_strip_idle_ticks = 15;
3183 }
3184 }
3185 }
3186 }
3187 }
3188 crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
3189 if app.show_help {
3190 app.help_scroll = app.help_scroll.saturating_add(1);
3191 } else if app.browsing_favourites {
3192 app.navigate_favourites(1);
3193 } else if app.show_browser {
3194 app.navigate_browser(BrowserDirection::Down);
3195 } else {
3196 match app.focus_target {
3197 FocusTarget::MediaPool => {
3198 if app.media_pool_index + 1 < app.imported_files.len() {
3199 app.media_pool_index += 1;
3200 }
3201 }
3202 FocusTarget::Queue => {
3203 if app.queue_index + 1 < app.queue.len() {
3204 app.queue_index += 1;
3205 }
3206 }
3207 FocusTarget::ExportSettings => {
3208 app.export_focus = match app.export_focus {
3209 ExportFocus::ColorSpace => ExportFocus::TransferFunction,
3210 ExportFocus::TransferFunction => ExportFocus::CodecFamily,
3211 ExportFocus::CodecFamily => ExportFocus::Profile,
3212 ExportFocus::Profile => ExportFocus::RateControl,
3213 ExportFocus::RateControl => ExportFocus::ColorSpace,
3214 };
3215 }
3216 FocusTarget::Preview => {}
3217 FocusTarget::Grade => {
3218 if app.grade_focus + 1 < GradeSliders::count() {
3219 app.grade_morph = Some((app.grade_focus, 4));
3220 app.grade_focus += 1;
3221 app.grade_strip_active = true;
3222 app.grade_strip_idle_ticks = 15;
3223 }
3224 }
3225 }
3226 }
3227 }
3228 crossterm::event::KeyCode::Char(' ') => {
3229 if app.show_browser {
3230 app.browser.toggle_selection();
3231 } else {
3232 match app.focus_target {
3233 FocusTarget::MediaPool => app.toggle_media_pool_selection(),
3234 FocusTarget::Queue => app.toggle_queue_selection(),
3235 FocusTarget::ExportSettings => {}
3236 FocusTarget::Preview => {}
3237 FocusTarget::Grade => {}
3238 }
3239 }
3240 }
3241 crossterm::event::KeyCode::PageUp => {
3242 if app.show_help {
3243 app.help_scroll = app.help_scroll.saturating_sub(10);
3244 } else if app.browsing_favourites {
3245 app.navigate_favourites(-10);
3246 } else if app.show_browser {
3247 let entries_len = app.browser.entries.len();
3248 if entries_len > 0 {
3249 let new_index = app.browser.selected_index.saturating_sub(10.min(entries_len));
3250 app.browser.selected_index = new_index;
3251 }
3252 } else if app.focus_target == FocusTarget::MediaPool {
3253 let len = app.imported_files.len();
3254 if len > 0 {
3255 app.media_pool_index = app.media_pool_index.saturating_sub(10.min(len));
3256 }
3257 } else if app.focus_target == FocusTarget::Queue {
3258 let len = app.queue.len();
3259 if len > 0 {
3260 app.queue_index = app.queue_index.saturating_sub(10.min(len));
3261 }
3262 }
3263 }
3264 crossterm::event::KeyCode::PageDown => {
3265 if app.show_help {
3266 app.help_scroll = app.help_scroll.saturating_add(10);
3267 } else if app.browsing_favourites {
3268 app.navigate_favourites(10);
3269 } else if app.show_browser {
3270 let entries_len = app.browser.entries.len();
3271 if entries_len > 0 {
3272 let new_index = (app.browser.selected_index + 10).min(entries_len - 1);
3273 app.browser.selected_index = new_index;
3274 }
3275 } else if app.focus_target == FocusTarget::MediaPool {
3276 let len = app.imported_files.len();
3277 if len > 0 {
3278 app.media_pool_index = (app.media_pool_index + 10).min(len - 1);
3279 }
3280 } else if app.focus_target == FocusTarget::Queue {
3281 let len = app.queue.len();
3282 if len > 0 {
3283 app.queue_index = (app.queue_index + 10).min(len - 1);
3284 }
3285 }
3286 }
3287 crossterm::event::KeyCode::Home => {
3288 if app.browsing_favourites {
3289 app.favourites_scroll_offset = Cell::new(0);
3290 } else if app.show_browser {
3291 app.browser.selected_index = 0;
3292 } else if app.focus_target == FocusTarget::MediaPool {
3293 app.media_pool_index = 0;
3294 } else if app.focus_target == FocusTarget::Queue {
3295 app.queue_index = 0;
3296 } else {
3297 app.frame_index = 0;
3298 }
3299 }
3300 crossterm::event::KeyCode::End => {
3301 if app.browsing_favourites {
3302 if !app.favourite_folders.is_empty() {
3303 app.favourites_scroll_offset
3304 .set(app.favourite_folders.len() - 1);
3305 }
3306 } else if app.show_browser {
3307 let entries_len = app.browser.entries.len();
3308 if entries_len > 0 {
3309 app.browser.selected_index = entries_len - 1;
3310 }
3311 } else if app.focus_target == FocusTarget::MediaPool {
3312 if !app.imported_files.is_empty() {
3313 app.media_pool_index = app.imported_files.len() - 1;
3314 }
3315 } else if app.focus_target == FocusTarget::Queue {
3316 if !app.queue.is_empty() {
3317 app.queue_index = app.queue.len() - 1;
3318 }
3319 } else {
3320 app.frame_index = app.frame_count.saturating_sub(1);
3321 }
3322 }
3323 crossterm::event::KeyCode::Backspace => {
3324 if app.browsing_favourites {
3325 app.browsing_favourites = false;
3326 app.status_message = "Folder view".to_string();
3327 } else if app.show_browser {
3328 app.navigate_browser(BrowserDirection::GoUp);
3329 }
3330 }
3331 _ => {}
3332 }
3333 }
3334 _ => {}
3335 }
3336}