1use anyhow::Result;
2use crossterm::{
3 cursor::MoveTo,
4 terminal::{disable_raw_mode, enable_raw_mode, window_size, EnterAlternateScreen, LeaveAlternateScreen},
5 event::{Event, KeyEventKind, EnableBracketedPaste, DisableBracketedPaste, EnableMouseCapture, DisableMouseCapture},
6 QueueableCommand,
7};
8use std::io::Write;
9use percent_encoding::percent_decode_str;
10use ratatui::backend::CrosstermBackend;
11use std::cell::Cell;
12use std::path::PathBuf;
13use std::sync::mpsc;
14use std::time::{Duration, Instant};
15use tokio::time;
16
17use crate::cli::{Cli, CliCommands, ResolvedCli};
18use crate::color::{build_preview_ccm, ColorSpace, TransferFunction};
19use crate::export::{
20 Av1Profile, CodecFamily, DnxhrProfile, H264Profile, HevcProfile,
21 ProResProfile, RateControl, Vp9Profile,
22};
23use crate::hardware::probe_hardware;
24use std::sync::Arc;
25use std::sync::atomic::{AtomicBool, Ordering};
26use crate::decoder::Decoder;
27use crate::encoder::{EncodeJob, EncodeStatus, Encoder, OutputFormat};
28use crate::file::McrawFileInfo;
29use crate::file_browser::FileBrowser;
30use crate::preset::ExportPreset;
31use crate::preview::pipeline::{GpuPreviewPipeline, PreviewParams, PreviewGpuContext, Ready};
32use crate::preview::PreviewState;
33use crate::preview::pipeline::params::{transfer_to_u32, color_space_to_u32, bayer_phase_to_u32};
34use crate::stats::PipelineStats;
35use crate::thumbnail::ThumbnailCache;
36use crate::thumbnail_worker::{ThumbnailWorkerPool, ThumbnailRequest};
37
38pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
40use crate::ui::{self, ClickAction};
41
42#[derive(Debug, Clone)]
47pub struct ImportedFile {
48 pub path: String,
49 pub info: McrawFileInfo,
50 pub selected: bool,
51 pub first_timestamp: i64,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum QueueStatus {
56 Waiting,
57 Rendering,
58 Completed,
59 Failed(String),
60}
61
62#[derive(Debug, Clone)]
63pub struct QueuedFile {
64 pub path: String,
65 pub info: McrawFileInfo,
66 pub selected: bool,
67 pub status: QueueStatus,
68 pub progress: f64,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum FocusTarget {
73 MediaPool,
74 Queue,
75 ExportSettings,
76 Grade,
77}
78
79#[derive(Debug, Clone, Copy)]
80pub struct GradeSliders {
81 pub exposure: f32,
82 pub contrast: f32,
83 pub saturation: f32,
84 pub shadows: f32,
85 pub highlights: f32,
86 pub temperature: f32,
87 pub tint: f32,
88 pub sharpen: f32,
89}
90
91impl GradeSliders {
92 pub fn name(index: usize) -> &'static str {
93 match index {
94 0 => "Exposure",
95 1 => "Contrast",
96 2 => "Saturation",
97 3 => "Shadows",
98 4 => "Highlights",
99 5 => "Temp",
100 6 => "Tint",
101 7 => "Sharpen",
102 _ => "",
103 }
104 }
105
106 pub fn default_val(index: usize) -> f32 {
107 match index {
108 0 => 0.0,
109 1 => 1.0,
110 2 => 1.0,
111 3 => 0.0,
112 4 => 0.0,
113 5 => 5200.0,
114 6 => 0.0,
115 7 => 0.0,
116 _ => 0.0,
117 }
118 }
119
120 pub fn min(index: usize) -> f32 {
121 match index {
122 0 => -5.0,
123 1 => 0.0,
124 2 => 0.0,
125 3 => -1.0,
126 4 => -1.0,
127 5 => 2000.0,
128 6 => -100.0,
129 7 => 0.0,
130 _ => 0.0,
131 }
132 }
133
134 pub fn max(index: usize) -> f32 {
135 match index {
136 0 => 5.0,
137 1 => 2.0,
138 2 => 2.0,
139 3 => 1.0,
140 4 => 1.0,
141 5 => 10000.0,
142 6 => 100.0,
143 7 => 1.0,
144 _ => 1.0,
145 }
146 }
147
148 pub fn step_small(index: usize) -> f32 {
149 match index {
150 0 => 0.1,
151 5 => 50.0,
152 6 => 1.0,
153 _ => 0.05,
154 }
155 }
156
157 pub fn step_large(index: usize) -> f32 {
158 match index {
159 0 => 1.0,
160 5 => 500.0,
161 6 => 10.0,
162 _ => 0.25,
163 }
164 }
165
166 pub fn value(&self, index: usize) -> f32 {
167 match index {
168 0 => self.exposure,
169 1 => self.contrast,
170 2 => self.saturation,
171 3 => self.shadows,
172 4 => self.highlights,
173 5 => self.temperature,
174 6 => self.tint,
175 7 => self.sharpen,
176 _ => 0.0,
177 }
178 }
179
180 pub fn normalized(&self, index: usize) -> f32 {
181 let v = self.value(index);
182 let lo = Self::min(index);
183 let hi = Self::max(index);
184 if hi <= lo { return 0.5; }
185 ((v - lo) / (hi - lo)).clamp(0.0, 1.0)
186 }
187
188 pub fn display_value(&self, index: usize) -> String {
189 let sign = |x: f32| if x >= 0.0 { "+" } else { "" };
190 match index {
191 0 => format!("{}{:.1} stops", sign(self.exposure), self.exposure),
192 1 => format!("{:.2}x", self.contrast),
193 2 => format!("{:.2}x", self.saturation),
194 3 => format!("{}{:.2}", sign(self.shadows), self.shadows),
195 4 => format!("{}{:.2}", sign(self.highlights), self.highlights),
196 5 => format!("{:.0}K", self.temperature),
197 6 => format!("{}{:.0}", sign(self.tint), self.tint),
198 _ => format!("{:.2}", self.sharpen),
199 }
200 }
201
202 pub fn set(&mut self, index: usize, v: f32) {
203 let lo = Self::min(index);
204 let hi = Self::max(index);
205 let v = v.clamp(lo, hi);
206 match index {
207 0 => self.exposure = v,
208 1 => self.contrast = v,
209 2 => self.saturation = v,
210 3 => self.shadows = v,
211 4 => self.highlights = v,
212 5 => self.temperature = v,
213 6 => self.tint = v,
214 7 => self.sharpen = v,
215 _ => {}
216 }
217 }
218
219 pub fn apply_delta(&mut self, index: usize, step: f32) {
220 let cur = self.value(index);
221 self.set(index, cur + step);
222 }
223
224 pub fn count() -> usize { 8 }
225}
226
227impl Default for GradeSliders {
228 fn default() -> Self {
229 Self {
230 exposure: 0.0,
231 contrast: 1.0,
232 saturation: 1.0,
233 shadows: 0.0,
234 highlights: 0.0,
235 temperature: 5200.0,
236 tint: 0.0,
237 sharpen: 0.0,
238 }
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum ImportPopupState {
244 Hidden,
245 DroppedFiles {
246 files: Vec<String>,
247 folder: String,
248 all_in_folder: Vec<String>,
249 },
250}
251
252#[derive(Debug)]
253pub enum ExportEvent {
254 Progress(f64),
255 Stats(Arc<PipelineStats>),
256 Done(Result<()>),
257}
258
259#[derive(Debug, Clone)]
263pub struct ExportSummary {
264 pub output_path: String,
265 pub codec_label: String,
266 pub profile_label: String,
267 pub color_space: String,
268 pub transfer: String,
269 pub rate_control: String,
270 pub frame_count: usize,
271 pub elapsed: Duration,
272 pub result: Result<(), String>,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum Screen {
277 Browse,
278 Info,
279 Export,
280}
281
282#[derive(Debug, Clone)]
288pub struct FPSCounter {
289 last_draw: Instant,
290 frames_this_second: u32,
291 second_start: Instant,
292 smooth_fps: f64,
293}
294
295impl FPSCounter {
296 pub fn new() -> Self {
297 Self {
298 last_draw: Instant::now(),
299 frames_this_second: 0,
300 second_start: Instant::now(),
301 smooth_fps: 0.0,
302 }
303 }
304
305 pub fn tick(&mut self) {
307 let now = Instant::now();
308 self.frames_this_second += 1;
309 if now.duration_since(self.second_start).as_secs_f64() >= 1.0 {
310 let fps = self.frames_this_second as f64;
311 if self.smooth_fps == 0.0 {
312 self.smooth_fps = fps;
313 } else {
314 self.smooth_fps = self.smooth_fps * 0.9 + fps * 0.1;
315 }
316 self.frames_this_second = 0;
317 self.second_start = now;
318 }
319 self.last_draw = now;
320 }
321
322 pub fn fps(&self) -> f64 {
323 self.smooth_fps
324 }
325}
326
327pub struct App {
328 pub running: bool,
329 pub screen: Screen,
330 pub file_path: Option<String>,
331 pub file_info: Option<McrawFileInfo>,
332 pub frame_index: usize,
333 pub frame_count: usize,
334 pub encode_jobs: Vec<EncodeJob>,
335 pub status_message: String,
336 pub show_help: bool,
337 pub error: Option<String>,
338 pub browser: FileBrowser,
339
340 pub is_exporting: bool,
341 pub export_cancelled: bool,
342 pub export_progress: f64,
343 pub export_rx: Option<mpsc::Receiver<ExportEvent>>,
344 pub cancel_token: Option<Arc<AtomicBool>>,
345
346 pub last_export_summary: Option<ExportSummary>,
349
350 pub pending_export_summary: Option<ExportSummary>,
354
355 pub current_rendering_index: Option<usize>,
357
358 pub export_folder: Option<std::path::PathBuf>,
360
361 pub favourite_folders: Vec<std::path::PathBuf>,
363
364 pub help_scroll: u16,
366
367 pub show_culling: bool,
369
370 pub show_grade_screen: bool,
372
373 pub export_color_space: ColorSpace,
375 pub export_transfer_function: TransferFunction,
376 pub export_codec_family: CodecFamily,
377 pub export_focus: ExportFocus,
378 pub export_fps: Option<f64>,
379 pub export_start_time: Option<Instant>,
380
381 pub prores_profile: ProResProfile,
383 pub dnxhr_profile: DnxhrProfile,
384 pub hevc_profile: HevcProfile,
385 pub h264_profile: H264Profile,
386 pub av1_profile: Av1Profile,
387 pub vp9_profile: Vp9Profile,
388
389 pub hardware_caps: crate::hardware::HardwareCaps,
391
392 pub active_rate_control: RateControl,
394 pub is_editing_custom_rate: bool,
395
396 pub grade_sliders: GradeSliders,
398 pub grade_focus: usize,
399 pub grade_dragging: Option<(usize, u16, u16)>,
401
402 pub imported_files: Vec<ImportedFile>,
404 pub media_pool_index: usize,
405
406 pub queue: Vec<QueuedFile>,
407 pub queue_index: usize,
408
409 pub show_browser: bool,
410 pub import_popup: ImportPopupState,
411
412 pub focus_target: FocusTarget,
413
414 pub show_full_info: bool,
415
416 pub last_browser_click: Option<(Instant, usize)>,
418
419 pub last_grade_click: Option<(Instant, usize)>,
421
422 pub drop_highlight: Option<Instant>,
424
425 pub drop_import_rx: Option<mpsc::Receiver<DropImportEvent>>,
427 pub drop_import_cancel: Option<Arc<AtomicBool>>,
428
429 pub drop_preview: Option<DropPreview>,
431
432 pub browser_scroll_offset: Cell<usize>,
434
435 pub show_favourites_bar: bool,
437
438 pub browsing_favourites: bool,
442
443 pub favourites_scroll_offset: Cell<usize>,
445
446 pub last_clicked_favourite: Option<(Instant, usize)>,
448
449 pub presets: Vec<crate::preset::ExportPreset>,
455
456 pub active_preset: Option<String>,
460
461 pub preset_picker: PresetPickerState,
463
464 pub preset_naming: Option<PresetNamingState>,
467
468 pub decoder: Option<Decoder>,
470 pub timestamps: Vec<i64>,
471 pub preview_state: PreviewState,
472 pub preview_pipeline: Option<GpuPreviewPipeline<Ready>>,
473 pub preview_gpu_context: Option<Arc<PreviewGpuContext>>,
474 pub thumbnail_cache: ThumbnailCache,
475 pub pending_preview_ts: Option<i64>,
476 pub thumbnail_worker: Option<ThumbnailWorkerPool>,
478 pub thumbnail_requested: Option<(PathBuf, i64)>,
481
482 pub sixel_pending: Cell<bool>,
484 pub sixel_write_pos: Cell<Option<(u16, u16)>>,
485 pub sixel_occupy_size: Cell<Option<(u16, u16, u16, u16)>>,
487 pub last_written_media_index: Cell<Option<usize>>,
489 pub term_cell_size: Cell<(f32, f32)>,
491 pub preview_panel_chars: Cell<Option<(u16, u16)>>,
493 pub needs_rethumbnail: Cell<bool>,
496
497 pub spinner_frame: u8,
499 pub progress_anim_offset: u8,
500
501 pub fps_counter: FPSCounter,
503
504 pub shockwave_ticks_remaining: u8,
506
507 pub grade_strip_active: bool,
509 pub grade_morph: Option<(usize, u8)>,
511 pub phosphor_trail: Vec<(f32, u8)>,
513 pub grade_before_snapshot: Option<GradeSliders>,
515 pub grade_strip_idle_ticks: u8,
517
518}
519
520#[derive(Debug, Clone, Default)]
523pub struct PresetPickerState {
524 pub open: bool,
525 pub index: usize,
526 pub message: Option<String>,
527}
528
529#[derive(Debug, Clone)]
530pub struct PresetNamingState {
531 pub name: String,
532 pub message: Option<String>,
533}
534
535pub enum DropImportEvent {
537 FileReady { path: String, info: McrawFileInfo, first_timestamp: i64 },
538 Failed { path: String, error: String },
539 Complete { imported: usize, failed: usize },
540}
541
542pub struct DropPreview {
544 pub files: Vec<String>,
545 pub start_time: Instant,
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub enum ExportFocus {
550 ColorSpace,
551 TransferFunction,
552 CodecFamily,
553 Profile,
554 RateControl,
555 Fps,
556}
557
558impl App {
559 fn favourites_file() -> Option<PathBuf> {
560 let mut dir = dirs::config_dir()?;
561 dir.push("mcraw-tui");
562 std::fs::create_dir_all(&dir).ok()?;
563 dir.push("favourites.json");
564 Some(dir)
565 }
566
567 fn load_favourites() -> Vec<PathBuf> {
568 let path = match Self::favourites_file() {
569 Some(p) => p,
570 None => return Vec::new(),
571 };
572 let data = match std::fs::read_to_string(&path) {
573 Ok(d) => d,
574 Err(_) => return Vec::new(),
575 };
576 serde_json::from_str(&data).unwrap_or_default()
577 }
578
579 fn save_favourites(&self) {
580 let path = match Self::favourites_file() {
581 Some(p) => p,
582 None => return,
583 };
584 if let Ok(data) = serde_json::to_string(&self.favourite_folders) {
585 let _ = std::fs::write(path, data);
586 }
587 }
588
589 pub fn new_with_placeholder(placeholder_path: Option<PathBuf>) -> Self {
590 let caps = probe_hardware();
591 App {
592 running: true,
593 screen: Screen::Browse,
594 file_path: None,
595 file_info: None,
596 frame_index: 0,
597 frame_count: 0,
598 encode_jobs: Vec::new(),
599 status_message: String::from("Ready | Drag-drop .mcraw files or press b to browse"),
600 show_help: false,
601 error: None,
602 browser: FileBrowser::new(),
603
604 is_exporting: false,
605 export_cancelled: false,
606 export_progress: 0.0,
607 export_rx: None,
608 cancel_token: None,
609 last_export_summary: None,
610 pending_export_summary: None,
611
612 export_color_space: ColorSpace::Rec709,
613 export_transfer_function: TransferFunction::Gamma24,
614 export_codec_family: CodecFamily::HEVC,
615 export_focus: ExportFocus::CodecFamily,
616 export_fps: None,
617 export_start_time: None,
618
619 prores_profile: ProResProfile::HQ,
620 dnxhr_profile: DnxhrProfile::HQX,
621 hevc_profile: HevcProfile::Main10_420,
622 h264_profile: H264Profile::Main8bit,
623 av1_profile: Av1Profile::Profile0_420_10bit,
624 vp9_profile: Vp9Profile::Profile2_420_10bit,
625
626 hardware_caps: caps,
627 active_rate_control: RateControl::Lossless,
628 is_editing_custom_rate: false,
629
630 imported_files: Vec::new(),
631 media_pool_index: 0,
632 queue: Vec::new(),
633 queue_index: 0,
634 show_browser: true,
635 current_rendering_index: None,
636 export_folder: None,
637 favourite_folders: Self::load_favourites(),
638 help_scroll: 0,
639 show_culling: false,
640 show_grade_screen: false,
641 import_popup: ImportPopupState::Hidden,
642 focus_target: FocusTarget::MediaPool,
643 show_full_info: false,
644 last_browser_click: None,
645 last_grade_click: None,
646 drop_highlight: None,
647 drop_import_rx: None,
648 drop_import_cancel: None,
649 drop_preview: None,
650 browser_scroll_offset: Cell::new(0),
651 show_favourites_bar: true,
652 last_clicked_favourite: None,
653 browsing_favourites: false,
654 favourites_scroll_offset: Cell::new(0),
655 presets: ExportPreset::load_all(),
656 active_preset: None,
657 preset_picker: PresetPickerState::default(),
658 preset_naming: None,
659
660 spinner_frame: 0,
661 progress_anim_offset: 0,
662 decoder: None,
663 timestamps: Vec::new(),
664 preview_state: PreviewState::Empty,
665 preview_pipeline: None,
666 preview_gpu_context: None,
667 thumbnail_cache: ThumbnailCache::new_with_placeholder(placeholder_path.as_deref()),
668 pending_preview_ts: None,
669 thumbnail_worker: Some(ThumbnailWorkerPool::new(2)),
670 thumbnail_requested: None,
671 sixel_pending: Cell::new(false),
672 sixel_write_pos: Cell::new(None),
673 sixel_occupy_size: Cell::new(None),
674 last_written_media_index: Cell::new(None),
675 term_cell_size: Cell::new((10.0, 20.0)),
676 preview_panel_chars: Cell::new(None),
677 needs_rethumbnail: Cell::new(false),
678 fps_counter: FPSCounter::new(),
679 shockwave_ticks_remaining: 0,
680 grade_sliders: GradeSliders::default(),
681 grade_focus: 0,
682 grade_dragging: None,
683 grade_strip_active: true,
684 grade_morph: None,
685 phosphor_trail: Vec::new(),
686 grade_before_snapshot: None,
687 grade_strip_idle_ticks: 0,
688 }
689 }
690
691 pub fn new() -> Self {
693 Self::new_with_placeholder(None)
694 }
695
696 pub fn load_file(&mut self, path: String) {
701 tracing::info!("load_file: path={}", path);
702 self.error = None;
703 self.status_message = String::new();
704 match McrawFileInfo::from_path(&path) {
705 Ok(mut info) => {
706 tracing::debug!("file parsed: frames={} {}x{} fps={}", info.frame_count, info.width, info.height, info.fps);
707 let (decoder, timestamps) = match Decoder::new(&path) {
708 Ok(decoder) => {
709 let ts = decoder.timestamps().unwrap_or_default();
710 (Some(decoder), ts)
711 }
712 Err(e) => {
713 tracing::warn!("decoder init failed (OK for non-RAW): {}", e);
714 (None, Vec::new())
715 }
716 };
717
718 if let Some(ref decoder) = decoder {
719 if let Ok(container_meta) = decoder.container_metadata() {
720 let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
721 let mut r = [0.0; 9];
722 for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
723 r
724 };
725 let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
726
727 info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
728 if non_zero(&container_meta.color_matrix2) {
729 info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
730 }
731 if non_zero(&container_meta.forward_matrix1) {
732 info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
733 }
734 if non_zero(&container_meta.forward_matrix2) {
735 info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
736 }
737 if container_meta.has_calibration_illuminants {
738 info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
739 info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
740 }
741
742 if container_meta.white_level > 0.0 {
743 info.white_level = container_meta.white_level;
744 }
745 if container_meta.black_level_count > 0 {
746 info.black_level = container_meta.black_level[0];
747 }
748 }
749 info.frame_count = timestamps.len() as u32;
750 if timestamps.len() >= 2 {
751 let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
752 if duration_ns > 0 {
753 let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
754 info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
755 }
756 }
757 if !timestamps.is_empty() {
758 if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
759 info.width = first_frame_meta.width as u16;
760 info.height = first_frame_meta.height as u16;
761 }
762 if let Some(wb) = info.camera_metadata.wb_multipliers {
764 let r_gain = wb[0];
765 let b_gain = wb[2];
766 let ratio = (r_gain / b_gain.max(1e-6)).clamp(0.1, 10.0);
767 let temp = if ratio >= 1.0 {
768 5200.0 + (ratio - 1.0) * 3000.0
769 } else {
770 5200.0 - (1.0 - ratio) * 3000.0
771 };
772 self.grade_sliders.set(5, temp.clamp(2000.0, 10000.0));
773 } else {
774 self.grade_sliders.set(5, 5200.0);
775 }
776 }
777 }
778
779 self.decoder = decoder;
781 self.timestamps = timestamps;
782
783 self.preview_state = PreviewState::Empty;
785 self.pending_preview_ts = None;
786
787 if self.preview_pipeline.is_none() {
789 if let Ok(context) = PreviewGpuContext::new() {
790 let ctx_arc = Arc::new(context);
791 match GpuPreviewPipeline::new().init(ctx_arc.clone()) {
792 Ok(pipeline) => {
793 self.preview_pipeline = Some(pipeline);
794 self.preview_gpu_context = Some(ctx_arc);
795 }
796 Err(e) => {
797 tracing::warn!("GPU preview pipeline init failed: {}", e);
798 self.preview_state = PreviewState::Error(format!("GPU: {}", e));
799 }
800 }
801 } else {
802 tracing::warn!("No GPU adapter found — preview disabled");
803 self.preview_state = PreviewState::Error("No GPU available".into());
804 }
805 }
806
807 self.file_info = Some(info.clone());
808 self.frame_count = info.frame_count as usize;
809 self.file_path = Some(path.clone());
810
811 let already_pos = self.imported_files.iter().position(|f| f.path == path);
812 if let Some(pos) = already_pos {
813 self.media_pool_index = pos;
814 tracing::debug!("file already in media pool at index={}, switching to it", pos);
815 } else {
816 self.imported_files.push(ImportedFile {
817 path: path.clone(),
818 info: info.clone(),
819 selected: true,
820 first_timestamp: self.timestamps.first().copied().unwrap_or(0),
821 });
822 self.media_pool_index = self.imported_files.len() - 1;
823 tracing::info!("file added to media pool: index={}", self.media_pool_index);
824 }
825
826 self.status_message = format!("Imported: {}", path);
827 tracing::info!("file loaded successfully: {}", path);
828
829 self.last_written_media_index.set(None);
831
832 if self.decoder.is_some() && !self.timestamps.is_empty() {
834 self.frame_index = 0;
835 self.request_frame_decode(0);
836 }
837 }
838 Err(e) => {
839 tracing::error!("failed to load file {}: {}", path, e);
840 self.error = Some(format!("Failed to load file: {}", e));
841 self.status_message = format!("Error: {}", e);
842 }
843 }
844 }
845
846 pub fn request_frame_decode(&mut self, new_index: usize) {
853 if new_index >= self.timestamps.len() {
854 self.preview_state = PreviewState::Empty;
855 self.pending_preview_ts = None;
856 return;
857 }
858 let ts = self.timestamps[new_index];
859 self.preview_state = PreviewState::Loading { started: Instant::now() };
860 self.pending_preview_ts = Some(ts);
861 }
862
863 pub fn poll_thumbnail(&mut self) {
866 if let Some(ref worker) = self.thumbnail_worker {
868 while let Ok(result) = worker.result_rx.try_recv() {
869 if let Some(cached) = result.to_cached() {
871 self.thumbnail_cache.insert(result.path.clone(), cached);
872 }
873 let is_current = self.file_path.as_ref().map_or(false, |fp| *fp == *result.path.to_string_lossy());
875 if is_current {
876 if let Some(sixel) = result.sixel {
877 self.sixel_pending.set(true);
878 self.preview_state = PreviewState::Ready {
879 sixel,
880 width: result.width,
881 height: result.height,
882 };
883 } else {
884 let msg = result.error.unwrap_or_else(|| "Unknown error".into());
885 self.preview_state = PreviewState::Error(msg);
886 }
887 }
888 }
889 }
890
891 let ts = match self.pending_preview_ts.take() {
893 Some(ts) => ts,
894 None => return,
895 };
896
897 let path_buf = match self.file_path.as_ref() {
900 Some(p) => PathBuf::from(p),
901 None => {
902 self.preview_state = PreviewState::Empty;
903 return;
904 }
905 };
906 let needs_regen = self.needs_rethumbnail.get();
907 if !needs_regen {
908 if let Some(cached) = self.thumbnail_cache.get(&path_buf) {
909 self.sixel_pending.set(true);
910 self.preview_state = PreviewState::Ready {
911 sixel: cached.sixel,
912 width: cached.width,
913 height: cached.height,
914 };
915 return;
916 }
917 }
918
919 if !needs_regen && self.thumbnail_requested.as_ref() == Some(&(path_buf.clone(), ts)) {
921 return;
922 }
923
924 if self.preview_panel_chars.get().is_none() {
927 self.pending_preview_ts = Some(ts); return;
929 }
930
931 if let PreviewState::Loading { started } = &self.preview_state {
933 if started.elapsed() > Duration::from_secs(5) {
934 self.preview_state = PreviewState::Error("Timed out".into());
935 return;
936 }
937 }
938
939 let frame_meta_width;
941 let frame_meta_height;
942 let (cm_f32, bayer_phase, bl, wl) = match self.file_info.as_ref() {
943 Some(info) => {
944 let cm = build_preview_ccm(
945 info.camera_metadata.color_matrix.as_ref(),
946 info.camera_metadata.forward_matrix1.as_ref(),
947 info.camera_metadata.forward_matrix2.as_ref(),
948 info.camera_metadata.color_matrix2.as_ref(),
949 info.camera_metadata.calibration_matrix1.as_ref(),
950 );
951 frame_meta_width = info.width as u32;
952 frame_meta_height = info.height as u32;
953 let bp = bayer_phase_to_u32(&info.bayer_pattern);
954 let bl = info.black_level as f32;
955 let wl = if info.white_level > 0.0 { info.white_level as f32 } else { 4095.0 };
956 (cm, bp, bl, wl)
957 }
958 None => {
959 self.preview_state = PreviewState::Empty;
961 return;
962 }
963 };
964
965 let (target_w, target_h) = match self.preview_panel_chars.get() {
966 Some((panel_cols, panel_rows)) => {
967 let (cell_w, cell_h) = self.term_cell_size.get();
968 let avail_px_w = (panel_cols as f32 * cell_w).ceil() as u32;
969 let avail_px_h = (panel_rows as f32 * cell_h).ceil() as u32;
970 (avail_px_w.max(16), avail_px_h.max(16))
971 }
972 None => (crate::thumbnail::THUMBNAIL_WIDTH, crate::thumbnail::THUMBNAIL_HEIGHT),
973 };
974
975 let params = self.build_preview_params(&cm_f32, bayer_phase, bl, wl,
976 frame_meta_width, frame_meta_height, target_w, target_h);
977
978 if let Some(ref worker) = self.thumbnail_worker {
980 worker.submit(ThumbnailRequest {
981 path: path_buf.clone(),
982 timestamp_ns: ts,
983 params,
984 });
985 self.thumbnail_requested = Some((path_buf, ts));
986 self.preview_state = PreviewState::Loading { started: Instant::now() };
987 }
988 self.needs_rethumbnail.set(false);
989 }
990
991 fn build_preview_params(
993 &self,
994 ccm: &[f32; 9],
995 bayer_phase: u32,
996 black_level: f32,
997 white_level: f32,
998 raw_width: u32,
999 raw_height: u32,
1000 target_w: u32,
1001 target_h: u32,
1002 ) -> PreviewParams {
1003 let bayer_aspect = raw_width as f64 / raw_height as f64;
1005 let target_aspect = target_w as f64 / target_h as f64;
1006
1007 let (width, height) = if bayer_aspect > target_aspect {
1008 let h = (target_w as f64 / bayer_aspect) as u32;
1009 (target_w, h.max(1))
1010 } else {
1011 let w = (target_h as f64 * bayer_aspect) as u32;
1012 (w.max(1), target_h)
1013 };
1014
1015 let as_shot = self.file_info.as_ref()
1017 .and_then(|info| info.camera_metadata.wb_multipliers)
1018 .unwrap_or([1.0, 1.0, 1.0]);
1019
1020 let temp_offset = self.grade_sliders.temperature - 5200.0;
1022 let tint_offset = self.grade_sliders.tint;
1023 let wb_gain_r = as_shot[0] * (1.0 + temp_offset / 10000.0);
1024 let wb_gain_g = as_shot[1];
1025 let wb_gain_b = as_shot[2] * (1.0 - temp_offset / 10000.0 + tint_offset / 100.0);
1026
1027 let exposure_stops = self.grade_sliders.exposure;
1029
1030 let adjust_enabled = (self.grade_sliders.exposure.abs() > 0.01
1032 || (self.grade_sliders.contrast - 1.0).abs() > 0.01
1033 || (self.grade_sliders.saturation - 1.0).abs() > 0.01
1034 || self.grade_sliders.shadows.abs() > 0.01
1035 || self.grade_sliders.highlights.abs() > 0.01
1036 || (self.grade_sliders.temperature - 5200.0).abs() > 50.0
1037 || self.grade_sliders.tint.abs() > 0.5) as u32;
1038
1039 PreviewParams {
1040 width,
1041 height,
1042 bayer_width: raw_width,
1043 bayer_height: raw_height,
1044 black_level,
1045 white_level,
1046 exposure: exposure_stops,
1047 wb_r: wb_gain_r,
1048 wb_g: wb_gain_g,
1049 wb_b: wb_gain_b,
1050 contrast: self.grade_sliders.contrast,
1051 saturation: self.grade_sliders.saturation,
1052 shadows: self.grade_sliders.shadows,
1053 highlights: self.grade_sliders.highlights,
1054 _align0: 0.0,
1055 _align1: 0.0,
1056 ccm_row0: [ccm[0], ccm[1], ccm[2], 0.0],
1057 ccm_row1: [ccm[3], ccm[4], ccm[5], 0.0],
1058 ccm_row2: [ccm[6], ccm[7], ccm[8], 0.0],
1059 color_space: color_space_to_u32(&ColorSpace::Rec709),
1060 transfer: transfer_to_u32(&TransferFunction::Gamma24),
1061 adjust_enabled,
1062 bayer_phase,
1063 compute_histogram: 0,
1064 _pad0: 0, _pad1: 0, _pad2: 0, _pad3: 0, _pad4: 0, _pad5: 0, _pad6: 0,
1065 }
1066 }
1067
1068 fn request_thumbnail_for(&self, path: &str, timestamp_ns: i64) {
1071 let worker = match self.thumbnail_worker.as_ref() {
1072 Some(w) => w,
1073 None => return,
1074 };
1075 let imported = match self.imported_files.iter().find(|f| f.path == path) {
1076 Some(f) => f,
1077 None => return,
1078 };
1079 let cm = build_preview_ccm(
1080 imported.info.camera_metadata.color_matrix.as_ref(),
1081 imported.info.camera_metadata.forward_matrix1.as_ref(),
1082 imported.info.camera_metadata.forward_matrix2.as_ref(),
1083 imported.info.camera_metadata.color_matrix2.as_ref(),
1084 imported.info.camera_metadata.calibration_matrix1.as_ref(),
1085 );
1086 let bp = bayer_phase_to_u32(&imported.info.bayer_pattern);
1087 let bl = imported.info.black_level as f32;
1088 let wl = if imported.info.white_level > 0.0 { imported.info.white_level as f32 } else { 4095.0 };
1089 let (target_w, target_h) = match self.preview_panel_chars.get() {
1090 Some((pc, pr)) => {
1091 let (cw, ch) = self.term_cell_size.get();
1092 ((pc as f32 * cw).ceil() as u32, (pr as f32 * ch).ceil() as u32)
1093 }
1094 None => (crate::thumbnail::THUMBNAIL_WIDTH, crate::thumbnail::THUMBNAIL_HEIGHT),
1095 };
1096 let params = self.build_preview_params(&cm, bp, bl, wl,
1097 imported.info.width as u32, imported.info.height as u32, target_w, target_h);
1098 worker.submit(ThumbnailRequest {
1099 path: PathBuf::from(path),
1100 timestamp_ns,
1101 params,
1102 });
1103 }
1104
1105 fn resize_rgba(&self, src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32) -> Vec<u8> {
1107 if src_w == dst_w && src_h == dst_h {
1108 return src.to_vec();
1109 }
1110 let mut dst = vec![0u8; (dst_w * dst_h * 4) as usize];
1111 for y in 0..dst_h {
1112 let src_y = y as f32 * src_h as f32 / dst_h as f32;
1113 let y0 = (src_y.floor() as u32).min(src_h.saturating_sub(1));
1114 let y1 = (y0 + 1).min(src_h.saturating_sub(1));
1115 let fy = src_y - y0 as f32;
1116 for x in 0..dst_w {
1117 let src_x = x as f32 * src_w as f32 / dst_w as f32;
1118 let x0 = (src_x.floor() as u32).min(src_w.saturating_sub(1));
1119 let x1 = (x0 + 1).min(src_w.saturating_sub(1));
1120 let fx = src_x - x0 as f32;
1121 let idx00 = ((y0 * src_w + x0) * 4) as usize;
1122 let idx01 = ((y0 * src_w + x1) * 4) as usize;
1123 let idx10 = ((y1 * src_w + x0) * 4) as usize;
1124 let idx11 = ((y1 * src_w + x1) * 4) as usize;
1125 let didx = ((y * dst_w + x) * 4) as usize;
1126 for c in 0..4 {
1127 let v00 = src[idx00 + c] as f32;
1128 let v01 = src[idx01 + c] as f32;
1129 let v10 = src[idx10 + c] as f32;
1130 let v11 = src[idx11 + c] as f32;
1131 let v0 = v00 + (v01 - v00) * fx;
1132 let v1 = v10 + (v11 - v10) * fx;
1133 dst[didx + c] = (v0 + (v1 - v0) * fy).round().clamp(0.0, 255.0) as u8;
1134 }
1135 }
1136 }
1137 dst
1138 }
1139
1140 pub fn load_files_batch(&mut self, paths: &[String]) -> (usize, usize) {
1143 tracing::info!("load_files_batch: count={}", paths.len());
1144 let mut imported = 0;
1145 let mut failed = 0;
1146 for path in paths {
1147 self.error = None;
1148 match McrawFileInfo::from_path(path) {
1149 Ok(mut info) => {
1150 let mut first_ts = 0i64;
1151 if let Ok(decoder) = Decoder::new(path) {
1152 if let Ok(container_meta) = decoder.container_metadata() {
1153 let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
1154 let mut r = [0.0; 9];
1155 for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
1156 r
1157 };
1158 let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
1159 info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
1160 if non_zero(&container_meta.color_matrix2) {
1161 info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
1162 }
1163 if non_zero(&container_meta.forward_matrix1) {
1164 info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
1165 }
1166 if non_zero(&container_meta.forward_matrix2) {
1167 info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
1168 }
1169 if container_meta.has_calibration_illuminants {
1170 info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
1171 info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
1172 }
1173 if container_meta.white_level > 0.0 {
1174 info.white_level = container_meta.white_level;
1175 }
1176 if container_meta.black_level_count > 0 {
1177 info.black_level = container_meta.black_level[0];
1178 }
1179 }
1180 if let Ok(timestamps) = decoder.timestamps() {
1181 first_ts = timestamps.first().copied().unwrap_or(0);
1182 info.frame_count = timestamps.len() as u32;
1183 if timestamps.len() >= 2 {
1184 let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
1185 if duration_ns > 0 {
1186 let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
1187 info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
1188 }
1189 }
1190 if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
1191 info.width = first_frame_meta.width as u16;
1192 info.height = first_frame_meta.height as u16;
1193 }
1194 }
1195 }
1196
1197 let already = self.imported_files.iter().any(|f| f.path == *path);
1198 if !already {
1199 self.imported_files.push(ImportedFile {
1200 path: path.clone(),
1201 info: info.clone(),
1202 selected: true,
1203 first_timestamp: first_ts,
1204 });
1205 imported += 1;
1206 tracing::debug!("batch imported: {} ({} total)", path, self.imported_files.len());
1207 }
1208 }
1209 Err(e) => {
1210 failed += 1;
1211 tracing::warn!("batch import failed for {}: {}", path, e);
1212 }
1213 }
1214 }
1215 if imported > 0 && self.imported_files.len() > 0 {
1217 self.media_pool_index = self.imported_files.len() - imported;
1218 self.file_info = Some(self.imported_files[self.media_pool_index].info.clone());
1219 self.file_path = Some(self.imported_files[self.media_pool_index].path.clone());
1220 self.frame_count = self.imported_files[self.media_pool_index].info.frame_count as usize;
1221 }
1222 (imported, failed)
1223 }
1224
1225 pub fn start_async_import(&mut self, paths: Vec<String>) {
1228 if let Some(cancel) = self.drop_import_cancel.take() {
1230 cancel.store(true, Ordering::Relaxed);
1231 }
1232
1233 let (tx, rx) = mpsc::channel::<DropImportEvent>();
1234 let cancel_flag = Arc::new(AtomicBool::new(false));
1235 self.drop_import_cancel = Some(cancel_flag.clone());
1236 self.drop_import_rx = Some(rx);
1237
1238 self.drop_preview = Some(DropPreview {
1240 files: paths.iter()
1241 .filter(|p| p.to_lowercase().ends_with(".mcraw"))
1242 .map(|p| p.clone())
1243 .collect(),
1244 start_time: Instant::now(),
1245 });
1246
1247 let total = paths.len();
1248 self.status_message = format!("Importing {} file(s)...", total);
1249
1250 std::thread::spawn(move || {
1251 let mut imported = 0;
1252 let mut failed = 0;
1253
1254 for path in paths {
1255 if cancel_flag.load(Ordering::Relaxed) {
1256 tracing::info!("async drag-drop import cancelled");
1257 break;
1258 }
1259
1260 let path_clone = path.clone();
1261 match McrawFileInfo::from_path(&path) {
1262 Ok(mut info) => {
1263 let mut first_ts: i64 = 0;
1264 if let Ok(decoder) = Decoder::new(&path) {
1266 first_ts = decoder.timestamps().ok()
1267 .and_then(|ts| ts.first().copied())
1268 .unwrap_or(0);
1269 if let Ok(container_meta) = decoder.container_metadata() {
1270 let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
1271 let mut r = [0.0; 9];
1272 for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
1273 r
1274 };
1275 let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
1276 info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
1277 if non_zero(&container_meta.color_matrix2) {
1278 info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
1279 }
1280 if non_zero(&container_meta.forward_matrix1) {
1281 info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
1282 }
1283 if non_zero(&container_meta.forward_matrix2) {
1284 info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
1285 }
1286 if container_meta.has_calibration_illuminants {
1287 info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
1288 info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
1289 }
1290 if container_meta.white_level > 0.0 {
1291 info.white_level = container_meta.white_level;
1292 }
1293 if container_meta.black_level_count > 0 {
1294 info.black_level = container_meta.black_level[0];
1295 }
1296 }
1297 if let Ok(timestamps) = decoder.timestamps() {
1298 info.frame_count = timestamps.len() as u32;
1299 if timestamps.len() >= 2 {
1300 let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
1301 if duration_ns > 0 {
1302 let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
1303 info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
1304 }
1305 }
1306 if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
1307 info.width = first_frame_meta.width as u16;
1308 info.height = first_frame_meta.height as u16;
1309 }
1310 }
1311 }
1312
1313 let _ = tx.send(DropImportEvent::FileReady { path: path_clone, info, first_timestamp: first_ts });
1314 imported += 1;
1315 }
1316 Err(e) => {
1317 let _ = tx.send(DropImportEvent::Failed {
1318 path: path_clone,
1319 error: e.to_string(),
1320 });
1321 failed += 1;
1322 tracing::warn!("async drag-drop import failed: {}: {}", path, e);
1323 }
1324 }
1325 }
1326
1327 let _ = tx.send(DropImportEvent::Complete { imported, failed });
1328 });
1329 }
1330
1331 pub fn poll_drop_import(&mut self) {
1333 let rx = match self.drop_import_rx.take() {
1334 Some(rx) => rx,
1335 None => return,
1336 };
1337
1338 let mut keep_rx = true;
1339 while let Ok(event) = rx.try_recv() {
1340 match event {
1341 DropImportEvent::FileReady { path, info, first_timestamp } => {
1342 let already = self.imported_files.iter().any(|f| f.path == path);
1343 if !already {
1344 self.imported_files.push(ImportedFile {
1345 path: path.clone(),
1346 info: info.clone(),
1347 selected: true,
1348 first_timestamp,
1349 });
1350 if self.imported_files.len() == 1 {
1352 self.media_pool_index = 0;
1353 self.file_info = Some(info.clone());
1354 self.file_path = Some(path.clone());
1355 self.frame_count = info.frame_count as usize;
1356 }
1357 tracing::debug!("async imported: {} ({} total)", path, self.imported_files.len());
1358 }
1359 }
1360 DropImportEvent::Failed { path, error } => {
1361 tracing::warn!("async import failed: {}: {}", path, error);
1362 }
1363 DropImportEvent::Complete { imported, failed } => {
1364 keep_rx = false;
1365 self.drop_import_cancel = None;
1366 if imported > 0 {
1367 self.media_pool_index = self.imported_files.len().saturating_sub(imported);
1368 if let Some(f) = self.imported_files.get(self.media_pool_index) {
1369 self.file_info = Some(f.info.clone());
1370 self.file_path = Some(f.path.clone());
1371 self.frame_count = f.info.frame_count as usize;
1372 }
1373 }
1374 if failed > 0 {
1375 self.status_message = format!("Imported {} file(s), {} failed", imported, failed);
1376 } else {
1377 self.status_message = format!("Imported {} file(s)", imported);
1378 }
1379 tracing::info!("async drag-drop import complete: {} imported, {} failed", imported, failed);
1380 }
1381 }
1382 }
1383
1384 if keep_rx {
1385 self.drop_import_rx = Some(rx);
1386 }
1387 }
1388
1389 pub fn load_all_in_folder(&mut self, dir: &std::path::Path) {
1390 if let Ok(entries) = std::fs::read_dir(dir) {
1391 let mut mcraw_paths: Vec<String> = entries
1392 .filter_map(|e| e.ok())
1393 .map(|e| e.path())
1394 .filter(|p| p.extension().map_or(false, |ext| ext == "mcraw"))
1395 .map(|p| p.to_string_lossy().to_string())
1396 .collect();
1397 mcraw_paths.sort();
1398 let count = mcraw_paths.len();
1399 for path in mcraw_paths {
1400 self.load_file(path);
1401 }
1402 if count > 0 {
1403 self.status_message = format!("Imported {} .mcraw files from {}", count, dir.display());
1404 } else {
1405 self.status_message = format!("No .mcraw files found in {}", dir.display());
1406 }
1407 }
1408 }
1409
1410 pub fn focused_file_info(&self) -> Option<&McrawFileInfo> {
1415 self.imported_files.get(self.media_pool_index).map(|f| &f.info)
1416 }
1417
1418 pub fn toggle_media_pool_selection(&mut self) {
1419 if let Some(f) = self.imported_files.get_mut(self.media_pool_index) {
1420 f.selected = !f.selected;
1421 }
1422 }
1423
1424 pub fn toggle_select_all(&mut self) {
1427 if self.imported_files.is_empty() {
1428 return;
1429 }
1430 let all_selected = self.imported_files.iter().all(|f| f.selected);
1431 for f in &mut self.imported_files {
1432 f.selected = !all_selected;
1433 }
1434 let msg = if all_selected { "Deselected all" } else { "Selected all" };
1435 self.status_message = format!("{} ({} files)", msg, self.imported_files.len());
1436 }
1437
1438 pub fn switch_media_pool_item(&mut self, new_index: usize) {
1440 if new_index >= self.imported_files.len() {
1441 return;
1442 }
1443 if new_index == self.media_pool_index {
1444 return;
1445 }
1446 let path = self.imported_files[new_index].path.clone();
1447 self.media_pool_index = new_index;
1448 self.last_export_summary = None;
1449 self.sixel_pending.set(false);
1450 self.sixel_write_pos.set(None);
1451 self.last_written_media_index.set(None);
1452 if self.file_path.as_deref() != Some(&path) {
1453 self.load_file(path);
1454 } else {
1455 self.preview_state = PreviewState::Empty;
1457 if self.decoder.is_some() && !self.timestamps.is_empty() {
1458 self.request_frame_decode(self.frame_index.min(self.timestamps.len() - 1));
1459 }
1460 }
1461
1462 let start = new_index.saturating_sub(3);
1464 let end = self.imported_files.len().min(new_index + 4);
1465 for i in start..end {
1466 if i == new_index { continue; }
1467 let n = &self.imported_files[i];
1468 if n.first_timestamp > 0 {
1469 self.request_thumbnail_for(&n.path, n.first_timestamp);
1470 }
1471 }
1472 }
1473
1474 pub fn add_selected_to_queue(&mut self) {
1475 let selected: Vec<ImportedFile> = self.imported_files.iter()
1476 .filter(|f| f.selected)
1477 .cloned()
1478 .collect();
1479 if selected.is_empty() {
1480 self.status_message = "No files selected - use Space to select, then a to add".to_string();
1481 return;
1482 }
1483 let count = selected.len();
1484 for imp in &selected {
1485 let already = self.queue.iter().any(|q| q.path == imp.path);
1486 if !already {
1487 self.queue.push(QueuedFile {
1488 path: imp.path.clone(),
1489 info: imp.info.clone(),
1490 selected: true,
1491 status: QueueStatus::Waiting,
1492 progress: 0.0,
1493 });
1494 }
1495 }
1496 self.status_message = format!("Added {} file(s) to render queue", count);
1497 }
1498
1499 pub fn add_all_to_queue(&mut self) {
1500 if self.imported_files.is_empty() {
1501 self.status_message = "No files in media pool".to_string();
1502 return;
1503 }
1504 let count = self.imported_files.len();
1505 for imp in &self.imported_files {
1506 let already = self.queue.iter().any(|q| q.path == imp.path);
1507 if !already {
1508 self.queue.push(QueuedFile {
1509 path: imp.path.clone(),
1510 info: imp.info.clone(),
1511 selected: true,
1512 status: QueueStatus::Waiting,
1513 progress: 0.0,
1514 });
1515 }
1516 }
1517 self.status_message = format!("Added all {} file(s) to render queue", count);
1518 }
1519
1520 pub fn remove_from_media_pool(&mut self) {
1521 if self.imported_files.is_empty() {
1522 return;
1523 }
1524 let name = self.imported_files[self.media_pool_index]
1525 .path
1526 .split(std::path::MAIN_SEPARATOR)
1527 .last()
1528 .unwrap_or("unknown")
1529 .to_string();
1530 self.imported_files.remove(self.media_pool_index);
1531 if self.media_pool_index >= self.imported_files.len() && self.imported_files.len() > 0 {
1532 self.media_pool_index = self.imported_files.len() - 1;
1533 }
1534 self.status_message = format!("Removed {} from media pool", name);
1535 }
1536
1537 pub fn toggle_queue_selection(&mut self) {
1542 if let Some(q) = self.queue.get_mut(self.queue_index) {
1543 q.selected = !q.selected;
1544 }
1545 }
1546
1547 pub fn remove_from_queue(&mut self) {
1548 if self.queue.is_empty() {
1549 return;
1550 }
1551 let has_selected = self.queue.iter().any(|q| q.selected);
1552 if has_selected {
1553 self.queue.retain(|q| !q.selected);
1554 self.status_message = "Removed selected items from queue".to_string();
1555 } else {
1556 let name = self.queue[self.queue_index]
1557 .path
1558 .split(std::path::MAIN_SEPARATOR)
1559 .last()
1560 .unwrap_or("unknown")
1561 .to_string();
1562 self.queue.remove(self.queue_index);
1563 if self.queue_index >= self.queue.len() && self.queue.len() > 0 {
1564 self.queue_index = self.queue.len() - 1;
1565 }
1566 self.status_message = format!("Removed {} from queue", name);
1567 }
1568 if self.queue_index >= self.queue.len() && !self.queue.is_empty() {
1569 self.queue_index = self.queue.len() - 1;
1570 }
1571 }
1572
1573 pub fn clear_completed_queue(&mut self) {
1574 let before = self.queue.len();
1575 self.queue.retain(|q| !matches!(q.status, QueueStatus::Completed | QueueStatus::Failed(_)));
1576 let removed = before - self.queue.len();
1577 if removed > 0 {
1578 self.status_message = format!("Cleared {} completed/failed item(s)", removed);
1579 } else {
1580 self.status_message = "No completed/failed items to clear".to_string();
1581 }
1582 if self.queue_index >= self.queue.len() && !self.queue.is_empty() {
1583 self.queue_index = self.queue.len() - 1;
1584 }
1585 }
1586
1587 pub fn render_selected(&mut self) {
1588 let selected_indices: Vec<usize> = self.queue.iter()
1589 .enumerate()
1590 .filter(|(_, q)| q.selected)
1591 .map(|(i, _)| i)
1592 .collect();
1593 if selected_indices.is_empty() {
1594 self.status_message = "No items selected in queue - use Space to select".to_string();
1595 return;
1596 }
1597 self.status_message = format!("Starting render of {} selected file(s)...", selected_indices.len());
1598 if let Some(&first_idx) = selected_indices.first() {
1600 self.current_rendering_index = Some(first_idx);
1601 let q = &self.queue[first_idx];
1602 self.file_info = Some(q.info.clone());
1603 self.file_path = Some(q.path.clone());
1604 self.frame_count = q.info.frame_count as usize;
1605 self.start_export();
1606 }
1607 }
1608
1609 pub fn render_all(&mut self) {
1610 if self.queue.is_empty() {
1611 self.status_message = "Queue is empty".to_string();
1612 return;
1613 }
1614 self.status_message = format!("Starting render of all {} file(s)...", self.queue.len());
1615 for q in &mut self.queue {
1616 q.selected = true;
1617 }
1618 self.current_rendering_index = Some(0);
1620 if let Some(q) = self.queue.first() {
1621 self.file_info = Some(q.info.clone());
1622 self.file_path = Some(q.path.clone());
1623 self.frame_count = q.info.frame_count as usize;
1624 self.start_export();
1625 }
1626 }
1627
1628 fn start_next_queued_render(&mut self) {
1629 if let Some(current) = self.current_rendering_index {
1631 let next_idx = (current + 1..self.queue.len())
1632 .find(|&i| self.queue[i].selected && self.queue[i].status == QueueStatus::Waiting);
1633 if let Some(idx) = next_idx {
1634 self.current_rendering_index = Some(idx);
1635 self.queue[idx].status = QueueStatus::Rendering;
1636 let q = &self.queue[idx];
1637 self.file_info = Some(q.info.clone());
1638 self.file_path = Some(q.path.clone());
1639 self.frame_count = q.info.frame_count as usize;
1640 self.start_export();
1641 } else {
1642 self.current_rendering_index = None;
1644 let done = self.queue.iter().filter(|q| q.selected && q.status == QueueStatus::Completed).count();
1645 let total = self.queue.iter().filter(|q| q.selected).count();
1646 self.status_message = format!("Batch render complete: {}/{} done", done, total);
1647 }
1648 }
1649 }
1650
1651 pub fn active_profile_is_8bit(&self) -> bool {
1656 match self.export_codec_family {
1657 CodecFamily::ProRes => false,
1658 CodecFamily::DNxHR => false,
1659 CodecFamily::HEVC => self.hevc_profile.is_8bit(),
1660 CodecFamily::H264 => self.h264_profile.is_8bit(),
1661 CodecFamily::AV1 => self.av1_profile.is_8bit(),
1662 CodecFamily::VP9 => self.vp9_profile.is_8bit(),
1663 }
1664 }
1665
1666 pub fn active_profile_name(&self) -> &'static str {
1667 match self.export_codec_family {
1668 CodecFamily::ProRes => self.prores_profile.name(),
1669 CodecFamily::DNxHR => self.dnxhr_profile.name(),
1670 CodecFamily::HEVC => self.hevc_profile.name(),
1671 CodecFamily::H264 => self.h264_profile.name(),
1672 CodecFamily::AV1 => self.av1_profile.name(),
1673 CodecFamily::VP9 => self.vp9_profile.name(),
1674 }
1675 }
1676
1677 pub fn cycle_rate_control(&mut self) {
1678 self.active_rate_control = self.active_rate_control.next();
1679 self.is_editing_custom_rate = false;
1680 self.status_message = format!("Rate: {}", self.active_rate_control.name());
1681 }
1682
1683 pub fn fps_label(fps: Option<f64>) -> String {
1684 match fps {
1685 None => "Original".to_string(),
1686 Some(v) if (v - 23.976).abs() < 0.001 => "23.976".to_string(),
1687 Some(v) if (v - 24.0).abs() < 0.001 => "24".to_string(),
1688 Some(v) if (v - 25.0).abs() < 0.001 => "25".to_string(),
1689 Some(v) if (v - 30.0).abs() < 0.001 => "30".to_string(),
1690 Some(v) if (v - 50.0).abs() < 0.001 => "50".to_string(),
1691 Some(v) if (v - 60.0).abs() < 0.001 => "60".to_string(),
1692 Some(v) if (v - 120.0).abs() < 0.001 => "120".to_string(),
1693 Some(v) => format!("{:.3}", v),
1694 }
1695 }
1696
1697 pub fn cycle_export_fps(&mut self) {
1699 self.export_fps = match self.export_fps {
1700 None => Some(23.976),
1701 Some(v) if (v - 23.976).abs() < 0.001 => Some(24.0),
1702 Some(v) if (v - 24.0).abs() < 0.001 => Some(25.0),
1703 Some(v) if (v - 25.0).abs() < 0.001 => Some(30.0),
1704 Some(v) if (v - 30.0).abs() < 0.001 => Some(50.0),
1705 Some(v) if (v - 50.0).abs() < 0.001 => Some(60.0),
1706 Some(v) if (v - 60.0).abs() < 0.001 => Some(120.0),
1707 _ => None,
1708 };
1709 self.export_focus = ExportFocus::Fps;
1710 self.status_message = format!("FPS: {}", Self::fps_label(self.export_fps));
1711 }
1712
1713 pub fn cycle_codec(&mut self, forward: bool) {
1714 self.export_codec_family = if forward {
1715 self.export_codec_family.next()
1716 } else {
1717 self.export_codec_family.prev()
1718 };
1719 self.export_focus = ExportFocus::CodecFamily;
1720 self.status_message = format!("Codec: {}", self.export_codec_family.name());
1721 }
1722
1723 pub fn cycle_profile(&mut self, forward: bool) {
1724 match self.export_codec_family {
1725 CodecFamily::ProRes => {
1726 self.prores_profile = if forward { self.prores_profile.next() } else { self.prores_profile.prev() };
1727 self.status_message = format!("Profile: {}", self.prores_profile.name());
1728 }
1729 CodecFamily::DNxHR => {
1730 self.dnxhr_profile = if forward { self.dnxhr_profile.next() } else { self.dnxhr_profile.prev() };
1731 self.status_message = format!("Profile: {}", self.dnxhr_profile.name());
1732 }
1733 CodecFamily::HEVC => {
1734 self.hevc_profile = if forward { self.hevc_profile.next() } else { self.hevc_profile.prev() };
1735 self.status_message = format!("Profile: {}", self.hevc_profile.name());
1736 }
1737 CodecFamily::H264 => {
1738 self.h264_profile = if forward { self.h264_profile.next() } else { self.h264_profile.prev() };
1739 self.status_message = format!("Profile: {}", self.h264_profile.name());
1740 }
1741 CodecFamily::AV1 => {
1742 self.av1_profile = if forward { self.av1_profile.next() } else { self.av1_profile.prev() };
1743 self.status_message = format!("Profile: {}", self.av1_profile.name());
1744 }
1745 CodecFamily::VP9 => {
1746 self.vp9_profile = if forward { self.vp9_profile.next() } else { self.vp9_profile.prev() };
1747 self.status_message = format!("Profile: {}", self.vp9_profile.name());
1748 }
1749 }
1750 self.export_focus = ExportFocus::Profile;
1751 }
1752
1753 pub fn start_export(&mut self) {
1754 if self.is_exporting {
1755 tracing::info!("export cancelled by user (was already exporting)");
1756 self.cancel_export();
1757 self.status_message = "Export cancelled. Press V again to restart.".to_string();
1758 return;
1759 }
1760 let info = match self.file_info.clone() {
1761 Some(i) => i,
1762 None => {
1763 tracing::warn!("start_export called with no file loaded");
1764 self.status_message = "No file loaded".to_string();
1765 return;
1766 }
1767 };
1768
1769 if self.export_transfer_function.requires_10bit() && self.active_profile_is_8bit() {
1770 tracing::warn!("export blocked: log/HDR to 8-bit codec not supported");
1771 self.status_message = "Cannot export Log/HDR to 8-bit codec".to_string();
1772 return;
1773 }
1774
1775 let input_path = std::path::Path::new(&info.path);
1776 let parent = self.export_folder.clone().unwrap_or_else(|| {
1777 input_path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf()
1778 });
1779 let stem = input_path.file_stem().and_then(|s| s.to_str()).unwrap_or("output");
1780
1781 let ext = match self.export_codec_family {
1782 CodecFamily::ProRes | CodecFamily::DNxHR => "mov",
1783 CodecFamily::VP9 => "webm",
1784 _ => "mp4",
1785 };
1786 let tf_label = self.export_transfer_function.name().replace([' ', '(', ')', '.'], "");
1787 let cs_label = self.export_color_space.name().replace([' ', '(', ')', '.'], "");
1788 let filename = format!("{}_{}_{}.{}", stem, tf_label, cs_label, ext);
1789 let mut file = parent.join(&filename);
1790 let mut suffix = 1;
1791 while file.exists() {
1792 let base = format!("{}_{}_{}_{}", stem, tf_label, cs_label, suffix);
1793 file = parent.join(&base).with_extension(ext);
1794 suffix += 1;
1795 }
1796 let output_path = file.to_string_lossy().to_string();
1797 tracing::info!("export starting: output={} codec={} profile={} rate={}",
1798 output_path, self.export_codec_family.name(),
1799 self.active_profile_name(), self.active_rate_control.name());
1800 let cs = self.export_color_space;
1801 let tf = self.export_transfer_function;
1802 let cf = self.export_codec_family;
1803 let pp = self.prores_profile;
1804 let dp = self.dnxhr_profile;
1805 let hp = self.hevc_profile;
1806 let h4p = self.h264_profile;
1807 let ap = self.av1_profile;
1808 let vp = self.vp9_profile;
1809 let hevc_enc = self.hardware_caps.best_hevc_encoder.clone();
1810 let h264_enc = self.hardware_caps.best_h264_encoder.clone();
1811 let av1_enc = self.hardware_caps.best_av1_encoder.clone();
1812 let prores_enc = self.hardware_caps.best_prores_encoder.clone();
1813
1814 self.is_exporting = true;
1815 self.export_cancelled = false;
1816 self.export_progress = 0.0;
1817 self.export_start_time = Some(Instant::now());
1818 self.last_export_summary = None;
1822 self.pending_export_summary = Some(ExportSummary {
1826 output_path: output_path.clone(),
1827 codec_label: cf.name().to_string(),
1828 profile_label: self.active_profile_name().to_string(),
1829 color_space: cs.name().to_string(),
1830 transfer: tf.name().to_string(),
1831 rate_control: self.active_rate_control.name(),
1832 frame_count: info.frame_count as usize,
1833 elapsed: Duration::default(),
1834 result: Ok(()),
1835 });
1836 if let Some(idx) = self.current_rendering_index {
1838 if idx < self.queue.len() {
1839 self.queue[idx].status = QueueStatus::Rendering;
1840 }
1841 }
1842 let cancel_flag = Arc::new(AtomicBool::new(false));
1843 self.cancel_token = Some(cancel_flag.clone());
1844 let (tx, rx) = mpsc::channel::<ExportEvent>();
1845 self.export_rx = Some(rx);
1846 self.status_message = format!(
1847 "Starting export: {} / {} via {} {} ...",
1848 cs.name(),
1849 tf.name(),
1850 cf.name(),
1851 self.active_profile_name(),
1852 );
1853
1854 let progress_cb = {
1855 let prog_tx = tx.clone();
1856 Arc::new(move |pct: f64| { let _ = prog_tx.send(ExportEvent::Progress(pct)); })
1857 };
1858
1859 let rate_control = self.active_rate_control.clone();
1860 let custom_fps = self.export_fps;
1861 let stats = Arc::new(PipelineStats::new());
1862 let stats_for_event = Arc::clone(&stats);
1863
1864 std::thread::spawn(move || {
1865 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1866 crate::pipeline::run_export(
1867 info, output_path, progress_cb, cancel_flag, stats,
1868 cs, tf, cf, pp, dp, hp, h4p, ap, vp,
1869 hevc_enc, h264_enc, av1_enc, prores_enc,
1870 rate_control, custom_fps,
1871 )
1872 }));
1873 let _ = tx.send(ExportEvent::Stats(stats_for_event));
1876 match result {
1877 Ok(export_result) => {
1878 let _ = tx.send(ExportEvent::Done(export_result));
1879 }
1880 Err(panic) => {
1881 tracing::error!("export thread panicked: {:?}", panic);
1882 let _ = tx.send(ExportEvent::Done(Err(anyhow::anyhow!("Export thread panicked"))));
1883 }
1884 }
1885 });
1886 }
1887
1888 pub fn remove_selected_from_media_pool(&mut self) {
1889 let has_selected = self.imported_files.iter().any(|f| f.selected);
1890 if has_selected {
1891 let count = self.imported_files.iter().filter(|f| f.selected).count();
1892 self.imported_files.retain(|f| !f.selected);
1893 if self.media_pool_index >= self.imported_files.len() && !self.imported_files.is_empty() {
1894 self.media_pool_index = self.imported_files.len() - 1;
1895 }
1896 self.status_message = format!("Removed {} selected file(s) from media pool", count);
1897 } else {
1898 self.status_message = "No files selected - use Space to select".to_string();
1899 }
1900 }
1901
1902 pub fn set_export_folder(&mut self, folder: std::path::PathBuf) {
1903 self.export_folder = Some(folder);
1904 self.status_message = format!("Export folder set");
1905 }
1906
1907 pub fn toggle_favourite_folder(&mut self, folder: PathBuf) {
1908 if let Some(pos) = self.favourite_folders.iter().position(|f| f == &folder) {
1909 self.favourite_folders.remove(pos);
1910 self.status_message = "Removed from favourites".to_string();
1911 } else {
1912 self.favourite_folders.push(folder);
1913 self.status_message = "Added to favourites".to_string();
1914 }
1915 self.save_favourites();
1916 }
1917
1918 pub fn save_current_as_preset(&mut self, name: String) {
1926 let name = name.trim().to_string();
1927 if name.is_empty() {
1928 self.status_message = "Preset name cannot be empty".to_string();
1929 return;
1930 }
1931 let preset = ExportPreset::snapshot(
1932 name.clone(),
1933 self.export_color_space,
1934 self.export_transfer_function,
1935 self.export_codec_family,
1936 self.prores_profile,
1937 self.dnxhr_profile,
1938 self.hevc_profile,
1939 self.h264_profile,
1940 self.av1_profile,
1941 self.vp9_profile,
1942 self.active_rate_control.clone(),
1943 self.export_folder.clone(),
1944 );
1945 ExportPreset::upsert(&mut self.presets, preset);
1946 ExportPreset::save_all(&self.presets);
1947 self.active_preset = Some(name.clone());
1948 self.status_message = format!("Saved preset: {}", name);
1949 }
1950
1951 pub fn apply_preset(&mut self, index: usize) {
1954 if index >= self.presets.len() {
1955 return;
1956 }
1957 let p = self.presets[index].clone();
1958 self.export_color_space = p.color_space;
1959 self.export_transfer_function = p.transfer_function;
1960 self.export_codec_family = p.codec_family;
1961 self.prores_profile = p.prores_profile;
1962 self.dnxhr_profile = p.dnxhr_profile;
1963 self.hevc_profile = p.hevc_profile;
1964 self.h264_profile = p.h264_profile;
1965 self.av1_profile = p.av1_profile;
1966 self.vp9_profile = p.vp9_profile;
1967 self.active_rate_control = p.rate_control;
1968 self.export_folder = p.export_folder;
1969 if !matches!(self.active_rate_control, RateControl::Custom(_)) {
1971 self.is_editing_custom_rate = false;
1972 }
1973 self.active_preset = Some(p.name.clone());
1974 self.status_message = format!("Applied preset: {}", p.name);
1975 }
1976
1977 pub fn delete_preset(&mut self, index: usize) {
1980 if index >= self.presets.len() {
1981 return;
1982 }
1983 let removed_name = self.presets[index].name.clone();
1984 self.presets.remove(index);
1985 ExportPreset::save_all(&self.presets);
1986 if self.active_preset.as_deref() == Some(removed_name.as_str()) {
1987 self.active_preset = None;
1988 }
1989 if !self.presets.is_empty() && self.preset_picker.index >= self.presets.len() {
1991 self.preset_picker.index = self.presets.len() - 1;
1992 }
1993 self.preset_picker.message = Some(format!("Deleted preset: {}", removed_name));
1994 self.status_message = format!("Deleted preset: {}", removed_name);
1995 }
1996
1997 pub fn open_preset_picker(&mut self) {
2000 if self.presets.is_empty() {
2001 self.status_message = "No presets yet — press [p] to save the current settings".to_string();
2002 return;
2003 }
2004 self.preset_picker.open = true;
2005 self.preset_picker.index = self.presets.len().saturating_sub(1).min(self.preset_picker.index);
2006 self.preset_picker.message = None;
2007 }
2008
2009 pub fn close_preset_picker(&mut self) {
2010 self.preset_picker.open = false;
2011 self.preset_picker.message = None;
2012 }
2013
2014 pub fn begin_naming_preset(&mut self) {
2017 let default_name = match &self.active_preset {
2018 Some(n) => format!("{} (copy)", n),
2019 None => "My Preset".to_string(),
2020 };
2021 self.preset_naming = Some(PresetNamingState { name: default_name, message: None });
2022 self.preset_picker.open = false;
2023 }
2024
2025 pub fn cancel_naming_preset(&mut self) {
2026 self.preset_naming = None;
2027 }
2028
2029 pub fn commit_naming_preset(&mut self) {
2031 let name = match self.preset_naming.as_ref() {
2032 Some(s) => s.name.clone(),
2033 None => return,
2034 };
2035 self.preset_naming = None;
2036 self.save_current_as_preset(name);
2037 }
2038
2039 pub fn current_matches_preset(&self, name: &str) -> bool {
2042 if let Some(p) = self.presets.iter().find(|p| p.name == name) {
2043 p.color_space == self.export_color_space
2044 && p.transfer_function == self.export_transfer_function
2045 && p.codec_family == self.export_codec_family
2046 && p.prores_profile == self.prores_profile
2047 && p.dnxhr_profile == self.dnxhr_profile
2048 && p.hevc_profile == self.hevc_profile
2049 && p.h264_profile == self.h264_profile
2050 && p.av1_profile == self.av1_profile
2051 && p.vp9_profile == self.vp9_profile
2052 && p.rate_control.name() == self.active_rate_control.name()
2053 && p.export_folder == self.export_folder
2054 } else {
2055 false
2056 }
2057 }
2058
2059 pub fn import_selected_from_browser(&mut self) {
2060 let paths = self.browser.selected_mcraw_paths();
2061 if paths.is_empty() {
2062 self.status_message = "No .mcraw files selected in browser".to_string();
2063 return;
2064 }
2065 let count = paths.len();
2066 let (imported, failed) = self.load_files_batch(&paths);
2067 let msg = if failed > 0 {
2068 format!("Imported {} file(s), {} failed", imported, failed)
2069 } else {
2070 format!("Imported {} file(s)", imported)
2071 };
2072 self.status_message = msg;
2073 for entry in self.browser.entries.iter_mut() {
2075 if entry.selected && entry.name.to_lowercase().ends_with(".mcraw") {
2076 entry.selected = false;
2077 }
2078 }
2079 if count > 0 {
2080 self.show_browser = false;
2081 }
2082 }
2083
2084 pub fn cancel_export(&mut self) {
2085 if let Some(ref token) = self.cancel_token {
2086 tracing::info!("export cancellation requested");
2087 token.store(true, Ordering::Relaxed);
2088 self.export_cancelled = true;
2089 self.status_message = "Cancelling export...".to_string();
2090 }
2091 }
2092
2093 pub fn poll_export(&mut self) {
2094 let rx = match self.export_rx.take() {
2095 Some(rx) => rx,
2096 None => return,
2097 };
2098 let mut keep_rx = true;
2099 while let Ok(event) = rx.try_recv() {
2100 match event {
2101 ExportEvent::Progress(pct) => {
2102 self.export_progress = pct;
2103 if let Some(q) = self.queue.iter_mut().find(|q| matches!(q.status, QueueStatus::Rendering)) {
2104 q.progress = pct;
2105 }
2106 }
2107 ExportEvent::Stats(_stats) => {
2108 }
2111 ExportEvent::Done(result) => {
2112 self.is_exporting = false;
2113 keep_rx = false;
2114 self.cancel_token = None;
2115 let elapsed = self.export_start_time
2116 .take()
2117 .map(|t| t.elapsed())
2118 .unwrap_or_default();
2119 if let Some(idx) = self.current_rendering_index {
2121 if idx < self.queue.len() {
2122 self.queue[idx].progress = 100.0;
2123 if self.export_cancelled {
2124 self.queue[idx].status = QueueStatus::Waiting;
2125 } else {
2126 match &result {
2127 Ok(()) => {
2128 self.queue[idx].status = QueueStatus::Completed;
2129 }
2130 Err(e) => {
2131 self.queue[idx].status = QueueStatus::Failed(e.to_string());
2132 }
2133 }
2134 }
2135 }
2136 }
2137 if let Some(mut summary) = self.pending_export_summary.take() {
2141 summary.elapsed = elapsed;
2142 summary.result = if self.export_cancelled {
2143 Err("Cancelled by user".to_string())
2144 } else {
2145 match &result {
2146 Ok(()) => Ok(()),
2147 Err(e) => Err(e.to_string()),
2148 }
2149 };
2150 self.last_export_summary = Some(summary);
2151 }
2152 if self.export_cancelled {
2153 self.status_message = "Export cancelled".to_string();
2154 self.export_cancelled = false;
2155 self.current_rendering_index = None;
2156 } else {
2157 let mins = elapsed.as_secs() / 60;
2158 let secs = elapsed.as_secs() % 60;
2159 match result {
2160 Ok(()) => {
2161 tracing::info!("export completed in {:02}m {:02}s", mins, secs);
2162 self.status_message = format!(
2163 "Video export completed ({:02}m {:02}s)", mins, secs
2164 );
2165 self.shockwave_ticks_remaining = 30;
2166 }
2167 Err(e) => {
2168 tracing::error!("export failed: {}", e);
2169 self.status_message = format!("Export failed: {}", e);
2170 }
2171 }
2172 self.start_next_queued_render();
2174 }
2175 self.export_start_time = None;
2176 }
2177 }
2178 }
2179 if keep_rx {
2180 self.export_rx = Some(rx);
2181 }
2182 }
2183
2184 pub fn add_encode_job(&mut self, format: OutputFormat) {
2185 let job = EncodeJob::new(uuid::Uuid::new_v4().to_string()[..8].to_string(), format);
2186 self.encode_jobs.push(job);
2187 self.status_message = "Export job added".to_string();
2188 }
2189
2190 pub fn select_file(&mut self) {
2195 let entry_data = self.browser.selected_entry().map(|e| (e.is_dir, e.name.clone(), e.path.clone()));
2196 if let Some((is_dir, name, path)) = entry_data {
2197 if is_dir {
2198 self.browser.enter();
2199 self.status_message = format!("Entered: {}", name);
2200 self.show_favourites_bar = false;
2201 } else if name.ends_with(".mcraw") {
2202 let path_str = path.to_string_lossy().to_string();
2203 self.load_file(path_str.clone());
2204 self.show_browser = false;
2205
2206 if let Some(ref info) = self.file_info {
2208 if !self.imported_files.iter().any(|f| f.path == path_str) {
2209 self.imported_files.push(ImportedFile {
2210 path: path_str.clone(),
2211 info: info.clone(),
2212 selected: true,
2213 first_timestamp: self.timestamps.first().copied().unwrap_or(0),
2214 });
2215 }
2216 }
2217
2218 if let Some(idx) = self.imported_files.iter().position(|f| f.path == path_str) {
2220 self.media_pool_index = idx;
2221 }
2222 self.last_written_media_index.set(None);
2223 self.sixel_pending.set(false);
2224 self.sixel_write_pos.set(None);
2225 self.sixel_occupy_size.set(None);
2226 if self.decoder.is_some() && !self.timestamps.is_empty() {
2227 self.request_frame_decode(self.frame_index.min(self.timestamps.len() - 1));
2228 }
2229 } else {
2230 self.status_message = format!("Cannot open: {} (not a .mcraw file)", name);
2231 }
2232 }
2233 }
2234
2235 pub fn scan_mcraw_files_in_folder(&self, folder: &str) -> Vec<String> {
2237 if let Ok(entries) = std::fs::read_dir(folder) {
2238 let mut files: Vec<String> = entries
2239 .filter_map(|e| e.ok())
2240 .map(|e| e.path())
2241 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2242 .map(|p| p.to_string_lossy().to_string())
2243 .collect();
2244 files.sort();
2245 files
2246 } else {
2247 Vec::new()
2248 }
2249 }
2250
2251 pub fn navigate_browser(&mut self, direction: BrowserDirection) {
2252 match direction {
2253 BrowserDirection::Up => {
2254 self.browser.navigate_up();
2255 }
2256 BrowserDirection::Down => {
2257 self.browser.navigate_down();
2258 }
2259 BrowserDirection::Enter => self.select_file(),
2260 BrowserDirection::GoUp => {
2261 self.browser.go_up();
2262 self.show_favourites_bar = false;
2263 }
2264 BrowserDirection::ToggleHidden => self.browser.toggle_hidden(),
2265 }
2266 }
2267
2268 pub fn navigate_favourites(&mut self, delta: i64) {
2270 if self.favourite_folders.is_empty() {
2271 return;
2272 }
2273 let cur = self.favourites_scroll_offset.get() as i64;
2274 let max = (self.favourite_folders.len() as i64) - 1;
2275 let next = (cur + delta).clamp(0, max);
2276 self.favourites_scroll_offset.set(next as usize);
2277 }
2278
2279 pub fn open_selected_favourite(&mut self) {
2281 let idx = self.favourites_scroll_offset.get();
2282 if let Some(path) = self.favourite_folders.get(idx).cloned() {
2283 self.status_message = format!("Navigated to favourite: {}", path.display());
2284 self.browser = FileBrowser::from_path(path);
2285 self.browser_scroll_offset = Cell::new(0);
2286 self.browsing_favourites = false;
2287 self.show_favourites_bar = false;
2288 }
2289 }
2290
2291 pub fn delete_selected_favourite(&mut self) {
2293 let idx = self.favourites_scroll_offset.get();
2294 if idx < self.favourite_folders.len() {
2295 let name = self.favourite_folders[idx].display().to_string();
2296 self.favourite_folders.remove(idx);
2297 self.save_favourites();
2298 if self.favourite_folders.is_empty() {
2299 self.browsing_favourites = false;
2300 } else if self.favourites_scroll_offset.get() >= self.favourite_folders.len() {
2301 self.favourites_scroll_offset.set(self.favourite_folders.len() - 1);
2302 }
2303 self.status_message = format!("Removed favourite: {}", name);
2304 }
2305 }
2306
2307 pub fn cycle_focus(&mut self) {
2312 self.focus_target = match self.focus_target {
2313 FocusTarget::MediaPool => FocusTarget::Grade,
2314 FocusTarget::Grade => FocusTarget::ExportSettings,
2315 FocusTarget::ExportSettings => FocusTarget::Queue,
2316 FocusTarget::Queue => FocusTarget::MediaPool,
2317 };
2318 let label = match self.focus_target {
2319 FocusTarget::MediaPool => "Media Pool",
2320 FocusTarget::Grade => "Grade",
2321 FocusTarget::ExportSettings => "Export Settings",
2322 FocusTarget::Queue => "Render Queue",
2323 };
2324 self.status_message = format!("Focus: {}", label);
2325 }
2326
2327 pub fn set_focus(&mut self, target: FocusTarget) {
2328 self.focus_target = target;
2329 let label = match target {
2330 FocusTarget::MediaPool => "Media Pool",
2331 FocusTarget::Grade => "Grade",
2332 FocusTarget::ExportSettings => "Export Settings",
2333 FocusTarget::Queue => "Render Queue",
2334 };
2335 self.status_message = format!("Focus: {}", label);
2336 }
2337
2338}
2339
2340fn execute_click_action(app: &mut App, action: ClickAction) {
2341 match action {
2342 ClickAction::ToggleBrowser => {
2343 app.show_browser = !app.show_browser;
2344 app.status_message = if app.show_browser { "Browser shown" } else { "Browser hidden" }.to_string();
2345 }
2346 ClickAction::ToggleFileSelection(i) => {
2347 if let Some(f) = app.imported_files.get_mut(i) {
2348 f.selected = !f.selected;
2349 }
2350 }
2351 ClickAction::ToggleQueueSelection(i) => {
2352 if let Some(q) = app.queue.get_mut(i) {
2353 q.selected = !q.selected;
2354 }
2355 }
2356 ClickAction::SelectMediaPoolItem(i) => {
2357 if i < app.imported_files.len() {
2358 app.switch_media_pool_item(i);
2359 }
2360 }
2361 ClickAction::SelectQueueItem(i) => {
2362 if i < app.queue.len() {
2363 app.queue_index = i;
2364 app.set_focus(FocusTarget::Queue);
2365 }
2366 }
2367 ClickAction::FocusMediaPool => {
2368 app.set_focus(FocusTarget::MediaPool);
2369 }
2370 ClickAction::FocusQueue => {
2371 app.set_focus(FocusTarget::Queue);
2372 }
2373 ClickAction::FocusExport => {
2374 app.set_focus(FocusTarget::ExportSettings);
2375 }
2376 ClickAction::FocusGrade => {
2377 app.show_grade_screen = !app.show_grade_screen;
2378 if app.show_grade_screen {
2379 app.set_focus(FocusTarget::Grade);
2380 app.status_message = "Grade screen — Esc to exit".to_string();
2381 } else {
2382 app.grade_dragging = None;
2383 app.set_focus(FocusTarget::MediaPool);
2384 app.status_message = "Normal view".to_string();
2385 }
2386 }
2387 ClickAction::AddSelectedToQueue => app.add_selected_to_queue(),
2388 ClickAction::AddAllToQueue => app.add_all_to_queue(),
2389 ClickAction::RemoveSelectedFromMediaPool => app.remove_selected_from_media_pool(),
2390 ClickAction::ToggleSelectAll => app.toggle_select_all(),
2391 ClickAction::ToggleBrowserSelection(i) => {
2392 if let Some(entry) = app.browser.entries.get_mut(i) {
2393 if entry.name.to_lowercase().ends_with(".mcraw") {
2394 entry.selected = !entry.selected;
2395 }
2396 }
2397 }
2398 ClickAction::RenderSelected => app.render_selected(),
2399 ClickAction::RenderAll => app.render_all(),
2400 ClickAction::ClearQueue => app.clear_completed_queue(),
2401 ClickAction::CycleCodec => {
2402 app.set_focus(FocusTarget::ExportSettings);
2403 app.cycle_codec(true);
2404 }
2405 ClickAction::CycleGamut => {
2406 app.set_focus(FocusTarget::ExportSettings);
2407 app.export_focus = ExportFocus::ColorSpace;
2408 app.export_color_space = app.export_color_space.next();
2409 app.status_message = format!("Gamut: {}", app.export_color_space.name());
2410 }
2411 ClickAction::CycleTransfer => {
2412 app.set_focus(FocusTarget::ExportSettings);
2413 app.export_focus = ExportFocus::TransferFunction;
2414 app.export_transfer_function = app.export_transfer_function.next();
2415 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
2416 }
2417 ClickAction::CycleProfile => {
2418 app.set_focus(FocusTarget::ExportSettings);
2419 app.cycle_profile(true);
2420 }
2421 ClickAction::CycleRate => {
2422 app.set_focus(FocusTarget::ExportSettings);
2423 app.export_focus = ExportFocus::RateControl;
2424 app.cycle_rate_control();
2425 }
2426 ClickAction::CycleFps => {
2427 app.set_focus(FocusTarget::ExportSettings);
2428 app.cycle_export_fps();
2429 }
2430 ClickAction::ImportOption1 => {
2431 if app.import_popup != ImportPopupState::Hidden {
2432 if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
2433 let files = files.clone();
2434 if !files.is_empty() {
2435 let count = files.len();
2436 app.status_message = format!("Importing {} file(s)...", count);
2437 let (imported, failed) = app.load_files_batch(&files);
2438 if failed > 0 {
2439 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2440 } else {
2441 app.status_message = format!("Imported {} file(s)", imported);
2442 }
2443 }
2444 app.import_popup = ImportPopupState::Hidden;
2445 app.show_browser = false;
2446 }
2447 } else if app.show_browser {
2448 app.import_selected_from_browser();
2449 }
2450 }
2451 ClickAction::ImportOption2 => {
2452 if app.import_popup != ImportPopupState::Hidden {
2453 if let ImportPopupState::DroppedFiles { all_in_folder, .. } = &app.import_popup {
2454 let all_in_folder = all_in_folder.clone();
2455 if !all_in_folder.is_empty() {
2456 let count = all_in_folder.len();
2457 app.status_message = format!("Importing all {} file(s) from folder...", count);
2458 let (imported, failed) = app.load_files_batch(&all_in_folder);
2459 if failed > 0 {
2460 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2461 } else {
2462 app.status_message = format!("Imported all {} file(s)", imported);
2463 }
2464 }
2465 app.import_popup = ImportPopupState::Hidden;
2466 app.show_browser = false;
2467 }
2468 } else if app.show_browser {
2469 let folder = app.browser.current_path.clone();
2470 app.load_all_in_folder(&folder);
2471 app.show_browser = false;
2472 }
2473 }
2474 ClickAction::ClosePopup => { app.import_popup = ImportPopupState::Hidden; }
2475 ClickAction::ToggleHelp => { app.show_help = !app.show_help; }
2476 ClickAction::BrowserNavigate(i) => {
2477 let now = Instant::now();
2478 let was_same = app.last_browser_click.as_ref().map(|&(_, idx)| idx == i).unwrap_or(false);
2479 let is_double = app.last_browser_click.as_ref().map(|&(t, _)| now.duration_since(t).as_millis() < 400).unwrap_or(false);
2480
2481 app.browser.selected_index = i;
2482
2483 if was_same && is_double {
2484 app.select_file();
2485 app.last_browser_click = None;
2486 } else {
2487 app.last_browser_click = Some((now, i));
2488 }
2489 }
2490 ClickAction::BrowserSelectAndEnter(i) => {
2491 let now = Instant::now();
2492 let was_same = app.last_browser_click.as_ref().map(|&(_, idx)| idx == i).unwrap_or(false);
2493 let is_double = app.last_browser_click.as_ref().map(|&(t, _)| now.duration_since(t).as_millis() < 400).unwrap_or(false);
2494
2495 app.browser.selected_index = i;
2496
2497 if was_same && is_double {
2498 app.select_file();
2499 app.last_browser_click = None;
2500 } else {
2501 app.last_browser_click = Some((now, i));
2502 }
2503 }
2504 ClickAction::BrowserEnter => {
2505 app.navigate_browser(BrowserDirection::Enter);
2506 }
2507 ClickAction::BrowserGoUp => {
2508 app.navigate_browser(BrowserDirection::GoUp);
2509 }
2510 ClickAction::FavouriteNavigate(i) => {
2511 if i < app.favourite_folders.len() {
2512 let path = app.favourite_folders[i].clone();
2513 app.browser = FileBrowser::from_path(path);
2514 app.browser_scroll_offset = Cell::new(0);
2515 app.show_favourites_bar = false;
2516 app.last_clicked_favourite = Some((Instant::now(), i));
2517 app.status_message = "Navigated to favourite folder".to_string();
2518 }
2519 }
2520 ClickAction::OpenPresetPicker => {
2521 app.open_preset_picker();
2522 }
2523 ClickAction::GradeSlider(i) => {
2524 app.grade_focus = i;
2525 app.set_focus(FocusTarget::Grade);
2526 }
2527 }
2528}
2529
2530pub enum BrowserDirection {
2531 Up,
2532 Down,
2533 Enter,
2534 GoUp,
2535 ToggleHidden,
2536}
2537
2538pub async fn run(args: Cli) -> Result<()> {
2539 let placeholder_path = args.placeholder_path.clone()
2541 .or_else(|| std::env::var("MCRAW_TUI_PLACEHOLDER").ok())
2542 .map(std::path::PathBuf::from);
2543 if let Some(ref p) = placeholder_path {
2544 tracing::info!("custom placeholder: {}", p.display());
2545 }
2546
2547 let mut app = App::new_with_placeholder(placeholder_path);
2548 tracing::info!("app initialized: hardware_caps={:?}", app.hardware_caps);
2549
2550 match args.resolve() {
2551 ResolvedCli::Command(CliCommands::Open { file }) => {
2552 if let Some(path) = file {
2553 app.load_file(path);
2554 }
2555 }
2556 ResolvedCli::Command(CliCommands::Info { file }) => {
2557 let path = match file {
2558 Some(p) => p,
2559 None => return Err(anyhow::anyhow!("No file specified")),
2560 };
2561 match McrawFileInfo::from_path(&path) {
2562 Ok(mut info) => {
2563 info.enhance_with_decoder();
2564 return Ok(());
2565 }
2566 Err(e) => return Err(e),
2567 }
2568 }
2569 ResolvedCli::Command(CliCommands::Export { file, format, output }) => {
2570 if file.is_none() {
2571 return Err(anyhow::anyhow!("No file specified"));
2572 }
2573 if let Err(e) = Cli::validate_export_format(&format) {
2574 anyhow::bail!("{}", e);
2575 }
2576 let format = match format.to_lowercase().as_str() {
2577 "dng" => OutputFormat::DNG { output_path: std::path::PathBuf::from(&output) },
2578 "prores" => OutputFormat::ProRes { output_path: std::path::PathBuf::from(&output) },
2579 "h264" => OutputFormat::H264 { output_path: std::path::PathBuf::from(&output) },
2580 "hevc" => OutputFormat::HEVC { output_path: std::path::PathBuf::from(&output) },
2581 _ => anyhow::bail!("Invalid format: {}", format),
2582 };
2583
2584 let encoder = Encoder::new();
2585 let mut job = EncodeJob::new("cli-export".to_string(), format.clone());
2586 job.status = EncodeStatus::Running;
2587
2588 match encoder.start_job(job.clone()).await {
2589 Ok(()) => { job.status = EncodeStatus::Completed; }
2590 Err(e) => { job.status = EncodeStatus::Failed(e.to_string()); }
2591 }
2592 return Ok(());
2593 }
2594 ResolvedCli::NoFile => {
2595 app.status_message = "No file specified. Use: mcraw-tui -f <path>".to_string();
2596 }
2597 }
2598
2599 let stdout = std::io::stdout();
2600 let backend = CrosstermBackend::new(stdout);
2601 let mut terminal = ratatui::Terminal::new(backend)?;
2602 terminal.clear()?;
2603 crossterm::execute!(
2604 std::io::stdout(),
2605 EnterAlternateScreen,
2606 EnableBracketedPaste,
2607 EnableMouseCapture,
2608 )?;
2609 terminal.hide_cursor()?;
2610
2611 enable_raw_mode()?;
2612 tracing::info!("terminal initialized: alternate_screen, bracketed_paste, mouse_capture enabled");
2613
2614 let event_loop_running = Arc::new(AtomicBool::new(true));
2615 let elr = event_loop_running.clone();
2616
2617 let (tx, rx) = mpsc::channel();
2618 tokio::spawn(async move {
2619 event_loop(tx, elr).await;
2620 });
2621
2622 let encoder = Encoder::new();
2623 tracing::info!("entering main event loop");
2624
2625 while app.running {
2626 if let Ok(ws) = window_size() {
2628 if ws.width > 0 && ws.height > 0 && ws.columns > 0 && ws.rows > 0 {
2629 app.term_cell_size.set((
2630 ws.width as f32 / ws.columns as f32,
2631 ws.height as f32 / ws.rows as f32,
2632 ));
2633 }
2634 }
2635
2636 app.poll_export();
2637 app.poll_drop_import();
2638 let on_normal_main = !app.show_grade_screen
2643 && !app.show_culling
2644 && !app.imported_files.is_empty();
2645 if on_normal_main {
2646 app.poll_thumbnail();
2647 }
2648 app.browser.try_refresh();
2649
2650 app.fps_counter.tick();
2654
2655 let mut click_regions = Vec::new();
2656 terminal.draw(|frame| ui::render(frame, &app, &mut click_regions))?;
2657
2658 let current_idx = app.media_pool_index;
2661 let file_changed = app.last_written_media_index.get() != Some(current_idx);
2662
2663 if file_changed {
2665 if let Some((lx, ly, lw, lh)) = app.sixel_occupy_size.get() {
2666 let clear_line: Vec<u8> = vec![b' '; lw as usize];
2667 for row in ly..(ly + lh).min(9999) {
2668 let _ = std::io::stdout()
2669 .queue(MoveTo(lx, row))
2670 .and_then(|out| out.write_all(&clear_line));
2671 }
2672 app.sixel_occupy_size.set(None);
2673 }
2674 }
2675
2676 if app.sixel_pending.get()
2677 && !app.is_exporting
2678 && (app.last_export_summary.is_none() || app.focused_file_info().or(app.file_info.as_ref()).is_some())
2679 && !app.show_grade_screen
2680 && !app.show_culling {
2681 if let Some((x, y)) = app.sixel_write_pos.get() {
2682 if let PreviewState::Ready { ref sixel, .. } = app.preview_state {
2683 let _ = std::io::stdout()
2684 .queue(MoveTo(x, y))
2685 .and_then(|out| out.write_all(sixel));
2686 }
2687 }
2688 app.sixel_pending.set(false);
2689 app.last_written_media_index.set(Some(current_idx));
2690 }
2691
2692 app.spinner_frame = app.spinner_frame.wrapping_add(1);
2696 if app.spinner_frame % 4 == 0 {
2698 app.progress_anim_offset = app.progress_anim_offset.wrapping_add(1);
2699 }
2700 if app.shockwave_ticks_remaining > 0 {
2701 app.shockwave_ticks_remaining -= 1;
2702 }
2703 if let Some((_, ref mut t)) = app.grade_morph {
2705 *t = t.saturating_sub(1);
2706 if *t == 0 { app.grade_morph = None; }
2707 }
2708 app.phosphor_trail.iter_mut().for_each(|(_, t)| *t = t.saturating_sub(1));
2710 app.phosphor_trail.retain(|(_, t)| *t > 0);
2711 if app.grade_strip_idle_ticks > 0 {
2713 app.grade_strip_idle_ticks = app.grade_strip_idle_ticks.saturating_sub(1);
2714 } else if app.show_grade_screen {
2715 app.grade_strip_active = false;
2716 }
2717
2718 while let Ok(event) = rx.try_recv() {
2723 handle_event(&mut app, event, &encoder, &click_regions).await;
2724 }
2725
2726 time::sleep(Duration::from_millis(16)).await;
2727 }
2728
2729 event_loop_running.store(false, Ordering::Relaxed);
2730 drop(rx);
2731 tokio::task::yield_now().await;
2732
2733 disable_raw_mode()?;
2734 terminal.show_cursor()?;
2735 crossterm::execute!(
2736 std::io::stdout(),
2737 DisableMouseCapture,
2738 DisableBracketedPaste,
2739 LeaveAlternateScreen,
2740 )?;
2741 tracing::info!("terminal shutdown: raw_mode disabled, screen restored");
2742
2743 Ok(())
2744}
2745
2746async fn event_loop(tx: mpsc::Sender<Event>, running: Arc<AtomicBool>) {
2747 tracing::debug!("event_loop started");
2748 while running.load(Ordering::Relaxed) {
2749 if crossterm::event::poll(Duration::from_millis(8)).unwrap() {
2750 if let Ok(event) = crossterm::event::read() {
2751 if tx.send(event).is_err() {
2752 break;
2753 }
2754 }
2755 }
2756 }
2757}
2758
2759fn strip_surrounding_quotes(s: &str) -> String {
2765 let s = s.trim();
2766 if s.len() >= 2 {
2767 let first = s.chars().next().unwrap();
2768 let last = s.chars().last().unwrap();
2769 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
2770 return s[1..s.len() - 1].to_string();
2771 }
2772 }
2773 s.to_string()
2774}
2775
2776fn expand_tilde(s: &str) -> String {
2778 if s == "~" {
2779 if let Some(home) = dirs::home_dir() {
2780 return home.to_string_lossy().to_string();
2781 }
2782 }
2783 if let Some(rest) = s.strip_prefix("~/") {
2784 if let Some(home) = dirs::home_dir() {
2785 return home.join(rest).to_string_lossy().to_string();
2786 }
2787 }
2788 s.to_string()
2789}
2790
2791fn decode_file_uri(s: &str) -> String {
2794 if let Some(rest) = s.strip_prefix("file:///") {
2795 if cfg!(windows) && rest.len() >= 2 {
2797 let chars: Vec<char> = rest.chars().collect();
2798 if chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':' {
2799 return rest.to_string();
2800 }
2801 }
2802 return format!("/{}", rest);
2804 }
2805 if let Some(rest) = s.strip_prefix("file://") {
2806 if let Some(slash_pos) = rest.find('/') {
2808 return rest[slash_pos..].to_string();
2809 }
2810 return rest.to_string();
2811 }
2812 s.to_string()
2813}
2814
2815fn percent_decode_path(s: &str) -> String {
2817 if !s.contains('%') {
2818 return s.to_string();
2819 }
2820 match percent_decode_str(s).decode_utf8() {
2821 Ok(decoded) => decoded.into_owned(),
2822 Err(_) => s.to_string(), }
2824}
2825
2826fn normalize_path(s: &str) -> String {
2828 if cfg!(windows) {
2829 if s.starts_with("\\\\") {
2831 return s.to_string();
2832 }
2833 s.replace('/', "\\")
2835 } else {
2836 s.to_string()
2837 }
2838}
2839
2840fn validate_path(s: &str) -> Option<String> {
2842 let path = std::path::Path::new(s);
2843
2844 if !path.exists() {
2846 tracing::debug!("path validation: does not exist: {}", s);
2847 return None;
2848 }
2849
2850 match path.canonicalize() {
2853 Ok(canonical) => Some(canonical.to_string_lossy().to_string()),
2854 Err(_) => {
2855 tracing::debug!("path validation: canonicalize failed, using original: {}", s);
2856 Some(s.to_string())
2857 }
2858 }
2859}
2860
2861async fn handle_event(app: &mut App, event: Event, _encoder: &Encoder, click_regions: &[ui::ClickRegion]) {
2862 match event {
2863 Event::Paste(pasted) => {
2867 tracing::trace!("drag-drop: raw pasted bytes={:?} len={}", pasted.as_bytes(), pasted.len());
2868
2869 let paths: Vec<String> = pasted
2870 .lines()
2871 .filter_map(|line| {
2872 let line = line.trim();
2873 if line.is_empty() {
2874 return None;
2875 }
2876
2877 let stripped = strip_surrounding_quotes(line);
2879
2880 let expanded = expand_tilde(&stripped);
2882
2883 let decoded = decode_file_uri(&expanded);
2885
2886 let percent_decoded = percent_decode_path(&decoded);
2888
2889 let normalized = normalize_path(&percent_decoded);
2891
2892 validate_path(&normalized)
2894 })
2895 .collect();
2896
2897 tracing::trace!("drag-drop: parsed {} paths: {:?}", paths.len(), paths);
2898
2899 if paths.is_empty() {
2900 app.status_message = "Drag-drop: no valid paths received".to_string();
2901 return;
2902 }
2903
2904 let mut mcraw_files: Vec<String> = Vec::new();
2906 let mut folders: Vec<String> = Vec::new();
2907
2908 for p in &paths {
2909 let path = std::path::Path::new(p);
2910 if path.is_dir() {
2911 folders.push(p.clone());
2912 } else if p.to_lowercase().ends_with(".mcraw") {
2913 mcraw_files.push(p.clone());
2914 }
2915 }
2916
2917 for folder in &folders {
2919 if let Ok(entries) = std::fs::read_dir(folder) {
2920 let mut files: Vec<String> = entries
2921 .filter_map(|e| e.ok())
2922 .map(|e| e.path())
2923 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2924 .map(|p| p.to_string_lossy().to_string())
2925 .collect();
2926 files.sort();
2927 mcraw_files.extend(files);
2928 }
2929 }
2930
2931 let mut seen = std::collections::HashSet::new();
2933 mcraw_files.retain(|f| seen.insert(f.clone()));
2934
2935 tracing::info!("drag-drop: {} .mcraw files, {} folders", mcraw_files.len(), folders.len());
2936
2937 if mcraw_files.is_empty() {
2938 app.status_message = "Drag-drop: no .mcraw files found in dropped items".to_string();
2939 return;
2940 }
2941
2942 app.drop_highlight = Some(Instant::now());
2944
2945 const ASYNC_THRESHOLD: usize = 3;
2948
2949 if mcraw_files.len() <= ASYNC_THRESHOLD && folders.is_empty() {
2950 app.start_async_import(mcraw_files);
2952 } else {
2953 if mcraw_files.len() == 1 {
2956 let file = &mcraw_files[0];
2957 let folder = std::path::Path::new(file)
2958 .parent()
2959 .map(|p| p.to_string_lossy().to_string())
2960 .unwrap_or_else(|| ".".to_string());
2961
2962 let all_in_folder: Vec<String> = if let Ok(entries) = std::fs::read_dir(&folder) {
2963 let mut files: Vec<String> = entries
2964 .filter_map(|e| e.ok())
2965 .map(|e| e.path())
2966 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2967 .map(|p| p.to_string_lossy().to_string())
2968 .collect();
2969 files.sort();
2970 files
2971 } else {
2972 Vec::new()
2973 };
2974
2975 if all_in_folder.len() == 1 {
2977 app.start_async_import(mcraw_files);
2978 return;
2979 }
2980 }
2981
2982 let folder = if !folders.is_empty() {
2984 folders[0].clone()
2985 } else {
2986 std::path::Path::new(&mcraw_files[0])
2987 .parent()
2988 .map(|p| p.to_string_lossy().to_string())
2989 .unwrap_or_else(|| ".".to_string())
2990 };
2991
2992 let all_in_folder: Vec<String> = if let Ok(entries) = std::fs::read_dir(&folder) {
2994 let mut files: Vec<String> = entries
2995 .filter_map(|e| e.ok())
2996 .map(|e| e.path())
2997 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2998 .map(|p| p.to_string_lossy().to_string())
2999 .collect();
3000 files.sort();
3001 files
3002 } else {
3003 Vec::new()
3004 };
3005
3006 app.import_popup = ImportPopupState::DroppedFiles {
3008 files: mcraw_files,
3009 folder,
3010 all_in_folder,
3011 };
3012 }
3013 }
3014
3015 crossterm::event::Event::Resize(_, _) => {
3019 app.preview_state = PreviewState::Empty;
3020 if app.decoder.is_some() && !app.timestamps.is_empty() {
3021 app.request_frame_decode(app.frame_index.min(app.timestamps.len() - 1));
3022 }
3023 }
3024
3025 Event::Mouse(mouse_event) => {
3029 use crossterm::event::{MouseEventKind, MouseButton};
3030
3031 if app.import_popup != ImportPopupState::Hidden {
3033 let col = mouse_event.column;
3034 let row = mouse_event.row;
3035 match mouse_event.kind {
3036 MouseEventKind::Down(MouseButton::Left) => {
3037 for region in click_regions.iter().rev() {
3038 if col >= region.area.x && col < region.area.x + region.area.width
3039 && row >= region.area.y && row < region.area.y + region.area.height {
3040 match ®ion.action {
3041 ClickAction::ImportOption1 | ClickAction::ImportOption2 => {
3042 execute_click_action(app, region.action.clone());
3043 }
3044 _ => {}
3045 }
3046 break;
3047 }
3048 }
3049 }
3050 _ => {}
3051 }
3052 return;
3053 }
3054
3055 if app.show_full_info {
3057 return;
3058 }
3059
3060 match mouse_event.kind {
3061 MouseEventKind::ScrollUp => {
3062 if app.show_help {
3063 app.help_scroll = app.help_scroll.saturating_sub(1);
3064 } else if app.show_browser {
3065 if app.browsing_favourites {
3066 app.navigate_favourites(-1);
3067 } else if app.browser.selected_index > 0 {
3068 app.browser.selected_index -= 1;
3069 }
3070 } else {
3071 match app.focus_target {
3072 FocusTarget::MediaPool => { if app.media_pool_index > 0 { let ni = app.media_pool_index - 1; app.switch_media_pool_item(ni); } }
3073 FocusTarget::Queue => { if app.queue_index > 0 { app.queue_index -= 1; } }
3074 FocusTarget::ExportSettings => {
3075 match app.export_focus {
3077 ExportFocus::CodecFamily => app.cycle_codec(false),
3078 ExportFocus::ColorSpace => {
3079 app.export_color_space = app.export_color_space.prev();
3080 app.status_message = format!("Gamut: {}", app.export_color_space.name());
3081 }
3082 ExportFocus::TransferFunction => {
3083 app.export_transfer_function = app.export_transfer_function.prev();
3084 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
3085 }
3086 ExportFocus::Profile => app.cycle_profile(false),
3087 ExportFocus::RateControl => {
3088 app.active_rate_control = app.active_rate_control.prev();
3089 app.status_message = format!("Rate: {}", app.active_rate_control.name());
3090 }
3091 ExportFocus::Fps => app.cycle_export_fps(),
3092 }
3093 }
3094 FocusTarget::Grade => {
3095 let step = if mouse_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3096 GradeSliders::step_large(app.grade_focus)
3097 } else {
3098 GradeSliders::step_small(app.grade_focus)
3099 };
3100 app.grade_sliders.apply_delta(app.grade_focus, step);
3101 }
3102 }
3103 }
3104 }
3105 MouseEventKind::ScrollDown => {
3106 if app.show_help {
3107 app.help_scroll = app.help_scroll.saturating_add(1);
3108 } else if app.show_browser {
3109 if app.browsing_favourites {
3110 app.navigate_favourites(1);
3111 } else {
3112 let len = app.browser.entries.len();
3113 if len > 0 { app.browser.selected_index = (app.browser.selected_index + 1).min(len - 1); }
3114 }
3115 } else {
3116 match app.focus_target {
3117 FocusTarget::MediaPool => {
3118 let ni = (app.media_pool_index + 1).min(app.imported_files.len().saturating_sub(1));
3119 if ni != app.media_pool_index { app.switch_media_pool_item(ni); }
3120 }
3121 FocusTarget::Queue => {
3122 let len = app.queue.len();
3123 if len > 0 { app.queue_index = (app.queue_index + 1).min(len - 1); }
3124 }
3125 FocusTarget::ExportSettings => {
3126 match app.export_focus {
3127 ExportFocus::CodecFamily => app.cycle_codec(true),
3128 ExportFocus::ColorSpace => {
3129 app.export_color_space = app.export_color_space.next();
3130 app.status_message = format!("Gamut: {}", app.export_color_space.name());
3131 }
3132 ExportFocus::TransferFunction => {
3133 app.export_transfer_function = app.export_transfer_function.next();
3134 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
3135 }
3136 ExportFocus::Profile => app.cycle_profile(true),
3137 ExportFocus::RateControl => app.cycle_rate_control(),
3138 ExportFocus::Fps => app.cycle_export_fps(),
3139 }
3140 }
3141 FocusTarget::Grade => {
3142 let step = if mouse_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3143 GradeSliders::step_large(app.grade_focus)
3144 } else {
3145 GradeSliders::step_small(app.grade_focus)
3146 };
3147 app.grade_sliders.apply_delta(app.grade_focus, -step);
3148 }
3149 }
3150 }
3151 }
3152 MouseEventKind::Down(MouseButton::Left) => {
3153 let col = mouse_event.column;
3154 let row = mouse_event.row;
3155 for region in click_regions.iter().rev() {
3156 if col >= region.area.x && col < region.area.x + region.area.width
3157 && row >= region.area.y && row < region.area.y + region.area.height {
3158 match ®ion.action {
3159 ClickAction::GradeSlider(i) => {
3160 let now = Instant::now();
3161 let is_double = app.last_grade_click.as_ref()
3162 .map(|&(t, idx)| idx == *i && now.duration_since(t).as_millis() < 400)
3163 .unwrap_or(false);
3164 if is_double {
3165 let def = GradeSliders::default_val(*i);
3167 app.grade_sliders.set(*i, def);
3168 app.last_grade_click = None;
3169 app.status_message = format!("Reset {} to default", GradeSliders::name(*i));
3170 } else {
3171 let x_offset = col.saturating_sub(region.area.x);
3173 let norm = (x_offset as f32 / region.area.width.max(1) as f32).clamp(0.0, 1.0);
3174 let lo = GradeSliders::min(*i);
3175 let hi = GradeSliders::max(*i);
3176 app.grade_sliders.set(*i, lo + norm * (hi - lo));
3177 app.grade_focus = *i;
3178 app.grade_dragging = Some((*i, region.area.x, region.area.width));
3179 app.last_grade_click = Some((now, *i));
3180 }
3181 }
3182 _ => execute_click_action(app, region.action.clone()),
3183 }
3184 break;
3185 }
3186 }
3187 }
3188 MouseEventKind::Drag(MouseButton::Left) => {
3189 if let Some((i, track_x, track_w)) = app.grade_dragging {
3190 let col = mouse_event.column;
3191 let x_offset = col.saturating_sub(track_x);
3192 let norm = (x_offset as f32 / track_w.max(1) as f32).clamp(0.0, 1.0);
3193 let lo = GradeSliders::min(i);
3194 let hi = GradeSliders::max(i);
3195 app.grade_sliders.set(i, lo + norm * (hi - lo));
3196 }
3197 }
3198 MouseEventKind::Up(MouseButton::Left) => {
3199 app.grade_dragging = None;
3200 }
3201 _ => {}
3202 }
3203 }
3204
3205 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
3209 if let crossterm::event::KeyCode::Char('c') = key_event.code {
3210 if key_event.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
3211 tracing::info!("ctrl+c received, quitting");
3212 app.running = false;
3213 return;
3214 }
3215 }
3216 if let crossterm::event::KeyCode::Char('x') = key_event.code {
3219 if key_event.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
3220 if app.is_exporting {
3221 tracing::info!("ctrl+x received, cancelling export");
3222 app.cancel_export();
3223 }
3224 return;
3225 }
3226 }
3227
3228 tracing::debug!("key event: code={:?} modifiers={:?}", key_event.code, key_event.modifiers);
3229
3230 if app.preset_naming.is_some() {
3234 let naming = app.preset_naming.clone().unwrap();
3235 match key_event.code {
3236 crossterm::event::KeyCode::Char(c) => {
3237 if let Some(state) = app.preset_naming.as_mut() {
3238 state.name.push(c);
3239 }
3240 }
3241 crossterm::event::KeyCode::Backspace => {
3242 if let Some(state) = app.preset_naming.as_mut() {
3243 state.name.pop();
3244 }
3245 }
3246 crossterm::event::KeyCode::Enter => {
3247 app.commit_naming_preset();
3248 }
3249 crossterm::event::KeyCode::Esc => {
3250 app.cancel_naming_preset();
3251 app.status_message = "Preset save cancelled".to_string();
3252 }
3253 _ => {}
3254 }
3255 let _ = naming; return;
3257 }
3258
3259 if app.preset_picker.open {
3263 match key_event.code {
3264 crossterm::event::KeyCode::Esc => app.close_preset_picker(),
3265 crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
3266 if app.preset_picker.index > 0 {
3267 app.preset_picker.index -= 1;
3268 }
3269 app.preset_picker.message = None;
3270 }
3271 crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
3272 if app.preset_picker.index + 1 < app.presets.len() {
3273 app.preset_picker.index += 1;
3274 }
3275 app.preset_picker.message = None;
3276 }
3277 crossterm::event::KeyCode::Enter => {
3278 let idx = app.preset_picker.index;
3279 app.close_preset_picker();
3280 app.apply_preset(idx);
3281 }
3282 crossterm::event::KeyCode::Delete | crossterm::event::KeyCode::Backspace => {
3283 let idx = app.preset_picker.index;
3284 app.delete_preset(idx);
3285 }
3286 _ => {}
3287 }
3288 return;
3289 }
3290
3291 if app.import_popup != ImportPopupState::Hidden {
3295 let has_option2 = if let ImportPopupState::DroppedFiles { files, all_in_folder, .. } = &app.import_popup {
3296 all_in_folder.len() > files.len()
3297 } else {
3298 false
3299 };
3300
3301 match key_event.code {
3302 crossterm::event::KeyCode::Char('1') => {
3303 let files = if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
3304 files.clone()
3305 } else {
3306 Vec::new()
3307 };
3308 if !files.is_empty() {
3309 let count = files.len();
3310 app.status_message = format!("Importing {} file(s)...", count);
3311 let (imported, failed) = app.load_files_batch(&files);
3312 if failed > 0 {
3313 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
3314 } else {
3315 app.status_message = format!("Imported {} file(s)", imported);
3316 }
3317 }
3318 app.import_popup = ImportPopupState::Hidden;
3319 app.show_browser = false;
3320 }
3321 crossterm::event::KeyCode::Char('2') if has_option2 => {
3322 let all_in_folder = if let ImportPopupState::DroppedFiles { all_in_folder, .. } = &app.import_popup {
3323 all_in_folder.clone()
3324 } else {
3325 Vec::new()
3326 };
3327 if !all_in_folder.is_empty() {
3328 let count = all_in_folder.len();
3329 app.status_message = format!("Importing all {} file(s) from folder...", count);
3330 let (imported, failed) = app.load_files_batch(&all_in_folder);
3331 if failed > 0 {
3332 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
3333 } else {
3334 app.status_message = format!("Imported all {} file(s)", imported);
3335 }
3336 }
3337 app.import_popup = ImportPopupState::Hidden;
3338 app.show_browser = false;
3339 }
3340 crossterm::event::KeyCode::Enter => {
3341 let files = if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
3342 files.clone()
3343 } else {
3344 Vec::new()
3345 };
3346 if !files.is_empty() {
3347 let count = files.len();
3348 app.status_message = format!("Importing {} file(s)...", count);
3349 let (imported, failed) = app.load_files_batch(&files);
3350 if failed > 0 {
3351 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
3352 } else {
3353 app.status_message = format!("Imported {} file(s)", imported);
3354 }
3355 }
3356 app.import_popup = ImportPopupState::Hidden;
3357 app.show_browser = false;
3358 }
3359 crossterm::event::KeyCode::Esc => {
3360 app.import_popup = ImportPopupState::Hidden;
3361 }
3362 _ => {}
3363 }
3364 return;
3365 }
3366
3367 if app.is_editing_custom_rate {
3371 match key_event.code {
3372 crossterm::event::KeyCode::Char(c) => {
3373 if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == 'M' || c == 'k' || c == 'm' {
3374 if let RateControl::Custom(ref mut val) = app.active_rate_control {
3375 val.push(c);
3376 }
3377 }
3378 }
3379 crossterm::event::KeyCode::Backspace => {
3380 if let RateControl::Custom(ref mut val) = app.active_rate_control {
3381 val.pop();
3382 }
3383 }
3384 crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Esc => {
3385 app.is_editing_custom_rate = false;
3386 app.status_message = format!("Rate: {}", app.active_rate_control.name());
3387 }
3388 _ => {}
3389 }
3390 return;
3391 }
3392
3393 if let crossterm::event::KeyCode::Char(c) = key_event.code {
3397 match c {
3398 'q' => {
3399 app.running = false;
3400 }
3401 '?' => {
3402 app.show_help = !app.show_help;
3403 }
3404 'b' => {
3405 if app.show_grade_screen || app.focus_target == FocusTarget::Grade {
3407 if app.grade_before_snapshot.is_none() {
3408 app.grade_before_snapshot = Some(app.grade_sliders);
3409 app.grade_sliders = GradeSliders::default();
3410 app.shockwave_ticks_remaining = 8;
3411 app.status_message = "BEFORE — holding original values".to_string();
3412 }
3413 } else {
3414 app.show_browser = !app.show_browser;
3415 app.status_message = if app.show_browser {
3416 "Browser shown"
3417 } else {
3418 "Browser hidden"
3419 }.to_string();
3420 }
3421 }
3422 'B' => {
3423 if let Some(snap) = app.grade_before_snapshot.take() {
3425 app.grade_sliders = snap;
3426 app.shockwave_ticks_remaining = 5;
3427 app.status_message = "AFTER — restored grade".to_string();
3428 }
3429 }
3430 'e' => {
3431 app.set_focus(FocusTarget::ExportSettings);
3432 }
3433 'a' => {
3434 app.add_selected_to_queue();
3435 }
3436 'A' => {
3437 app.add_all_to_queue();
3438 }
3439 'D' => {
3440 if app.focus_target == FocusTarget::MediaPool {
3441 app.remove_selected_from_media_pool();
3442 }
3443 }
3444 'd' => {
3445 if app.show_browser && app.show_favourites_bar {
3447 if let Some((ts, idx)) = app.last_clicked_favourite.take() {
3448 if ts.elapsed() < Duration::from_secs(2) && idx < app.favourite_folders.len() {
3449 app.favourite_folders.remove(idx);
3450 app.status_message = "Removed from favourites".to_string();
3451 app.save_favourites();
3452 return;
3453 }
3454 }
3455 }
3456 match app.focus_target {
3457 FocusTarget::MediaPool => app.remove_from_media_pool(),
3458 FocusTarget::Queue => app.remove_from_queue(),
3459 FocusTarget::ExportSettings => {}
3460 FocusTarget::Grade => {}
3461 }
3462 }
3463 'x' => {
3464 if app.is_exporting {
3467 app.cancel_export();
3468 } else {
3469 app.clear_completed_queue();
3470 }
3471 }
3472 'X' => {
3473 if app.is_exporting {
3474 app.cancel_export();
3475 } else {
3476 app.clear_completed_queue();
3477 }
3478 }
3479 'v' => {
3480 app.render_selected();
3481 }
3482 'R' => {
3483 app.render_all();
3484 }
3485 'r' => {
3486 if app.show_grade_screen || app.focus_target == FocusTarget::Grade {
3487 let def = GradeSliders::default_val(app.grade_focus);
3488 app.grade_sliders.set(app.grade_focus, def);
3489 app.status_message = format!("Reset {} to default", GradeSliders::name(app.grade_focus));
3490 app.grade_strip_active = true;
3491 app.grade_strip_idle_ticks = 15;
3492 } else if app.focus_target == FocusTarget::ExportSettings {
3493 app.export_focus = ExportFocus::RateControl;
3494 app.cycle_rate_control();
3495 }
3496 }
3497 't' => {
3498 if app.focus_target == FocusTarget::ExportSettings {
3499 app.export_focus = ExportFocus::TransferFunction;
3500 app.export_transfer_function = app.export_transfer_function.next();
3501 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
3502 }
3503 }
3504 'g' => {
3505 if app.focus_target == FocusTarget::ExportSettings {
3506 app.export_focus = ExportFocus::ColorSpace;
3507 app.export_color_space = app.export_color_space.next();
3508 app.status_message = format!("Gamut: {}", app.export_color_space.name());
3509 }
3510 }
3511 'c' => {
3512 if app.focus_target == FocusTarget::ExportSettings {
3513 app.cycle_codec(true);
3514 }
3515 }
3516 'o' => {
3517 if app.show_browser {
3518 app.set_export_folder(app.browser.current_path.clone());
3519 }
3520 }
3521 'f' => {
3522 if app.show_browser {
3523 if app.browsing_favourites {
3531 app.browsing_favourites = false;
3532 app.status_message = "Folder view".to_string();
3533 } else if app.favourite_folders.is_empty() {
3534 app.status_message = "No favourites yet — press [F] to add the current folder".to_string();
3535 } else {
3536 app.browsing_favourites = true;
3537 app.favourites_scroll_offset = Cell::new(0);
3538 app.status_message = "Favourites view (press [f] or [Esc] to return)".to_string();
3539 }
3540 } else if app.focus_target == FocusTarget::ExportSettings {
3541 app.cycle_export_fps();
3542 }
3543 }
3544 'F' => {
3545 if app.show_browser {
3546 app.toggle_favourite_folder(app.browser.current_path.clone());
3547 }
3548 }
3549 'i' => {
3550 if app.focus_target == FocusTarget::ExportSettings
3551 && matches!(app.active_rate_control, RateControl::Custom(_))
3552 {
3553 app.is_editing_custom_rate = !app.is_editing_custom_rate;
3554 if app.is_editing_custom_rate {
3555 app.status_message = "Type a rate value (e.g. 20, 400M, 50000k). Press Enter to confirm, Esc to cancel.".to_string();
3556 }
3557 } else {
3558 app.show_full_info = !app.show_full_info;
3559 if app.show_full_info {
3560 app.status_message = "Full file info shown (press i or Esc to close)".to_string();
3561 }
3562 }
3563 }
3564 'p' => {
3565 if app.focus_target == FocusTarget::ExportSettings {
3566 app.begin_naming_preset();
3568 } else {
3569 app.cycle_profile(true);
3570 }
3571 }
3572 'P' => {
3573 app.open_preset_picker();
3577 }
3578 's' => {
3579 app.toggle_select_all();
3580 }
3581 'n' => {
3582 if let Some(info) = app.focused_file_info().cloned().or_else(|| app.file_info.clone()) {
3583 let output_path = "naked_dump.raw";
3584 app.status_message = "Starting naked raw dump...".to_string();
3585 match crate::pipeline::run_naked(&info, output_path) {
3586 Ok(_) => {
3587 app.status_message = format!("Naked dump done: {}", output_path);
3588 }
3589 Err(e) => {
3590 app.status_message = format!("Naked dump failed: {}", e);
3591 }
3592 }
3593 }
3594 }
3595 '.' => {
3596 if app.show_browser {
3597 app.browser.toggle_hidden();
3598 app.status_message = if app.browser.show_hidden {
3599 "Showing hidden files"
3600 } else {
3601 "Hiding hidden files"
3602 }.to_string();
3603 }
3604 }
3605 'L' => {
3606 let folder = app.browser.current_path.clone();
3607 app.load_all_in_folder(&folder);
3608 app.show_browser = false;
3609 }
3610 'I' => {
3611 if app.show_browser {
3612 app.import_selected_from_browser();
3613 }
3614 }
3615 'C' => {
3616 if !app.imported_files.is_empty() {
3617 app.show_culling = !app.show_culling;
3618 app.status_message = if app.show_culling { "Culling mode" } else { "Normal mode" }.to_string();
3619 }
3620 }
3621 'G' => {
3622 app.show_grade_screen = !app.show_grade_screen;
3623 if app.show_grade_screen {
3624 if let Some((lx, ly, lw, lh)) = app.sixel_occupy_size.get() {
3626 let clear_line: Vec<u8> = vec![b' '; lw as usize];
3627 for row in ly..(ly + lh).min(9999) {
3628 let _ = std::io::stdout()
3629 .queue(MoveTo(lx, row))
3630 .and_then(|out| out.write_all(&clear_line));
3631 }
3632 app.sixel_occupy_size.set(None);
3633 }
3634 app.set_focus(FocusTarget::Grade);
3635 app.status_message = "Grade screen — Esc to exit".to_string();
3636 } else {
3637 app.grade_dragging = None;
3638 app.set_focus(FocusTarget::MediaPool);
3639 app.status_message = "Normal view".to_string();
3640 }
3641 }
3642 _ => {}
3643 }
3644 }
3645
3646 match key_event.code {
3650 crossterm::event::KeyCode::Esc => {
3651 if app.import_popup != ImportPopupState::Hidden {
3652 app.import_popup = ImportPopupState::Hidden;
3653 } else if app.show_full_info {
3654 app.show_full_info = false;
3655 } else if app.browsing_favourites {
3656 app.browsing_favourites = false;
3657 app.status_message = "Folder view".to_string();
3658 } else if app.show_browser {
3659 app.show_browser = false;
3660 } else if app.show_grade_screen {
3661 app.show_grade_screen = false;
3662 app.grade_dragging = None;
3663 app.set_focus(FocusTarget::MediaPool);
3664 app.status_message = "Normal view".to_string();
3665 } else if app.show_help {
3666 app.show_help = false;
3667 } else {
3668 app.running = false;
3669 }
3670 }
3671 crossterm::event::KeyCode::Delete => {
3672 if app.browsing_favourites {
3673 app.delete_selected_favourite();
3674 }
3675 }
3676 crossterm::event::KeyCode::Tab => {
3677 app.cycle_focus();
3678 }
3679 crossterm::event::KeyCode::Enter => {
3680 if app.focus_target == FocusTarget::ExportSettings
3681 && matches!(app.active_rate_control, RateControl::Custom(_))
3682 {
3683 app.is_editing_custom_rate = !app.is_editing_custom_rate;
3684 if app.is_editing_custom_rate {
3685 app.status_message = "Type a rate value. Enter to confirm, Esc to cancel.".to_string();
3686 }
3687 } else if app.browsing_favourites {
3688 app.open_selected_favourite();
3689 } else if app.show_browser {
3690 app.navigate_browser(BrowserDirection::Enter);
3691 }
3692 }
3693 crossterm::event::KeyCode::Right | crossterm::event::KeyCode::Char('l') => {
3694 if app.focus_target == FocusTarget::Grade {
3695 let step = if key_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3696 GradeSliders::step_large(app.grade_focus)
3697 } else {
3698 GradeSliders::step_small(app.grade_focus)
3699 };
3700 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3701 app.grade_sliders.apply_delta(app.grade_focus, step);
3702 app.phosphor_trail.push((old_norm, 4));
3703 app.grade_strip_active = true;
3704 app.grade_strip_idle_ticks = 15;
3705 }
3706 }
3707 crossterm::event::KeyCode::Left | crossterm::event::KeyCode::Char('h') => {
3708 if app.focus_target == FocusTarget::Grade {
3709 let step = if key_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3710 GradeSliders::step_large(app.grade_focus)
3711 } else {
3712 GradeSliders::step_small(app.grade_focus)
3713 };
3714 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3715 app.grade_sliders.apply_delta(app.grade_focus, -step);
3716 app.phosphor_trail.push((old_norm, 4));
3717 app.grade_strip_active = true;
3718 app.grade_strip_idle_ticks = 15;
3719 }
3720 }
3721 crossterm::event::KeyCode::Char('L') => {
3722 if app.focus_target == FocusTarget::Grade {
3723 let step = GradeSliders::step_large(app.grade_focus);
3724 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3725 app.grade_sliders.apply_delta(app.grade_focus, step);
3726 app.phosphor_trail.push((old_norm, 4));
3727 app.grade_strip_active = true;
3728 app.grade_strip_idle_ticks = 15;
3729 }
3730 }
3731 crossterm::event::KeyCode::Char('H') => {
3732 if app.focus_target == FocusTarget::Grade {
3733 let step = GradeSliders::step_large(app.grade_focus);
3734 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3735 app.grade_sliders.apply_delta(app.grade_focus, -step);
3736 app.phosphor_trail.push((old_norm, 4));
3737 app.grade_strip_active = true;
3738 app.grade_strip_idle_ticks = 15;
3739 }
3740 }
3741 crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
3742 if app.show_help {
3743 app.help_scroll = app.help_scroll.saturating_sub(1);
3744 } else if app.browsing_favourites {
3745 app.navigate_favourites(-1);
3746 } else if app.show_browser {
3747 app.navigate_browser(BrowserDirection::Up);
3748 } else {
3749 match app.focus_target {
3750 FocusTarget::MediaPool => {
3751 if app.media_pool_index > 0 {
3752 app.switch_media_pool_item(app.media_pool_index - 1);
3753 }
3754 }
3755 FocusTarget::Queue => {
3756 if app.queue_index > 0 {
3757 app.queue_index -= 1;
3758 }
3759 }
3760 FocusTarget::ExportSettings => {
3761 app.export_focus = match app.export_focus {
3762 ExportFocus::ColorSpace => ExportFocus::Fps,
3763 ExportFocus::Fps => ExportFocus::RateControl,
3764 ExportFocus::TransferFunction => ExportFocus::ColorSpace,
3765 ExportFocus::CodecFamily => ExportFocus::TransferFunction,
3766 ExportFocus::Profile => ExportFocus::CodecFamily,
3767 ExportFocus::RateControl => ExportFocus::Profile,
3768 };
3769 }
3770 FocusTarget::Grade => {
3771 if app.grade_focus > 0 {
3772 app.grade_morph = Some((app.grade_focus, 4));
3773 app.grade_focus -= 1;
3774 app.grade_strip_active = true;
3775 app.grade_strip_idle_ticks = 15;
3776 }
3777 }
3778 }
3779 }
3780 }
3781 crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
3782 if app.show_help {
3783 app.help_scroll = app.help_scroll.saturating_add(1);
3784 } else if app.browsing_favourites {
3785 app.navigate_favourites(1);
3786 } else if app.show_browser {
3787 app.navigate_browser(BrowserDirection::Down);
3788 } else {
3789 match app.focus_target {
3790 FocusTarget::MediaPool => {
3791 if app.media_pool_index + 1 < app.imported_files.len() {
3792 app.switch_media_pool_item(app.media_pool_index + 1);
3793 }
3794 }
3795 FocusTarget::Queue => {
3796 if app.queue_index + 1 < app.queue.len() {
3797 app.queue_index += 1;
3798 }
3799 }
3800 FocusTarget::ExportSettings => {
3801 app.export_focus = match app.export_focus {
3802 ExportFocus::ColorSpace => ExportFocus::TransferFunction,
3803 ExportFocus::TransferFunction => ExportFocus::CodecFamily,
3804 ExportFocus::CodecFamily => ExportFocus::Profile,
3805 ExportFocus::Profile => ExportFocus::RateControl,
3806 ExportFocus::RateControl => ExportFocus::Fps,
3807 ExportFocus::Fps => ExportFocus::ColorSpace,
3808 };
3809 }
3810 FocusTarget::Grade => {
3811 if app.grade_focus + 1 < GradeSliders::count() {
3812 app.grade_morph = Some((app.grade_focus, 4));
3813 app.grade_focus += 1;
3814 app.grade_strip_active = true;
3815 app.grade_strip_idle_ticks = 15;
3816 }
3817 }
3818 }
3819 }
3820 }
3821 crossterm::event::KeyCode::Char(' ') => {
3822 if app.show_browser {
3823 app.browser.toggle_selection();
3824 } else {
3825 match app.focus_target {
3826 FocusTarget::MediaPool => app.toggle_media_pool_selection(),
3827 FocusTarget::Queue => app.toggle_queue_selection(),
3828 FocusTarget::ExportSettings => {}
3829 FocusTarget::Grade => {}
3830 }
3831 }
3832 }
3833 crossterm::event::KeyCode::PageUp => {
3834 if app.show_help {
3835 app.help_scroll = app.help_scroll.saturating_sub(10);
3836 } else if app.browsing_favourites {
3837 app.navigate_favourites(-10);
3838 } else if app.show_browser {
3839 let entries_len = app.browser.entries.len();
3840 if entries_len > 0 {
3841 let new_index = app.browser.selected_index.saturating_sub(10.min(entries_len));
3842 app.browser.selected_index = new_index;
3843 }
3844 } else if app.focus_target == FocusTarget::MediaPool {
3845 let len = app.imported_files.len();
3846 if len > 0 {
3847 let new_index = app.media_pool_index.saturating_sub(10.min(len));
3848 app.switch_media_pool_item(new_index);
3849 }
3850 } else if app.focus_target == FocusTarget::Queue {
3851 let len = app.queue.len();
3852 if len > 0 {
3853 app.queue_index = app.queue_index.saturating_sub(10.min(len));
3854 }
3855 }
3856 }
3857 crossterm::event::KeyCode::PageDown => {
3858 if app.show_help {
3859 app.help_scroll = app.help_scroll.saturating_add(10);
3860 } else if app.browsing_favourites {
3861 app.navigate_favourites(10);
3862 } else if app.show_browser {
3863 let entries_len = app.browser.entries.len();
3864 if entries_len > 0 {
3865 let new_index = (app.browser.selected_index + 10).min(entries_len - 1);
3866 app.browser.selected_index = new_index;
3867 }
3868 } else if app.focus_target == FocusTarget::MediaPool {
3869 let len = app.imported_files.len();
3870 if len > 0 {
3871 let new_index = (app.media_pool_index + 10).min(len - 1);
3872 app.switch_media_pool_item(new_index);
3873 }
3874 } else if app.focus_target == FocusTarget::Queue {
3875 let len = app.queue.len();
3876 if len > 0 {
3877 app.queue_index = (app.queue_index + 10).min(len - 1);
3878 }
3879 }
3880 }
3881 crossterm::event::KeyCode::Home => {
3882 if app.browsing_favourites {
3883 app.favourites_scroll_offset = Cell::new(0);
3884 } else if app.show_browser {
3885 app.browser.selected_index = 0;
3886 } else if app.focus_target == FocusTarget::MediaPool {
3887 app.switch_media_pool_item(0);
3888 } else if app.focus_target == FocusTarget::Queue {
3889 app.queue_index = 0;
3890 }
3891 }
3892 crossterm::event::KeyCode::End => {
3893 if app.browsing_favourites {
3894 if !app.favourite_folders.is_empty() {
3895 app.favourites_scroll_offset
3896 .set(app.favourite_folders.len() - 1);
3897 }
3898 } else if app.show_browser {
3899 let entries_len = app.browser.entries.len();
3900 if entries_len > 0 {
3901 app.browser.selected_index = entries_len - 1;
3902 }
3903 } else if app.focus_target == FocusTarget::MediaPool {
3904 if !app.imported_files.is_empty() {
3905 app.switch_media_pool_item(app.imported_files.len() - 1);
3906 }
3907 } else if app.focus_target == FocusTarget::Queue {
3908 if !app.queue.is_empty() {
3909 app.queue_index = app.queue.len() - 1;
3910 }
3911 }
3912 }
3913 crossterm::event::KeyCode::Backspace => {
3914 if app.browsing_favourites {
3915 app.browsing_favourites = false;
3916 app.status_message = "Folder view".to_string();
3917 } else if app.show_browser {
3918 app.navigate_browser(BrowserDirection::GoUp);
3919 }
3920 }
3921 _ => {}
3922 }
3923 }
3924 _ => {}
3925 }
3926}
3927
3928