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 sixel_panel_rect: Cell<Option<(u16, u16, u16, u16)>>,
490 pub last_written_media_index: Cell<Option<usize>>,
492 pub term_cell_size: Cell<(f32, f32)>,
494 pub preview_panel_chars: Cell<Option<(u16, u16)>>,
496 pub needs_rethumbnail: Cell<bool>,
499
500 pub spinner_frame: u8,
502 pub progress_anim_offset: u8,
503
504 pub fps_counter: FPSCounter,
506
507 pub shockwave_ticks_remaining: u8,
509
510 pub grade_strip_active: bool,
512 pub grade_morph: Option<(usize, u8)>,
514 pub phosphor_trail: Vec<(f32, u8)>,
516 pub grade_before_snapshot: Option<GradeSliders>,
518 pub grade_strip_idle_ticks: u8,
520
521}
522
523#[derive(Debug, Clone, Default)]
526pub struct PresetPickerState {
527 pub open: bool,
528 pub index: usize,
529 pub message: Option<String>,
530}
531
532#[derive(Debug, Clone)]
533pub struct PresetNamingState {
534 pub name: String,
535 pub message: Option<String>,
536}
537
538pub enum DropImportEvent {
540 FileReady { path: String, info: McrawFileInfo, first_timestamp: i64 },
541 Failed { path: String, error: String },
542 Complete { imported: usize, failed: usize },
543}
544
545pub struct DropPreview {
547 pub files: Vec<String>,
548 pub start_time: Instant,
549}
550
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552pub enum ExportFocus {
553 ColorSpace,
554 TransferFunction,
555 CodecFamily,
556 Profile,
557 RateControl,
558 Fps,
559}
560
561impl App {
562 fn favourites_file() -> Option<PathBuf> {
563 let mut dir = dirs::config_dir()?;
564 dir.push("mcraw-tui");
565 std::fs::create_dir_all(&dir).ok()?;
566 dir.push("favourites.json");
567 Some(dir)
568 }
569
570 fn load_favourites() -> Vec<PathBuf> {
571 let path = match Self::favourites_file() {
572 Some(p) => p,
573 None => return Vec::new(),
574 };
575 let data = match std::fs::read_to_string(&path) {
576 Ok(d) => d,
577 Err(_) => return Vec::new(),
578 };
579 serde_json::from_str(&data).unwrap_or_default()
580 }
581
582 fn save_favourites(&self) {
583 let path = match Self::favourites_file() {
584 Some(p) => p,
585 None => return,
586 };
587 if let Ok(data) = serde_json::to_string(&self.favourite_folders) {
588 let _ = std::fs::write(path, data);
589 }
590 }
591
592 pub fn new_with_placeholder(placeholder_path: Option<PathBuf>) -> Self {
593 let caps = probe_hardware();
594 App {
595 running: true,
596 screen: Screen::Browse,
597 file_path: None,
598 file_info: None,
599 frame_index: 0,
600 frame_count: 0,
601 encode_jobs: Vec::new(),
602 status_message: String::from("Ready | Drag-drop .mcraw files or press b to browse"),
603 show_help: false,
604 error: None,
605 browser: FileBrowser::new(),
606
607 is_exporting: false,
608 export_cancelled: false,
609 export_progress: 0.0,
610 export_rx: None,
611 cancel_token: None,
612 last_export_summary: None,
613 pending_export_summary: None,
614
615 export_color_space: ColorSpace::Rec709,
616 export_transfer_function: TransferFunction::Gamma24,
617 export_codec_family: CodecFamily::HEVC,
618 export_focus: ExportFocus::CodecFamily,
619 export_fps: None,
620 export_start_time: None,
621
622 prores_profile: ProResProfile::HQ,
623 dnxhr_profile: DnxhrProfile::HQX,
624 hevc_profile: HevcProfile::Main10_420,
625 h264_profile: H264Profile::Main8bit,
626 av1_profile: Av1Profile::Profile0_420_10bit,
627 vp9_profile: Vp9Profile::Profile2_420_10bit,
628
629 hardware_caps: caps,
630 active_rate_control: RateControl::Lossless,
631 is_editing_custom_rate: false,
632
633 imported_files: Vec::new(),
634 media_pool_index: 0,
635 queue: Vec::new(),
636 queue_index: 0,
637 show_browser: true,
638 current_rendering_index: None,
639 export_folder: None,
640 favourite_folders: Self::load_favourites(),
641 help_scroll: 0,
642 show_culling: false,
643 show_grade_screen: false,
644 import_popup: ImportPopupState::Hidden,
645 focus_target: FocusTarget::MediaPool,
646 show_full_info: false,
647 last_browser_click: None,
648 last_grade_click: None,
649 drop_highlight: None,
650 drop_import_rx: None,
651 drop_import_cancel: None,
652 drop_preview: None,
653 browser_scroll_offset: Cell::new(0),
654 show_favourites_bar: true,
655 last_clicked_favourite: None,
656 browsing_favourites: false,
657 favourites_scroll_offset: Cell::new(0),
658 presets: ExportPreset::load_all(),
659 active_preset: None,
660 preset_picker: PresetPickerState::default(),
661 preset_naming: None,
662
663 spinner_frame: 0,
664 progress_anim_offset: 0,
665 decoder: None,
666 timestamps: Vec::new(),
667 preview_state: PreviewState::Empty,
668 preview_pipeline: None,
669 preview_gpu_context: None,
670 thumbnail_cache: ThumbnailCache::new_with_placeholder(placeholder_path.as_deref()),
671 pending_preview_ts: None,
672 thumbnail_worker: Some(ThumbnailWorkerPool::new(2)),
673 thumbnail_requested: None,
674 sixel_pending: Cell::new(false),
675 sixel_write_pos: Cell::new(None),
676 sixel_occupy_size: Cell::new(None),
677 sixel_panel_rect: Cell::new(None),
678 last_written_media_index: Cell::new(None),
679 term_cell_size: Cell::new((10.0, 20.0)),
680 preview_panel_chars: Cell::new(None),
681 needs_rethumbnail: Cell::new(false),
682 fps_counter: FPSCounter::new(),
683 shockwave_ticks_remaining: 0,
684 grade_sliders: GradeSliders::default(),
685 grade_focus: 0,
686 grade_dragging: None,
687 grade_strip_active: true,
688 grade_morph: None,
689 phosphor_trail: Vec::new(),
690 grade_before_snapshot: None,
691 grade_strip_idle_ticks: 0,
692 }
693 }
694
695 pub fn new() -> Self {
697 Self::new_with_placeholder(None)
698 }
699
700 pub fn load_file(&mut self, path: String) {
705 tracing::info!("load_file: path={}", path);
706 self.error = None;
707 self.status_message = String::new();
708 match McrawFileInfo::from_path(&path) {
709 Ok(mut info) => {
710 tracing::debug!("file parsed: frames={} {}x{} fps={}", info.frame_count, info.width, info.height, info.fps);
711 let (decoder, timestamps) = match Decoder::new(&path) {
712 Ok(decoder) => {
713 let ts = decoder.timestamps().unwrap_or_default();
714 (Some(decoder), ts)
715 }
716 Err(e) => {
717 tracing::warn!("decoder init failed (OK for non-RAW): {}", e);
718 (None, Vec::new())
719 }
720 };
721
722 if let Some(ref decoder) = decoder {
723 if let Ok(container_meta) = decoder.container_metadata() {
724 let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
725 let mut r = [0.0; 9];
726 for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
727 r
728 };
729 let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
730
731 info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
732 if non_zero(&container_meta.color_matrix2) {
733 info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
734 }
735 if non_zero(&container_meta.forward_matrix1) {
736 info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
737 }
738 if non_zero(&container_meta.forward_matrix2) {
739 info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
740 }
741 if container_meta.has_calibration_illuminants {
742 info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
743 info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
744 }
745
746 if container_meta.white_level > 0.0 {
747 info.white_level = container_meta.white_level;
748 }
749 if container_meta.black_level_count > 0 {
750 info.black_level = container_meta.black_level[0];
751 }
752 }
753 info.frame_count = timestamps.len() as u32;
754 if timestamps.len() >= 2 {
755 let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
756 if duration_ns > 0 {
757 let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
758 info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
759 }
760 }
761 if !timestamps.is_empty() {
762 if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
763 info.width = first_frame_meta.width as u16;
764 info.height = first_frame_meta.height as u16;
765 }
766 if let Some(wb) = info.camera_metadata.wb_multipliers {
768 let r_gain = wb[0];
769 let b_gain = wb[2];
770 let ratio = (r_gain / b_gain.max(1e-6)).clamp(0.1, 10.0);
771 let temp = if ratio >= 1.0 {
772 5200.0 + (ratio - 1.0) * 3000.0
773 } else {
774 5200.0 - (1.0 - ratio) * 3000.0
775 };
776 self.grade_sliders.set(5, temp.clamp(2000.0, 10000.0));
777 } else {
778 self.grade_sliders.set(5, 5200.0);
779 }
780 }
781 }
782
783 self.decoder = decoder;
785 self.timestamps = timestamps;
786
787 self.preview_state = PreviewState::Empty;
789 self.pending_preview_ts = None;
790
791 if self.preview_pipeline.is_none() {
793 if let Ok(context) = PreviewGpuContext::new() {
794 let ctx_arc = Arc::new(context);
795 match GpuPreviewPipeline::new().init(ctx_arc.clone()) {
796 Ok(pipeline) => {
797 self.preview_pipeline = Some(pipeline);
798 self.preview_gpu_context = Some(ctx_arc);
799 }
800 Err(e) => {
801 tracing::warn!("GPU preview pipeline init failed: {}", e);
802 self.preview_state = PreviewState::Error(format!("GPU: {}", e));
803 }
804 }
805 } else {
806 tracing::warn!("No GPU adapter found — preview disabled");
807 self.preview_state = PreviewState::Error("No GPU available".into());
808 }
809 }
810
811 self.file_info = Some(info.clone());
812 self.frame_count = info.frame_count as usize;
813 self.file_path = Some(path.clone());
814
815 let already_pos = self.imported_files.iter().position(|f| f.path == path);
816 if let Some(pos) = already_pos {
817 self.media_pool_index = pos;
818 tracing::debug!("file already in media pool at index={}, switching to it", pos);
819 } else {
820 self.imported_files.push(ImportedFile {
821 path: path.clone(),
822 info: info.clone(),
823 selected: true,
824 first_timestamp: self.timestamps.first().copied().unwrap_or(0),
825 });
826 self.media_pool_index = self.imported_files.len() - 1;
827 tracing::info!("file added to media pool: index={}", self.media_pool_index);
828 }
829
830 self.status_message = format!("Imported: {}", path);
831 tracing::info!("file loaded successfully: {}", path);
832
833 self.last_written_media_index.set(None);
835
836 if self.decoder.is_some() && !self.timestamps.is_empty() {
838 self.frame_index = 0;
839 self.request_frame_decode(0);
840 }
841 }
842 Err(e) => {
843 tracing::error!("failed to load file {}: {}", path, e);
844 self.error = Some(format!("Failed to load file: {}", e));
845 self.status_message = format!("Error: {}", e);
846 }
847 }
848 }
849
850 pub fn request_frame_decode(&mut self, new_index: usize) {
857 if new_index >= self.timestamps.len() {
858 self.preview_state = PreviewState::Empty;
859 self.pending_preview_ts = None;
860 return;
861 }
862 let ts = self.timestamps[new_index];
863 self.preview_state = PreviewState::Loading { started: Instant::now() };
864 self.pending_preview_ts = Some(ts);
865 }
866
867 pub fn poll_thumbnail(&mut self) {
870 if let Some(ref worker) = self.thumbnail_worker {
872 while let Ok(result) = worker.result_rx.try_recv() {
873 if let Some(cached) = result.to_cached() {
875 self.thumbnail_cache.insert(result.path.clone(), cached);
876 }
877 let is_current = self.file_path.as_ref().map_or(false, |fp| *fp == *result.path.to_string_lossy());
879 if is_current {
880 self.last_written_media_index.set(None);
882 if let Some(sixel) = result.sixel {
883 self.sixel_pending.set(true);
884 self.preview_state = PreviewState::Ready {
885 sixel,
886 width: result.width,
887 height: result.height,
888 };
889 } else {
890 let msg = result.error.unwrap_or_else(|| "Unknown error".into());
891 self.preview_state = PreviewState::Error(msg);
892 }
893 }
894 }
895 }
896
897 let ts = match self.pending_preview_ts.take() {
899 Some(ts) => ts,
900 None => return,
901 };
902
903 let path_buf = match self.file_path.as_ref() {
906 Some(p) => PathBuf::from(p),
907 None => {
908 self.preview_state = PreviewState::Empty;
909 return;
910 }
911 };
912 let needs_regen = self.needs_rethumbnail.get();
913 if !needs_regen {
914 if let Some(cached) = self.thumbnail_cache.get(&path_buf) {
915 self.sixel_pending.set(true);
916 self.preview_state = PreviewState::Ready {
917 sixel: cached.sixel,
918 width: cached.width,
919 height: cached.height,
920 };
921 return;
922 }
923 }
924
925 if !needs_regen && self.thumbnail_requested.as_ref() == Some(&(path_buf.clone(), ts)) {
927 return;
928 }
929
930 if self.preview_panel_chars.get().is_none() {
933 self.pending_preview_ts = Some(ts); return;
935 }
936
937 if let PreviewState::Loading { started } = &self.preview_state {
939 if started.elapsed() > Duration::from_secs(5) {
940 self.preview_state = PreviewState::Error("Timed out".into());
941 return;
942 }
943 }
944
945 let frame_meta_width;
947 let frame_meta_height;
948 let (cm_f32, bayer_phase, bl, wl) = match self.file_info.as_ref() {
949 Some(info) => {
950 let cm = build_preview_ccm(
951 info.camera_metadata.color_matrix.as_ref(),
952 info.camera_metadata.forward_matrix1.as_ref(),
953 info.camera_metadata.forward_matrix2.as_ref(),
954 info.camera_metadata.color_matrix2.as_ref(),
955 info.camera_metadata.calibration_matrix1.as_ref(),
956 );
957 frame_meta_width = info.width as u32;
958 frame_meta_height = info.height as u32;
959 let bp = bayer_phase_to_u32(&info.bayer_pattern);
960 let bl = info.black_level as f32;
961 let wl = if info.white_level > 0.0 { info.white_level as f32 } else { 4095.0 };
962 (cm, bp, bl, wl)
963 }
964 None => {
965 self.preview_state = PreviewState::Empty;
967 return;
968 }
969 };
970
971 let (target_w, target_h) = match self.preview_panel_chars.get() {
972 Some((panel_cols, panel_rows)) => {
973 let (cell_w, cell_h) = self.term_cell_size.get();
974 let avail_px_w = (panel_cols as f32 * cell_w).ceil() as u32;
975 let avail_px_h = (panel_rows as f32 * cell_h).ceil() as u32;
976 (avail_px_w.max(16), avail_px_h.max(16))
977 }
978 None => (crate::thumbnail::THUMBNAIL_WIDTH, crate::thumbnail::THUMBNAIL_HEIGHT),
979 };
980
981 let params = self.build_preview_params(&cm_f32, bayer_phase, bl, wl,
982 frame_meta_width, frame_meta_height, target_w, target_h);
983
984 if let Some(ref worker) = self.thumbnail_worker {
986 worker.submit(ThumbnailRequest {
987 path: path_buf.clone(),
988 timestamp_ns: ts,
989 params,
990 });
991 self.thumbnail_requested = Some((path_buf, ts));
992 self.preview_state = PreviewState::Loading { started: Instant::now() };
993 }
994 self.needs_rethumbnail.set(false);
995 }
996
997 fn build_preview_params(
999 &self,
1000 ccm: &[f32; 9],
1001 bayer_phase: u32,
1002 black_level: f32,
1003 white_level: f32,
1004 raw_width: u32,
1005 raw_height: u32,
1006 target_w: u32,
1007 target_h: u32,
1008 ) -> PreviewParams {
1009 let bayer_aspect = raw_width as f64 / raw_height as f64;
1011 let target_aspect = target_w as f64 / target_h as f64;
1012
1013 let (width, height) = if bayer_aspect > target_aspect {
1014 let h = (target_w as f64 / bayer_aspect) as u32;
1015 (target_w, h.max(1))
1016 } else {
1017 let w = (target_h as f64 * bayer_aspect) as u32;
1018 (w.max(1), target_h)
1019 };
1020
1021 let as_shot = self.file_info.as_ref()
1023 .and_then(|info| info.camera_metadata.wb_multipliers)
1024 .unwrap_or([1.0, 1.0, 1.0]);
1025
1026 let temp_offset = self.grade_sliders.temperature - 5200.0;
1028 let tint_offset = self.grade_sliders.tint;
1029 let wb_gain_r = as_shot[0] * (1.0 + temp_offset / 10000.0);
1030 let wb_gain_g = as_shot[1];
1031 let wb_gain_b = as_shot[2] * (1.0 - temp_offset / 10000.0 + tint_offset / 100.0);
1032
1033 let exposure_stops = self.grade_sliders.exposure;
1035
1036 let adjust_enabled = (self.grade_sliders.exposure.abs() > 0.01
1038 || (self.grade_sliders.contrast - 1.0).abs() > 0.01
1039 || (self.grade_sliders.saturation - 1.0).abs() > 0.01
1040 || self.grade_sliders.shadows.abs() > 0.01
1041 || self.grade_sliders.highlights.abs() > 0.01
1042 || (self.grade_sliders.temperature - 5200.0).abs() > 50.0
1043 || self.grade_sliders.tint.abs() > 0.5) as u32;
1044
1045 PreviewParams {
1046 width,
1047 height,
1048 bayer_width: raw_width,
1049 bayer_height: raw_height,
1050 black_level,
1051 white_level,
1052 exposure: exposure_stops,
1053 wb_r: wb_gain_r,
1054 wb_g: wb_gain_g,
1055 wb_b: wb_gain_b,
1056 contrast: self.grade_sliders.contrast,
1057 saturation: self.grade_sliders.saturation,
1058 shadows: self.grade_sliders.shadows,
1059 highlights: self.grade_sliders.highlights,
1060 _align0: 0.0,
1061 _align1: 0.0,
1062 ccm_row0: [ccm[0], ccm[1], ccm[2], 0.0],
1063 ccm_row1: [ccm[3], ccm[4], ccm[5], 0.0],
1064 ccm_row2: [ccm[6], ccm[7], ccm[8], 0.0],
1065 color_space: color_space_to_u32(&ColorSpace::Rec709),
1066 transfer: transfer_to_u32(&TransferFunction::Gamma24),
1067 adjust_enabled,
1068 bayer_phase,
1069 compute_histogram: 0,
1070 _pad0: 0, _pad1: 0, _pad2: 0, _pad3: 0, _pad4: 0, _pad5: 0, _pad6: 0,
1071 }
1072 }
1073
1074 fn request_thumbnail_for(&self, path: &str, timestamp_ns: i64) {
1077 let worker = match self.thumbnail_worker.as_ref() {
1078 Some(w) => w,
1079 None => return,
1080 };
1081 let imported = match self.imported_files.iter().find(|f| f.path == path) {
1082 Some(f) => f,
1083 None => return,
1084 };
1085 let cm = build_preview_ccm(
1086 imported.info.camera_metadata.color_matrix.as_ref(),
1087 imported.info.camera_metadata.forward_matrix1.as_ref(),
1088 imported.info.camera_metadata.forward_matrix2.as_ref(),
1089 imported.info.camera_metadata.color_matrix2.as_ref(),
1090 imported.info.camera_metadata.calibration_matrix1.as_ref(),
1091 );
1092 let bp = bayer_phase_to_u32(&imported.info.bayer_pattern);
1093 let bl = imported.info.black_level as f32;
1094 let wl = if imported.info.white_level > 0.0 { imported.info.white_level as f32 } else { 4095.0 };
1095 let (target_w, target_h) = match self.preview_panel_chars.get() {
1096 Some((pc, pr)) => {
1097 let (cw, ch) = self.term_cell_size.get();
1098 ((pc as f32 * cw).ceil() as u32, (pr as f32 * ch).ceil() as u32)
1099 }
1100 None => (crate::thumbnail::THUMBNAIL_WIDTH, crate::thumbnail::THUMBNAIL_HEIGHT),
1101 };
1102 let params = self.build_preview_params(&cm, bp, bl, wl,
1103 imported.info.width as u32, imported.info.height as u32, target_w, target_h);
1104 worker.submit(ThumbnailRequest {
1105 path: PathBuf::from(path),
1106 timestamp_ns,
1107 params,
1108 });
1109 }
1110
1111 fn resize_rgba(&self, src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32) -> Vec<u8> {
1113 if src_w == dst_w && src_h == dst_h {
1114 return src.to_vec();
1115 }
1116 let mut dst = vec![0u8; (dst_w * dst_h * 4) as usize];
1117 for y in 0..dst_h {
1118 let src_y = y as f32 * src_h as f32 / dst_h as f32;
1119 let y0 = (src_y.floor() as u32).min(src_h.saturating_sub(1));
1120 let y1 = (y0 + 1).min(src_h.saturating_sub(1));
1121 let fy = src_y - y0 as f32;
1122 for x in 0..dst_w {
1123 let src_x = x as f32 * src_w as f32 / dst_w as f32;
1124 let x0 = (src_x.floor() as u32).min(src_w.saturating_sub(1));
1125 let x1 = (x0 + 1).min(src_w.saturating_sub(1));
1126 let fx = src_x - x0 as f32;
1127 let idx00 = ((y0 * src_w + x0) * 4) as usize;
1128 let idx01 = ((y0 * src_w + x1) * 4) as usize;
1129 let idx10 = ((y1 * src_w + x0) * 4) as usize;
1130 let idx11 = ((y1 * src_w + x1) * 4) as usize;
1131 let didx = ((y * dst_w + x) * 4) as usize;
1132 for c in 0..4 {
1133 let v00 = src[idx00 + c] as f32;
1134 let v01 = src[idx01 + c] as f32;
1135 let v10 = src[idx10 + c] as f32;
1136 let v11 = src[idx11 + c] as f32;
1137 let v0 = v00 + (v01 - v00) * fx;
1138 let v1 = v10 + (v11 - v10) * fx;
1139 dst[didx + c] = (v0 + (v1 - v0) * fy).round().clamp(0.0, 255.0) as u8;
1140 }
1141 }
1142 }
1143 dst
1144 }
1145
1146 pub fn load_files_batch(&mut self, paths: &[String]) -> (usize, usize) {
1149 tracing::info!("load_files_batch: count={}", paths.len());
1150 let mut imported = 0;
1151 let mut failed = 0;
1152 for path in paths {
1153 self.error = None;
1154 match McrawFileInfo::from_path(path) {
1155 Ok(mut info) => {
1156 let mut first_ts = 0i64;
1157 if let Ok(decoder) = Decoder::new(path) {
1158 if let Ok(container_meta) = decoder.container_metadata() {
1159 let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
1160 let mut r = [0.0; 9];
1161 for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
1162 r
1163 };
1164 let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
1165 info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
1166 if non_zero(&container_meta.color_matrix2) {
1167 info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
1168 }
1169 if non_zero(&container_meta.forward_matrix1) {
1170 info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
1171 }
1172 if non_zero(&container_meta.forward_matrix2) {
1173 info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
1174 }
1175 if container_meta.has_calibration_illuminants {
1176 info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
1177 info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
1178 }
1179 if container_meta.white_level > 0.0 {
1180 info.white_level = container_meta.white_level;
1181 }
1182 if container_meta.black_level_count > 0 {
1183 info.black_level = container_meta.black_level[0];
1184 }
1185 }
1186 if let Ok(timestamps) = decoder.timestamps() {
1187 first_ts = timestamps.first().copied().unwrap_or(0);
1188 info.frame_count = timestamps.len() as u32;
1189 if timestamps.len() >= 2 {
1190 let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
1191 if duration_ns > 0 {
1192 let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
1193 info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
1194 }
1195 }
1196 if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
1197 info.width = first_frame_meta.width as u16;
1198 info.height = first_frame_meta.height as u16;
1199 }
1200 }
1201 }
1202
1203 let already = self.imported_files.iter().any(|f| f.path == *path);
1204 if !already {
1205 self.imported_files.push(ImportedFile {
1206 path: path.clone(),
1207 info: info.clone(),
1208 selected: true,
1209 first_timestamp: first_ts,
1210 });
1211 imported += 1;
1212 tracing::debug!("batch imported: {} ({} total)", path, self.imported_files.len());
1213 }
1214 }
1215 Err(e) => {
1216 failed += 1;
1217 tracing::warn!("batch import failed for {}: {}", path, e);
1218 }
1219 }
1220 }
1221 if imported > 0 && self.imported_files.len() > 0 {
1223 self.media_pool_index = self.imported_files.len() - imported;
1224 self.file_info = Some(self.imported_files[self.media_pool_index].info.clone());
1225 self.file_path = Some(self.imported_files[self.media_pool_index].path.clone());
1226 self.frame_count = self.imported_files[self.media_pool_index].info.frame_count as usize;
1227 }
1228 (imported, failed)
1229 }
1230
1231 pub fn start_async_import(&mut self, paths: Vec<String>) {
1234 if let Some(cancel) = self.drop_import_cancel.take() {
1236 cancel.store(true, Ordering::Relaxed);
1237 }
1238
1239 let (tx, rx) = mpsc::channel::<DropImportEvent>();
1240 let cancel_flag = Arc::new(AtomicBool::new(false));
1241 self.drop_import_cancel = Some(cancel_flag.clone());
1242 self.drop_import_rx = Some(rx);
1243
1244 self.drop_preview = Some(DropPreview {
1246 files: paths.iter()
1247 .filter(|p| p.to_lowercase().ends_with(".mcraw"))
1248 .map(|p| p.clone())
1249 .collect(),
1250 start_time: Instant::now(),
1251 });
1252
1253 let total = paths.len();
1254 self.status_message = format!("Importing {} file(s)...", total);
1255
1256 std::thread::spawn(move || {
1257 let mut imported = 0;
1258 let mut failed = 0;
1259
1260 for path in paths {
1261 if cancel_flag.load(Ordering::Relaxed) {
1262 tracing::info!("async drag-drop import cancelled");
1263 break;
1264 }
1265
1266 let path_clone = path.clone();
1267 match McrawFileInfo::from_path(&path) {
1268 Ok(mut info) => {
1269 let mut first_ts: i64 = 0;
1270 if let Ok(decoder) = Decoder::new(&path) {
1272 first_ts = decoder.timestamps().ok()
1273 .and_then(|ts| ts.first().copied())
1274 .unwrap_or(0);
1275 if let Ok(container_meta) = decoder.container_metadata() {
1276 let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
1277 let mut r = [0.0; 9];
1278 for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
1279 r
1280 };
1281 let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
1282 info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
1283 if non_zero(&container_meta.color_matrix2) {
1284 info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
1285 }
1286 if non_zero(&container_meta.forward_matrix1) {
1287 info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
1288 }
1289 if non_zero(&container_meta.forward_matrix2) {
1290 info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
1291 }
1292 if container_meta.has_calibration_illuminants {
1293 info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
1294 info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
1295 }
1296 if container_meta.white_level > 0.0 {
1297 info.white_level = container_meta.white_level;
1298 }
1299 if container_meta.black_level_count > 0 {
1300 info.black_level = container_meta.black_level[0];
1301 }
1302 }
1303 if let Ok(timestamps) = decoder.timestamps() {
1304 info.frame_count = timestamps.len() as u32;
1305 if timestamps.len() >= 2 {
1306 let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
1307 if duration_ns > 0 {
1308 let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
1309 info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
1310 }
1311 }
1312 if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
1313 info.width = first_frame_meta.width as u16;
1314 info.height = first_frame_meta.height as u16;
1315 }
1316 }
1317 }
1318
1319 let _ = tx.send(DropImportEvent::FileReady { path: path_clone, info, first_timestamp: first_ts });
1320 imported += 1;
1321 }
1322 Err(e) => {
1323 let _ = tx.send(DropImportEvent::Failed {
1324 path: path_clone,
1325 error: e.to_string(),
1326 });
1327 failed += 1;
1328 tracing::warn!("async drag-drop import failed: {}: {}", path, e);
1329 }
1330 }
1331 }
1332
1333 let _ = tx.send(DropImportEvent::Complete { imported, failed });
1334 });
1335 }
1336
1337 pub fn poll_drop_import(&mut self) {
1339 let rx = match self.drop_import_rx.take() {
1340 Some(rx) => rx,
1341 None => return,
1342 };
1343
1344 let mut keep_rx = true;
1345 while let Ok(event) = rx.try_recv() {
1346 match event {
1347 DropImportEvent::FileReady { path, info, first_timestamp } => {
1348 let already = self.imported_files.iter().any(|f| f.path == path);
1349 if !already {
1350 self.imported_files.push(ImportedFile {
1351 path: path.clone(),
1352 info: info.clone(),
1353 selected: true,
1354 first_timestamp,
1355 });
1356 if self.imported_files.len() == 1 {
1358 self.media_pool_index = 0;
1359 self.file_info = Some(info.clone());
1360 self.file_path = Some(path.clone());
1361 self.frame_count = info.frame_count as usize;
1362 }
1363 tracing::debug!("async imported: {} ({} total)", path, self.imported_files.len());
1364 }
1365 }
1366 DropImportEvent::Failed { path, error } => {
1367 tracing::warn!("async import failed: {}: {}", path, error);
1368 }
1369 DropImportEvent::Complete { imported, failed } => {
1370 keep_rx = false;
1371 self.drop_import_cancel = None;
1372 if imported > 0 {
1373 self.media_pool_index = self.imported_files.len().saturating_sub(imported);
1374 if let Some(f) = self.imported_files.get(self.media_pool_index) {
1375 self.file_info = Some(f.info.clone());
1376 self.file_path = Some(f.path.clone());
1377 self.frame_count = f.info.frame_count as usize;
1378 }
1379 }
1380 if failed > 0 {
1381 self.status_message = format!("Imported {} file(s), {} failed", imported, failed);
1382 } else {
1383 self.status_message = format!("Imported {} file(s)", imported);
1384 }
1385 tracing::info!("async drag-drop import complete: {} imported, {} failed", imported, failed);
1386 }
1387 }
1388 }
1389
1390 if keep_rx {
1391 self.drop_import_rx = Some(rx);
1392 }
1393 }
1394
1395 pub fn load_all_in_folder(&mut self, dir: &std::path::Path) {
1396 if let Ok(entries) = std::fs::read_dir(dir) {
1397 let mut mcraw_paths: Vec<String> = entries
1398 .filter_map(|e| e.ok())
1399 .map(|e| e.path())
1400 .filter(|p| p.extension().map_or(false, |ext| ext == "mcraw"))
1401 .map(|p| p.to_string_lossy().to_string())
1402 .collect();
1403 mcraw_paths.sort();
1404 let count = mcraw_paths.len();
1405 for path in mcraw_paths {
1406 self.load_file(path);
1407 }
1408 if count > 0 {
1409 self.status_message = format!("Imported {} .mcraw files from {}", count, dir.display());
1410 } else {
1411 self.status_message = format!("No .mcraw files found in {}", dir.display());
1412 }
1413 }
1414 }
1415
1416 pub fn focused_file_info(&self) -> Option<&McrawFileInfo> {
1421 self.imported_files.get(self.media_pool_index).map(|f| &f.info)
1422 }
1423
1424 pub fn toggle_media_pool_selection(&mut self) {
1425 if let Some(f) = self.imported_files.get_mut(self.media_pool_index) {
1426 f.selected = !f.selected;
1427 }
1428 }
1429
1430 pub fn toggle_select_all(&mut self) {
1433 if self.imported_files.is_empty() {
1434 return;
1435 }
1436 let all_selected = self.imported_files.iter().all(|f| f.selected);
1437 for f in &mut self.imported_files {
1438 f.selected = !all_selected;
1439 }
1440 let msg = if all_selected { "Deselected all" } else { "Selected all" };
1441 self.status_message = format!("{} ({} files)", msg, self.imported_files.len());
1442 }
1443
1444 pub fn switch_media_pool_item(&mut self, new_index: usize) {
1446 if new_index >= self.imported_files.len() {
1447 return;
1448 }
1449 if new_index == self.media_pool_index {
1450 return;
1451 }
1452 let path = self.imported_files[new_index].path.clone();
1453 self.media_pool_index = new_index;
1454 self.last_export_summary = None;
1455 self.sixel_pending.set(false);
1456 self.sixel_write_pos.set(None);
1457 self.last_written_media_index.set(None);
1458 if self.file_path.as_deref() != Some(&path) {
1459 self.load_file(path);
1460 } else {
1461 self.preview_state = PreviewState::Empty;
1463 if self.decoder.is_some() && !self.timestamps.is_empty() {
1464 self.request_frame_decode(self.frame_index.min(self.timestamps.len() - 1));
1465 }
1466 }
1467
1468 let start = new_index.saturating_sub(3);
1470 let end = self.imported_files.len().min(new_index + 4);
1471 for i in start..end {
1472 if i == new_index { continue; }
1473 let n = &self.imported_files[i];
1474 if n.first_timestamp > 0 {
1475 self.request_thumbnail_for(&n.path, n.first_timestamp);
1476 }
1477 }
1478 }
1479
1480 pub fn add_selected_to_queue(&mut self) {
1481 let selected: Vec<ImportedFile> = self.imported_files.iter()
1482 .filter(|f| f.selected)
1483 .cloned()
1484 .collect();
1485 if selected.is_empty() {
1486 self.status_message = "No files selected - use Space to select, then a to add".to_string();
1487 return;
1488 }
1489 let count = selected.len();
1490 for imp in &selected {
1491 let already = self.queue.iter().any(|q| q.path == imp.path);
1492 if !already {
1493 self.queue.push(QueuedFile {
1494 path: imp.path.clone(),
1495 info: imp.info.clone(),
1496 selected: true,
1497 status: QueueStatus::Waiting,
1498 progress: 0.0,
1499 });
1500 }
1501 }
1502 self.status_message = format!("Added {} file(s) to render queue", count);
1503 }
1504
1505 pub fn add_all_to_queue(&mut self) {
1506 if self.imported_files.is_empty() {
1507 self.status_message = "No files in media pool".to_string();
1508 return;
1509 }
1510 let count = self.imported_files.len();
1511 for imp in &self.imported_files {
1512 let already = self.queue.iter().any(|q| q.path == imp.path);
1513 if !already {
1514 self.queue.push(QueuedFile {
1515 path: imp.path.clone(),
1516 info: imp.info.clone(),
1517 selected: true,
1518 status: QueueStatus::Waiting,
1519 progress: 0.0,
1520 });
1521 }
1522 }
1523 self.status_message = format!("Added all {} file(s) to render queue", count);
1524 }
1525
1526 pub fn remove_from_media_pool(&mut self) {
1527 if self.imported_files.is_empty() {
1528 return;
1529 }
1530 let name = self.imported_files[self.media_pool_index]
1531 .path
1532 .split(std::path::MAIN_SEPARATOR)
1533 .last()
1534 .unwrap_or("unknown")
1535 .to_string();
1536 self.imported_files.remove(self.media_pool_index);
1537 if self.media_pool_index >= self.imported_files.len() && self.imported_files.len() > 0 {
1538 self.media_pool_index = self.imported_files.len() - 1;
1539 }
1540 self.status_message = format!("Removed {} from media pool", name);
1541 }
1542
1543 pub fn toggle_queue_selection(&mut self) {
1548 if let Some(q) = self.queue.get_mut(self.queue_index) {
1549 q.selected = !q.selected;
1550 }
1551 }
1552
1553 pub fn remove_from_queue(&mut self) {
1554 if self.queue.is_empty() {
1555 return;
1556 }
1557 let has_selected = self.queue.iter().any(|q| q.selected);
1558 if has_selected {
1559 self.queue.retain(|q| !q.selected);
1560 self.status_message = "Removed selected items from queue".to_string();
1561 } else {
1562 let name = self.queue[self.queue_index]
1563 .path
1564 .split(std::path::MAIN_SEPARATOR)
1565 .last()
1566 .unwrap_or("unknown")
1567 .to_string();
1568 self.queue.remove(self.queue_index);
1569 if self.queue_index >= self.queue.len() && self.queue.len() > 0 {
1570 self.queue_index = self.queue.len() - 1;
1571 }
1572 self.status_message = format!("Removed {} from queue", name);
1573 }
1574 if self.queue_index >= self.queue.len() && !self.queue.is_empty() {
1575 self.queue_index = self.queue.len() - 1;
1576 }
1577 }
1578
1579 pub fn clear_completed_queue(&mut self) {
1580 let before = self.queue.len();
1581 self.queue.retain(|q| !matches!(q.status, QueueStatus::Completed | QueueStatus::Failed(_)));
1582 let removed = before - self.queue.len();
1583 if removed > 0 {
1584 self.status_message = format!("Cleared {} completed/failed item(s)", removed);
1585 } else {
1586 self.status_message = "No completed/failed items to clear".to_string();
1587 }
1588 if self.queue_index >= self.queue.len() && !self.queue.is_empty() {
1589 self.queue_index = self.queue.len() - 1;
1590 }
1591 }
1592
1593 pub fn render_selected(&mut self) {
1594 let selected_indices: Vec<usize> = self.queue.iter()
1595 .enumerate()
1596 .filter(|(_, q)| q.selected)
1597 .map(|(i, _)| i)
1598 .collect();
1599 if selected_indices.is_empty() {
1600 self.status_message = "No items selected in queue - use Space to select".to_string();
1601 return;
1602 }
1603 self.status_message = format!("Starting render of {} selected file(s)...", selected_indices.len());
1604 if let Some(&first_idx) = selected_indices.first() {
1606 self.current_rendering_index = Some(first_idx);
1607 let q = &self.queue[first_idx];
1608 self.file_info = Some(q.info.clone());
1609 self.file_path = Some(q.path.clone());
1610 self.frame_count = q.info.frame_count as usize;
1611 self.start_export();
1612 }
1613 }
1614
1615 pub fn render_all(&mut self) {
1616 if self.queue.is_empty() {
1617 self.status_message = "Queue is empty".to_string();
1618 return;
1619 }
1620 self.status_message = format!("Starting render of all {} file(s)...", self.queue.len());
1621 for q in &mut self.queue {
1622 q.selected = true;
1623 }
1624 self.current_rendering_index = Some(0);
1626 if let Some(q) = self.queue.first() {
1627 self.file_info = Some(q.info.clone());
1628 self.file_path = Some(q.path.clone());
1629 self.frame_count = q.info.frame_count as usize;
1630 self.start_export();
1631 }
1632 }
1633
1634 fn start_next_queued_render(&mut self) {
1635 if let Some(current) = self.current_rendering_index {
1637 let next_idx = (current + 1..self.queue.len())
1638 .find(|&i| self.queue[i].selected && self.queue[i].status == QueueStatus::Waiting);
1639 if let Some(idx) = next_idx {
1640 self.current_rendering_index = Some(idx);
1641 self.queue[idx].status = QueueStatus::Rendering;
1642 let q = &self.queue[idx];
1643 self.file_info = Some(q.info.clone());
1644 self.file_path = Some(q.path.clone());
1645 self.frame_count = q.info.frame_count as usize;
1646 self.start_export();
1647 } else {
1648 self.current_rendering_index = None;
1650 let done = self.queue.iter().filter(|q| q.selected && q.status == QueueStatus::Completed).count();
1651 let total = self.queue.iter().filter(|q| q.selected).count();
1652 self.status_message = format!("Batch render complete: {}/{} done", done, total);
1653 }
1654 }
1655 }
1656
1657 pub fn active_profile_is_8bit(&self) -> bool {
1662 match self.export_codec_family {
1663 CodecFamily::ProRes => false,
1664 CodecFamily::DNxHR => false,
1665 CodecFamily::HEVC => self.hevc_profile.is_8bit(),
1666 CodecFamily::H264 => self.h264_profile.is_8bit(),
1667 CodecFamily::AV1 => self.av1_profile.is_8bit(),
1668 CodecFamily::VP9 => self.vp9_profile.is_8bit(),
1669 }
1670 }
1671
1672 pub fn active_profile_name(&self) -> &'static str {
1673 match self.export_codec_family {
1674 CodecFamily::ProRes => self.prores_profile.name(),
1675 CodecFamily::DNxHR => self.dnxhr_profile.name(),
1676 CodecFamily::HEVC => self.hevc_profile.name(),
1677 CodecFamily::H264 => self.h264_profile.name(),
1678 CodecFamily::AV1 => self.av1_profile.name(),
1679 CodecFamily::VP9 => self.vp9_profile.name(),
1680 }
1681 }
1682
1683 pub fn cycle_rate_control(&mut self) {
1684 self.active_rate_control = self.active_rate_control.next();
1685 self.is_editing_custom_rate = false;
1686 self.status_message = format!("Rate: {}", self.active_rate_control.name());
1687 }
1688
1689 pub fn fps_label(fps: Option<f64>) -> String {
1690 match fps {
1691 None => "Original".to_string(),
1692 Some(v) if (v - 23.976).abs() < 0.001 => "23.976".to_string(),
1693 Some(v) if (v - 24.0).abs() < 0.001 => "24".to_string(),
1694 Some(v) if (v - 25.0).abs() < 0.001 => "25".to_string(),
1695 Some(v) if (v - 30.0).abs() < 0.001 => "30".to_string(),
1696 Some(v) if (v - 50.0).abs() < 0.001 => "50".to_string(),
1697 Some(v) if (v - 60.0).abs() < 0.001 => "60".to_string(),
1698 Some(v) if (v - 120.0).abs() < 0.001 => "120".to_string(),
1699 Some(v) => format!("{:.3}", v),
1700 }
1701 }
1702
1703 pub fn cycle_export_fps(&mut self) {
1705 self.export_fps = match self.export_fps {
1706 None => Some(23.976),
1707 Some(v) if (v - 23.976).abs() < 0.001 => Some(24.0),
1708 Some(v) if (v - 24.0).abs() < 0.001 => Some(25.0),
1709 Some(v) if (v - 25.0).abs() < 0.001 => Some(30.0),
1710 Some(v) if (v - 30.0).abs() < 0.001 => Some(50.0),
1711 Some(v) if (v - 50.0).abs() < 0.001 => Some(60.0),
1712 Some(v) if (v - 60.0).abs() < 0.001 => Some(120.0),
1713 _ => None,
1714 };
1715 self.export_focus = ExportFocus::Fps;
1716 self.status_message = format!("FPS: {}", Self::fps_label(self.export_fps));
1717 }
1718
1719 pub fn cycle_codec(&mut self, forward: bool) {
1720 self.export_codec_family = if forward {
1721 self.export_codec_family.next()
1722 } else {
1723 self.export_codec_family.prev()
1724 };
1725 self.export_focus = ExportFocus::CodecFamily;
1726 self.status_message = format!("Codec: {}", self.export_codec_family.name());
1727 }
1728
1729 pub fn cycle_profile(&mut self, forward: bool) {
1730 match self.export_codec_family {
1731 CodecFamily::ProRes => {
1732 self.prores_profile = if forward { self.prores_profile.next() } else { self.prores_profile.prev() };
1733 self.status_message = format!("Profile: {}", self.prores_profile.name());
1734 }
1735 CodecFamily::DNxHR => {
1736 self.dnxhr_profile = if forward { self.dnxhr_profile.next() } else { self.dnxhr_profile.prev() };
1737 self.status_message = format!("Profile: {}", self.dnxhr_profile.name());
1738 }
1739 CodecFamily::HEVC => {
1740 self.hevc_profile = if forward { self.hevc_profile.next() } else { self.hevc_profile.prev() };
1741 self.status_message = format!("Profile: {}", self.hevc_profile.name());
1742 }
1743 CodecFamily::H264 => {
1744 self.h264_profile = if forward { self.h264_profile.next() } else { self.h264_profile.prev() };
1745 self.status_message = format!("Profile: {}", self.h264_profile.name());
1746 }
1747 CodecFamily::AV1 => {
1748 self.av1_profile = if forward { self.av1_profile.next() } else { self.av1_profile.prev() };
1749 self.status_message = format!("Profile: {}", self.av1_profile.name());
1750 }
1751 CodecFamily::VP9 => {
1752 self.vp9_profile = if forward { self.vp9_profile.next() } else { self.vp9_profile.prev() };
1753 self.status_message = format!("Profile: {}", self.vp9_profile.name());
1754 }
1755 }
1756 self.export_focus = ExportFocus::Profile;
1757 }
1758
1759 pub fn start_export(&mut self) {
1760 if self.is_exporting {
1761 tracing::info!("export cancelled by user (was already exporting)");
1762 self.cancel_export();
1763 self.status_message = "Export cancelled. Press V again to restart.".to_string();
1764 return;
1765 }
1766 let info = match self.file_info.clone() {
1767 Some(i) => i,
1768 None => {
1769 tracing::warn!("start_export called with no file loaded");
1770 self.status_message = "No file loaded".to_string();
1771 return;
1772 }
1773 };
1774
1775 if self.export_transfer_function.requires_10bit() && self.active_profile_is_8bit() {
1776 tracing::warn!("export blocked: log/HDR to 8-bit codec not supported");
1777 self.status_message = "Cannot export Log/HDR to 8-bit codec".to_string();
1778 return;
1779 }
1780
1781 let input_path = std::path::Path::new(&info.path);
1782 let parent = self.export_folder.clone().unwrap_or_else(|| {
1783 input_path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf()
1784 });
1785 let stem = input_path.file_stem().and_then(|s| s.to_str()).unwrap_or("output");
1786
1787 let ext = match self.export_codec_family {
1788 CodecFamily::ProRes | CodecFamily::DNxHR => "mov",
1789 CodecFamily::VP9 => "webm",
1790 _ => "mp4",
1791 };
1792 let tf_label = self.export_transfer_function.name().replace([' ', '(', ')', '.'], "");
1793 let cs_label = self.export_color_space.name().replace([' ', '(', ')', '.'], "");
1794 let filename = format!("{}_{}_{}.{}", stem, tf_label, cs_label, ext);
1795 let mut file = parent.join(&filename);
1796 let mut suffix = 1;
1797 while file.exists() {
1798 let base = format!("{}_{}_{}_{}", stem, tf_label, cs_label, suffix);
1799 file = parent.join(&base).with_extension(ext);
1800 suffix += 1;
1801 }
1802 let output_path = file.to_string_lossy().to_string();
1803 tracing::info!("export starting: output={} codec={} profile={} rate={}",
1804 output_path, self.export_codec_family.name(),
1805 self.active_profile_name(), self.active_rate_control.name());
1806 let cs = self.export_color_space;
1807 let tf = self.export_transfer_function;
1808 let cf = self.export_codec_family;
1809 let pp = self.prores_profile;
1810 let dp = self.dnxhr_profile;
1811 let hp = self.hevc_profile;
1812 let h4p = self.h264_profile;
1813 let ap = self.av1_profile;
1814 let vp = self.vp9_profile;
1815 let hevc_enc = self.hardware_caps.best_hevc_encoder.clone();
1816 let h264_enc = self.hardware_caps.best_h264_encoder.clone();
1817 let av1_enc = self.hardware_caps.best_av1_encoder.clone();
1818 let prores_enc = self.hardware_caps.best_prores_encoder.clone();
1819
1820 self.is_exporting = true;
1821 self.export_cancelled = false;
1822 self.export_progress = 0.0;
1823 self.export_start_time = Some(Instant::now());
1824 self.last_export_summary = None;
1828 self.pending_export_summary = Some(ExportSummary {
1832 output_path: output_path.clone(),
1833 codec_label: cf.name().to_string(),
1834 profile_label: self.active_profile_name().to_string(),
1835 color_space: cs.name().to_string(),
1836 transfer: tf.name().to_string(),
1837 rate_control: self.active_rate_control.name(),
1838 frame_count: info.frame_count as usize,
1839 elapsed: Duration::default(),
1840 result: Ok(()),
1841 });
1842 if let Some(idx) = self.current_rendering_index {
1844 if idx < self.queue.len() {
1845 self.queue[idx].status = QueueStatus::Rendering;
1846 }
1847 }
1848 let cancel_flag = Arc::new(AtomicBool::new(false));
1849 self.cancel_token = Some(cancel_flag.clone());
1850 let (tx, rx) = mpsc::channel::<ExportEvent>();
1851 self.export_rx = Some(rx);
1852 self.status_message = format!(
1853 "Starting export: {} / {} via {} {} ...",
1854 cs.name(),
1855 tf.name(),
1856 cf.name(),
1857 self.active_profile_name(),
1858 );
1859
1860 let progress_cb = {
1861 let prog_tx = tx.clone();
1862 Arc::new(move |pct: f64| { let _ = prog_tx.send(ExportEvent::Progress(pct)); })
1863 };
1864
1865 let rate_control = self.active_rate_control.clone();
1866 let custom_fps = self.export_fps;
1867 let stats = Arc::new(PipelineStats::new());
1868 let stats_for_event = Arc::clone(&stats);
1869
1870 std::thread::spawn(move || {
1871 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1872 crate::pipeline::run_export(
1873 info, output_path, progress_cb, cancel_flag, stats,
1874 cs, tf, cf, pp, dp, hp, h4p, ap, vp,
1875 hevc_enc, h264_enc, av1_enc, prores_enc,
1876 rate_control, custom_fps,
1877 )
1878 }));
1879 let _ = tx.send(ExportEvent::Stats(stats_for_event));
1882 match result {
1883 Ok(export_result) => {
1884 let _ = tx.send(ExportEvent::Done(export_result));
1885 }
1886 Err(panic) => {
1887 tracing::error!("export thread panicked: {:?}", panic);
1888 let _ = tx.send(ExportEvent::Done(Err(anyhow::anyhow!("Export thread panicked"))));
1889 }
1890 }
1891 });
1892 }
1893
1894 pub fn remove_selected_from_media_pool(&mut self) {
1895 let has_selected = self.imported_files.iter().any(|f| f.selected);
1896 if has_selected {
1897 let count = self.imported_files.iter().filter(|f| f.selected).count();
1898 self.imported_files.retain(|f| !f.selected);
1899 if self.media_pool_index >= self.imported_files.len() && !self.imported_files.is_empty() {
1900 self.media_pool_index = self.imported_files.len() - 1;
1901 }
1902 self.status_message = format!("Removed {} selected file(s) from media pool", count);
1903 } else {
1904 self.status_message = "No files selected - use Space to select".to_string();
1905 }
1906 }
1907
1908 pub fn set_export_folder(&mut self, folder: std::path::PathBuf) {
1909 self.export_folder = Some(folder);
1910 self.status_message = format!("Export folder set");
1911 }
1912
1913 pub fn toggle_favourite_folder(&mut self, folder: PathBuf) {
1914 if let Some(pos) = self.favourite_folders.iter().position(|f| f == &folder) {
1915 self.favourite_folders.remove(pos);
1916 self.status_message = "Removed from favourites".to_string();
1917 } else {
1918 self.favourite_folders.push(folder);
1919 self.status_message = "Added to favourites".to_string();
1920 }
1921 self.save_favourites();
1922 }
1923
1924 pub fn save_current_as_preset(&mut self, name: String) {
1932 let name = name.trim().to_string();
1933 if name.is_empty() {
1934 self.status_message = "Preset name cannot be empty".to_string();
1935 return;
1936 }
1937 let preset = ExportPreset::snapshot(
1938 name.clone(),
1939 self.export_color_space,
1940 self.export_transfer_function,
1941 self.export_codec_family,
1942 self.prores_profile,
1943 self.dnxhr_profile,
1944 self.hevc_profile,
1945 self.h264_profile,
1946 self.av1_profile,
1947 self.vp9_profile,
1948 self.active_rate_control.clone(),
1949 self.export_folder.clone(),
1950 );
1951 ExportPreset::upsert(&mut self.presets, preset);
1952 ExportPreset::save_all(&self.presets);
1953 self.active_preset = Some(name.clone());
1954 self.status_message = format!("Saved preset: {}", name);
1955 }
1956
1957 pub fn apply_preset(&mut self, index: usize) {
1960 if index >= self.presets.len() {
1961 return;
1962 }
1963 let p = self.presets[index].clone();
1964 self.export_color_space = p.color_space;
1965 self.export_transfer_function = p.transfer_function;
1966 self.export_codec_family = p.codec_family;
1967 self.prores_profile = p.prores_profile;
1968 self.dnxhr_profile = p.dnxhr_profile;
1969 self.hevc_profile = p.hevc_profile;
1970 self.h264_profile = p.h264_profile;
1971 self.av1_profile = p.av1_profile;
1972 self.vp9_profile = p.vp9_profile;
1973 self.active_rate_control = p.rate_control;
1974 self.export_folder = p.export_folder;
1975 if !matches!(self.active_rate_control, RateControl::Custom(_)) {
1977 self.is_editing_custom_rate = false;
1978 }
1979 self.active_preset = Some(p.name.clone());
1980 self.status_message = format!("Applied preset: {}", p.name);
1981 }
1982
1983 pub fn delete_preset(&mut self, index: usize) {
1986 if index >= self.presets.len() {
1987 return;
1988 }
1989 let removed_name = self.presets[index].name.clone();
1990 self.presets.remove(index);
1991 ExportPreset::save_all(&self.presets);
1992 if self.active_preset.as_deref() == Some(removed_name.as_str()) {
1993 self.active_preset = None;
1994 }
1995 if !self.presets.is_empty() && self.preset_picker.index >= self.presets.len() {
1997 self.preset_picker.index = self.presets.len() - 1;
1998 }
1999 self.preset_picker.message = Some(format!("Deleted preset: {}", removed_name));
2000 self.status_message = format!("Deleted preset: {}", removed_name);
2001 }
2002
2003 pub fn open_preset_picker(&mut self) {
2006 if self.presets.is_empty() {
2007 self.status_message = "No presets yet — press [p] to save the current settings".to_string();
2008 return;
2009 }
2010 self.preset_picker.open = true;
2011 self.preset_picker.index = self.presets.len().saturating_sub(1).min(self.preset_picker.index);
2012 self.preset_picker.message = None;
2013 }
2014
2015 pub fn close_preset_picker(&mut self) {
2016 self.preset_picker.open = false;
2017 self.preset_picker.message = None;
2018 }
2019
2020 pub fn begin_naming_preset(&mut self) {
2023 let default_name = match &self.active_preset {
2024 Some(n) => format!("{} (copy)", n),
2025 None => "My Preset".to_string(),
2026 };
2027 self.preset_naming = Some(PresetNamingState { name: default_name, message: None });
2028 self.preset_picker.open = false;
2029 }
2030
2031 pub fn cancel_naming_preset(&mut self) {
2032 self.preset_naming = None;
2033 }
2034
2035 pub fn commit_naming_preset(&mut self) {
2037 let name = match self.preset_naming.as_ref() {
2038 Some(s) => s.name.clone(),
2039 None => return,
2040 };
2041 self.preset_naming = None;
2042 self.save_current_as_preset(name);
2043 }
2044
2045 pub fn current_matches_preset(&self, name: &str) -> bool {
2048 if let Some(p) = self.presets.iter().find(|p| p.name == name) {
2049 p.color_space == self.export_color_space
2050 && p.transfer_function == self.export_transfer_function
2051 && p.codec_family == self.export_codec_family
2052 && p.prores_profile == self.prores_profile
2053 && p.dnxhr_profile == self.dnxhr_profile
2054 && p.hevc_profile == self.hevc_profile
2055 && p.h264_profile == self.h264_profile
2056 && p.av1_profile == self.av1_profile
2057 && p.vp9_profile == self.vp9_profile
2058 && p.rate_control.name() == self.active_rate_control.name()
2059 && p.export_folder == self.export_folder
2060 } else {
2061 false
2062 }
2063 }
2064
2065 pub fn import_selected_from_browser(&mut self) {
2066 let paths = self.browser.selected_mcraw_paths();
2067 if paths.is_empty() {
2068 self.status_message = "No .mcraw files selected in browser".to_string();
2069 return;
2070 }
2071 let count = paths.len();
2072 let (imported, failed) = self.load_files_batch(&paths);
2073 let msg = if failed > 0 {
2074 format!("Imported {} file(s), {} failed", imported, failed)
2075 } else {
2076 format!("Imported {} file(s)", imported)
2077 };
2078 self.status_message = msg;
2079 for entry in self.browser.entries.iter_mut() {
2081 if entry.selected && entry.name.to_lowercase().ends_with(".mcraw") {
2082 entry.selected = false;
2083 }
2084 }
2085 if count > 0 {
2086 self.show_browser = false;
2087 }
2088 }
2089
2090 pub fn cancel_export(&mut self) {
2091 if let Some(ref token) = self.cancel_token {
2092 tracing::info!("export cancellation requested");
2093 token.store(true, Ordering::Relaxed);
2094 self.export_cancelled = true;
2095 self.status_message = "Cancelling export...".to_string();
2096 }
2097 }
2098
2099 pub fn poll_export(&mut self) {
2100 let rx = match self.export_rx.take() {
2101 Some(rx) => rx,
2102 None => return,
2103 };
2104 let mut keep_rx = true;
2105 while let Ok(event) = rx.try_recv() {
2106 match event {
2107 ExportEvent::Progress(pct) => {
2108 self.export_progress = pct;
2109 if let Some(q) = self.queue.iter_mut().find(|q| matches!(q.status, QueueStatus::Rendering)) {
2110 q.progress = pct;
2111 }
2112 }
2113 ExportEvent::Stats(_stats) => {
2114 }
2117 ExportEvent::Done(result) => {
2118 self.is_exporting = false;
2119 keep_rx = false;
2120 self.cancel_token = None;
2121 let elapsed = self.export_start_time
2122 .take()
2123 .map(|t| t.elapsed())
2124 .unwrap_or_default();
2125 if let Some(idx) = self.current_rendering_index {
2127 if idx < self.queue.len() {
2128 self.queue[idx].progress = 100.0;
2129 if self.export_cancelled {
2130 self.queue[idx].status = QueueStatus::Waiting;
2131 } else {
2132 match &result {
2133 Ok(()) => {
2134 self.queue[idx].status = QueueStatus::Completed;
2135 }
2136 Err(e) => {
2137 self.queue[idx].status = QueueStatus::Failed(e.to_string());
2138 }
2139 }
2140 }
2141 }
2142 }
2143 if let Some(mut summary) = self.pending_export_summary.take() {
2147 summary.elapsed = elapsed;
2148 summary.result = if self.export_cancelled {
2149 Err("Cancelled by user".to_string())
2150 } else {
2151 match &result {
2152 Ok(()) => Ok(()),
2153 Err(e) => Err(e.to_string()),
2154 }
2155 };
2156 self.last_export_summary = Some(summary);
2157 }
2158 if self.export_cancelled {
2159 self.status_message = "Export cancelled".to_string();
2160 self.export_cancelled = false;
2161 self.current_rendering_index = None;
2162 } else {
2163 let mins = elapsed.as_secs() / 60;
2164 let secs = elapsed.as_secs() % 60;
2165 match result {
2166 Ok(()) => {
2167 tracing::info!("export completed in {:02}m {:02}s", mins, secs);
2168 self.status_message = format!(
2169 "Video export completed ({:02}m {:02}s)", mins, secs
2170 );
2171 self.shockwave_ticks_remaining = 30;
2172 }
2173 Err(e) => {
2174 tracing::error!("export failed: {}", e);
2175 self.status_message = format!("Export failed: {}", e);
2176 }
2177 }
2178 self.start_next_queued_render();
2180 }
2181 self.export_start_time = None;
2182 }
2183 }
2184 }
2185 if keep_rx {
2186 self.export_rx = Some(rx);
2187 }
2188 }
2189
2190 pub fn add_encode_job(&mut self, format: OutputFormat) {
2191 let job = EncodeJob::new(uuid::Uuid::new_v4().to_string()[..8].to_string(), format);
2192 self.encode_jobs.push(job);
2193 self.status_message = "Export job added".to_string();
2194 }
2195
2196 pub fn select_file(&mut self) {
2201 let entry_data = self.browser.selected_entry().map(|e| (e.is_dir, e.name.clone(), e.path.clone()));
2202 if let Some((is_dir, name, path)) = entry_data {
2203 if is_dir {
2204 self.browser.enter();
2205 self.status_message = format!("Entered: {}", name);
2206 self.show_favourites_bar = false;
2207 } else if name.ends_with(".mcraw") {
2208 let path_str = path.to_string_lossy().to_string();
2209 self.load_file(path_str.clone());
2210 self.show_browser = false;
2211
2212 if let Some(ref info) = self.file_info {
2214 if !self.imported_files.iter().any(|f| f.path == path_str) {
2215 self.imported_files.push(ImportedFile {
2216 path: path_str.clone(),
2217 info: info.clone(),
2218 selected: true,
2219 first_timestamp: self.timestamps.first().copied().unwrap_or(0),
2220 });
2221 }
2222 }
2223
2224 if let Some(idx) = self.imported_files.iter().position(|f| f.path == path_str) {
2226 self.media_pool_index = idx;
2227 }
2228 self.last_written_media_index.set(None);
2229 self.sixel_pending.set(false);
2230 self.sixel_write_pos.set(None);
2231 self.sixel_occupy_size.set(None);
2232 if self.decoder.is_some() && !self.timestamps.is_empty() {
2233 self.request_frame_decode(self.frame_index.min(self.timestamps.len() - 1));
2234 }
2235 } else {
2236 self.status_message = format!("Cannot open: {} (not a .mcraw file)", name);
2237 }
2238 }
2239 }
2240
2241 pub fn scan_mcraw_files_in_folder(&self, folder: &str) -> Vec<String> {
2243 if let Ok(entries) = std::fs::read_dir(folder) {
2244 let mut files: Vec<String> = entries
2245 .filter_map(|e| e.ok())
2246 .map(|e| e.path())
2247 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2248 .map(|p| p.to_string_lossy().to_string())
2249 .collect();
2250 files.sort();
2251 files
2252 } else {
2253 Vec::new()
2254 }
2255 }
2256
2257 pub fn navigate_browser(&mut self, direction: BrowserDirection) {
2258 match direction {
2259 BrowserDirection::Up => {
2260 self.browser.navigate_up();
2261 }
2262 BrowserDirection::Down => {
2263 self.browser.navigate_down();
2264 }
2265 BrowserDirection::Enter => self.select_file(),
2266 BrowserDirection::GoUp => {
2267 self.browser.go_up();
2268 self.show_favourites_bar = false;
2269 }
2270 BrowserDirection::ToggleHidden => self.browser.toggle_hidden(),
2271 }
2272 }
2273
2274 pub fn navigate_favourites(&mut self, delta: i64) {
2276 if self.favourite_folders.is_empty() {
2277 return;
2278 }
2279 let cur = self.favourites_scroll_offset.get() as i64;
2280 let max = (self.favourite_folders.len() as i64) - 1;
2281 let next = (cur + delta).clamp(0, max);
2282 self.favourites_scroll_offset.set(next as usize);
2283 }
2284
2285 pub fn open_selected_favourite(&mut self) {
2287 let idx = self.favourites_scroll_offset.get();
2288 if let Some(path) = self.favourite_folders.get(idx).cloned() {
2289 self.status_message = format!("Navigated to favourite: {}", path.display());
2290 self.browser = FileBrowser::from_path(path);
2291 self.browser_scroll_offset = Cell::new(0);
2292 self.browsing_favourites = false;
2293 self.show_favourites_bar = false;
2294 }
2295 }
2296
2297 pub fn delete_selected_favourite(&mut self) {
2299 let idx = self.favourites_scroll_offset.get();
2300 if idx < self.favourite_folders.len() {
2301 let name = self.favourite_folders[idx].display().to_string();
2302 self.favourite_folders.remove(idx);
2303 self.save_favourites();
2304 if self.favourite_folders.is_empty() {
2305 self.browsing_favourites = false;
2306 } else if self.favourites_scroll_offset.get() >= self.favourite_folders.len() {
2307 self.favourites_scroll_offset.set(self.favourite_folders.len() - 1);
2308 }
2309 self.status_message = format!("Removed favourite: {}", name);
2310 }
2311 }
2312
2313 pub fn cycle_focus(&mut self) {
2318 self.focus_target = match self.focus_target {
2319 FocusTarget::MediaPool => FocusTarget::Grade,
2320 FocusTarget::Grade => FocusTarget::ExportSettings,
2321 FocusTarget::ExportSettings => FocusTarget::Queue,
2322 FocusTarget::Queue => FocusTarget::MediaPool,
2323 };
2324 let label = match self.focus_target {
2325 FocusTarget::MediaPool => "Media Pool",
2326 FocusTarget::Grade => "Grade",
2327 FocusTarget::ExportSettings => "Export Settings",
2328 FocusTarget::Queue => "Render Queue",
2329 };
2330 self.status_message = format!("Focus: {}", label);
2331 }
2332
2333 pub fn set_focus(&mut self, target: FocusTarget) {
2334 self.focus_target = target;
2335 let label = match target {
2336 FocusTarget::MediaPool => "Media Pool",
2337 FocusTarget::Grade => "Grade",
2338 FocusTarget::ExportSettings => "Export Settings",
2339 FocusTarget::Queue => "Render Queue",
2340 };
2341 self.status_message = format!("Focus: {}", label);
2342 }
2343
2344}
2345
2346fn execute_click_action(app: &mut App, action: ClickAction) {
2347 match action {
2348 ClickAction::ToggleBrowser => {
2349 app.show_browser = !app.show_browser;
2350 app.status_message = if app.show_browser { "Browser shown" } else { "Browser hidden" }.to_string();
2351 }
2352 ClickAction::ToggleFileSelection(i) => {
2353 if let Some(f) = app.imported_files.get_mut(i) {
2354 f.selected = !f.selected;
2355 }
2356 }
2357 ClickAction::ToggleQueueSelection(i) => {
2358 if let Some(q) = app.queue.get_mut(i) {
2359 q.selected = !q.selected;
2360 }
2361 }
2362 ClickAction::SelectMediaPoolItem(i) => {
2363 if i < app.imported_files.len() {
2364 app.switch_media_pool_item(i);
2365 }
2366 }
2367 ClickAction::SelectQueueItem(i) => {
2368 if i < app.queue.len() {
2369 app.queue_index = i;
2370 app.set_focus(FocusTarget::Queue);
2371 }
2372 }
2373 ClickAction::FocusMediaPool => {
2374 app.set_focus(FocusTarget::MediaPool);
2375 }
2376 ClickAction::FocusQueue => {
2377 app.set_focus(FocusTarget::Queue);
2378 }
2379 ClickAction::FocusExport => {
2380 app.set_focus(FocusTarget::ExportSettings);
2381 }
2382 ClickAction::FocusGrade => {
2383 app.show_grade_screen = !app.show_grade_screen;
2384 if app.show_grade_screen {
2385 app.set_focus(FocusTarget::Grade);
2386 app.status_message = "Grade screen — Esc to exit".to_string();
2387 } else {
2388 app.grade_dragging = None;
2389 app.set_focus(FocusTarget::MediaPool);
2390 app.status_message = "Normal view".to_string();
2391 }
2392 }
2393 ClickAction::AddSelectedToQueue => app.add_selected_to_queue(),
2394 ClickAction::AddAllToQueue => app.add_all_to_queue(),
2395 ClickAction::RemoveSelectedFromMediaPool => app.remove_selected_from_media_pool(),
2396 ClickAction::ToggleSelectAll => app.toggle_select_all(),
2397 ClickAction::ToggleBrowserSelection(i) => {
2398 if let Some(entry) = app.browser.entries.get_mut(i) {
2399 if entry.name.to_lowercase().ends_with(".mcraw") {
2400 entry.selected = !entry.selected;
2401 }
2402 }
2403 }
2404 ClickAction::RenderSelected => app.render_selected(),
2405 ClickAction::RenderAll => app.render_all(),
2406 ClickAction::ClearQueue => app.clear_completed_queue(),
2407 ClickAction::CycleCodec => {
2408 app.set_focus(FocusTarget::ExportSettings);
2409 app.cycle_codec(true);
2410 }
2411 ClickAction::CycleGamut => {
2412 app.set_focus(FocusTarget::ExportSettings);
2413 app.export_focus = ExportFocus::ColorSpace;
2414 app.export_color_space = app.export_color_space.next();
2415 app.status_message = format!("Gamut: {}", app.export_color_space.name());
2416 }
2417 ClickAction::CycleTransfer => {
2418 app.set_focus(FocusTarget::ExportSettings);
2419 app.export_focus = ExportFocus::TransferFunction;
2420 app.export_transfer_function = app.export_transfer_function.next();
2421 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
2422 }
2423 ClickAction::CycleProfile => {
2424 app.set_focus(FocusTarget::ExportSettings);
2425 app.cycle_profile(true);
2426 }
2427 ClickAction::CycleRate => {
2428 app.set_focus(FocusTarget::ExportSettings);
2429 app.export_focus = ExportFocus::RateControl;
2430 app.cycle_rate_control();
2431 }
2432 ClickAction::CycleFps => {
2433 app.set_focus(FocusTarget::ExportSettings);
2434 app.cycle_export_fps();
2435 }
2436 ClickAction::ImportOption1 => {
2437 if app.import_popup != ImportPopupState::Hidden {
2438 if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
2439 let files = files.clone();
2440 if !files.is_empty() {
2441 let count = files.len();
2442 app.status_message = format!("Importing {} file(s)...", count);
2443 let (imported, failed) = app.load_files_batch(&files);
2444 if failed > 0 {
2445 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2446 } else {
2447 app.status_message = format!("Imported {} file(s)", imported);
2448 }
2449 }
2450 app.import_popup = ImportPopupState::Hidden;
2451 app.show_browser = false;
2452 }
2453 } else if app.show_browser {
2454 app.import_selected_from_browser();
2455 }
2456 }
2457 ClickAction::ImportOption2 => {
2458 if app.import_popup != ImportPopupState::Hidden {
2459 if let ImportPopupState::DroppedFiles { all_in_folder, .. } = &app.import_popup {
2460 let all_in_folder = all_in_folder.clone();
2461 if !all_in_folder.is_empty() {
2462 let count = all_in_folder.len();
2463 app.status_message = format!("Importing all {} file(s) from folder...", count);
2464 let (imported, failed) = app.load_files_batch(&all_in_folder);
2465 if failed > 0 {
2466 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2467 } else {
2468 app.status_message = format!("Imported all {} file(s)", imported);
2469 }
2470 }
2471 app.import_popup = ImportPopupState::Hidden;
2472 app.show_browser = false;
2473 }
2474 } else if app.show_browser {
2475 let folder = app.browser.current_path.clone();
2476 app.load_all_in_folder(&folder);
2477 app.show_browser = false;
2478 }
2479 }
2480 ClickAction::ClosePopup => { app.import_popup = ImportPopupState::Hidden; }
2481 ClickAction::ToggleHelp => { app.show_help = !app.show_help; }
2482 ClickAction::BrowserNavigate(i) => {
2483 let now = Instant::now();
2484 let was_same = app.last_browser_click.as_ref().map(|&(_, idx)| idx == i).unwrap_or(false);
2485 let is_double = app.last_browser_click.as_ref().map(|&(t, _)| now.duration_since(t).as_millis() < 400).unwrap_or(false);
2486
2487 app.browser.selected_index = i;
2488
2489 if was_same && is_double {
2490 app.select_file();
2491 app.last_browser_click = None;
2492 } else {
2493 app.last_browser_click = Some((now, i));
2494 }
2495 }
2496 ClickAction::BrowserSelectAndEnter(i) => {
2497 let now = Instant::now();
2498 let was_same = app.last_browser_click.as_ref().map(|&(_, idx)| idx == i).unwrap_or(false);
2499 let is_double = app.last_browser_click.as_ref().map(|&(t, _)| now.duration_since(t).as_millis() < 400).unwrap_or(false);
2500
2501 app.browser.selected_index = i;
2502
2503 if was_same && is_double {
2504 app.select_file();
2505 app.last_browser_click = None;
2506 } else {
2507 app.last_browser_click = Some((now, i));
2508 }
2509 }
2510 ClickAction::BrowserEnter => {
2511 app.navigate_browser(BrowserDirection::Enter);
2512 }
2513 ClickAction::BrowserGoUp => {
2514 app.navigate_browser(BrowserDirection::GoUp);
2515 }
2516 ClickAction::FavouriteNavigate(i) => {
2517 if i < app.favourite_folders.len() {
2518 let path = app.favourite_folders[i].clone();
2519 app.browser = FileBrowser::from_path(path);
2520 app.browser_scroll_offset = Cell::new(0);
2521 app.show_favourites_bar = false;
2522 app.last_clicked_favourite = Some((Instant::now(), i));
2523 app.status_message = "Navigated to favourite folder".to_string();
2524 }
2525 }
2526 ClickAction::OpenPresetPicker => {
2527 app.open_preset_picker();
2528 }
2529 ClickAction::GradeSlider(i) => {
2530 app.grade_focus = i;
2531 app.set_focus(FocusTarget::Grade);
2532 }
2533 }
2534}
2535
2536pub enum BrowserDirection {
2537 Up,
2538 Down,
2539 Enter,
2540 GoUp,
2541 ToggleHidden,
2542}
2543
2544pub async fn run(args: Cli) -> Result<()> {
2545 let placeholder_path = args.placeholder_path.clone()
2547 .or_else(|| std::env::var("MCRAW_TUI_PLACEHOLDER").ok())
2548 .map(std::path::PathBuf::from);
2549 if let Some(ref p) = placeholder_path {
2550 tracing::info!("custom placeholder: {}", p.display());
2551 }
2552
2553 let mut app = App::new_with_placeholder(placeholder_path);
2554 tracing::info!("app initialized: hardware_caps={:?}", app.hardware_caps);
2555
2556 match args.resolve() {
2557 ResolvedCli::Command(CliCommands::Open { file }) => {
2558 if let Some(path) = file {
2559 app.load_file(path);
2560 }
2561 }
2562 ResolvedCli::Command(CliCommands::Info { file }) => {
2563 let path = match file {
2564 Some(p) => p,
2565 None => return Err(anyhow::anyhow!("No file specified")),
2566 };
2567 match McrawFileInfo::from_path(&path) {
2568 Ok(mut info) => {
2569 info.enhance_with_decoder();
2570 return Ok(());
2571 }
2572 Err(e) => return Err(e),
2573 }
2574 }
2575 ResolvedCli::Command(CliCommands::Export { file, format, output }) => {
2576 if file.is_none() {
2577 return Err(anyhow::anyhow!("No file specified"));
2578 }
2579 if let Err(e) = Cli::validate_export_format(&format) {
2580 anyhow::bail!("{}", e);
2581 }
2582 let format = match format.to_lowercase().as_str() {
2583 "dng" => OutputFormat::DNG { output_path: std::path::PathBuf::from(&output) },
2584 "prores" => OutputFormat::ProRes { output_path: std::path::PathBuf::from(&output) },
2585 "h264" => OutputFormat::H264 { output_path: std::path::PathBuf::from(&output) },
2586 "hevc" => OutputFormat::HEVC { output_path: std::path::PathBuf::from(&output) },
2587 _ => anyhow::bail!("Invalid format: {}", format),
2588 };
2589
2590 let encoder = Encoder::new();
2591 let mut job = EncodeJob::new("cli-export".to_string(), format.clone());
2592 job.status = EncodeStatus::Running;
2593
2594 match encoder.start_job(job.clone()).await {
2595 Ok(()) => { job.status = EncodeStatus::Completed; }
2596 Err(e) => { job.status = EncodeStatus::Failed(e.to_string()); }
2597 }
2598 return Ok(());
2599 }
2600 ResolvedCli::NoFile => {
2601 app.status_message = "No file specified. Use: mcraw-tui -f <path>".to_string();
2602 }
2603 }
2604
2605 let stdout = std::io::stdout();
2606 let backend = CrosstermBackend::new(stdout);
2607 let mut terminal = ratatui::Terminal::new(backend)?;
2608 terminal.clear()?;
2609 crossterm::execute!(
2610 std::io::stdout(),
2611 EnterAlternateScreen,
2612 EnableBracketedPaste,
2613 EnableMouseCapture,
2614 )?;
2615 terminal.hide_cursor()?;
2616
2617 enable_raw_mode()?;
2618 tracing::info!("terminal initialized: alternate_screen, bracketed_paste, mouse_capture enabled");
2619
2620 let event_loop_running = Arc::new(AtomicBool::new(true));
2621 let elr = event_loop_running.clone();
2622
2623 let (tx, rx) = mpsc::channel();
2624 tokio::spawn(async move {
2625 event_loop(tx, elr).await;
2626 });
2627
2628 let encoder = Encoder::new();
2629 tracing::info!("entering main event loop");
2630
2631 while app.running {
2632 if let Ok(ws) = window_size() {
2634 if ws.columns > 0 && ws.rows > 0 {
2635 let cell_w = if ws.width > 0 {
2636 ws.width as f32 / ws.columns as f32
2637 } else {
2638 8.0
2639 };
2640 let cell_h = if ws.height > 0 {
2641 ws.height as f32 / ws.rows as f32
2642 } else {
2643 16.0
2644 };
2645 app.term_cell_size.set((cell_w, cell_h));
2646 }
2647 }
2648
2649 app.poll_export();
2650 app.poll_drop_import();
2651 let on_normal_main = !app.show_grade_screen
2656 && !app.show_culling
2657 && !app.imported_files.is_empty();
2658 if on_normal_main {
2659 app.poll_thumbnail();
2660 }
2661 app.browser.try_refresh();
2662
2663 app.fps_counter.tick();
2667
2668 let mut click_regions = Vec::new();
2669 terminal.draw(|frame| ui::render(frame, &app, &mut click_regions))?;
2670
2671 app.spinner_frame = app.spinner_frame.wrapping_add(1);
2673 if app.spinner_frame % 4 == 0 {
2675 app.progress_anim_offset = app.progress_anim_offset.wrapping_add(1);
2676 }
2677 if app.shockwave_ticks_remaining > 0 {
2678 app.shockwave_ticks_remaining -= 1;
2679 }
2680 if let Some((_, ref mut t)) = app.grade_morph {
2682 *t = t.saturating_sub(1);
2683 if *t == 0 { app.grade_morph = None; }
2684 }
2685 app.phosphor_trail.iter_mut().for_each(|(_, t)| *t = t.saturating_sub(1));
2687 app.phosphor_trail.retain(|(_, t)| *t > 0);
2688 if app.grade_strip_idle_ticks > 0 {
2690 app.grade_strip_idle_ticks = app.grade_strip_idle_ticks.saturating_sub(1);
2691 } else if app.show_grade_screen {
2692 app.grade_strip_active = false;
2693 }
2694
2695 let mut last_key = None::<crossterm::event::KeyEvent>;
2702 while let Ok(event) = rx.try_recv() {
2703 if let Event::Key(ref ke) = event {
2704 if last_key.as_ref().map_or(false, |last| {
2705 last.code == ke.code && last.modifiers == ke.modifiers && last.kind == ke.kind
2706 }) {
2707 continue;
2708 }
2709 last_key = Some(*ke);
2710 }
2711 handle_event(&mut app, event, &encoder, &click_regions).await;
2712 }
2713
2714 let current_idx = app.media_pool_index;
2720 let file_changed = app.last_written_media_index.get() != Some(current_idx);
2721
2722 if file_changed {
2724 if let Some((lx, ly, lw, lh)) = app.sixel_occupy_size.get() {
2725 let clear_line: Vec<u8> = vec![b' '; lw as usize];
2726 for row in ly..(ly + lh).min(9999) {
2727 let _ = std::io::stdout()
2728 .queue(MoveTo(lx, row))
2729 .and_then(|out| out.write_all(&clear_line));
2730 }
2731 app.sixel_occupy_size.set(None);
2732 }
2733 }
2734
2735 if app.sixel_pending.get()
2736 && file_changed
2737 && !app.is_exporting
2738 && (app.last_export_summary.is_none() || app.focused_file_info().or(app.file_info.as_ref()).is_some())
2739 && !app.show_grade_screen
2740 && !app.show_culling {
2741 if let Some((x, y)) = app.sixel_write_pos.get() {
2742 if let PreviewState::Ready { ref sixel, width, height } = app.preview_state {
2743 let mut out = std::io::stdout();
2744 if crate::terminal::protocol() == crate::terminal::TerminalProtocol::Kitty {
2745 if let Some((px, py, pw, ph)) = app.sixel_panel_rect.get() {
2753 let (cell_w, cell_h) = app.term_cell_size.get();
2754 let cell_aspect = cell_w / cell_h;
2755 let img_aspect_px = width as f32 / height as f32;
2756 let panel_px_aspect = (pw as f32 * cell_w) / (ph as f32 * cell_h);
2757
2758 let (fit_w, fit_h) = if img_aspect_px > panel_px_aspect {
2759 (pw, (pw as f32 * cell_aspect / img_aspect_px).round().max(1.0) as u16)
2761 } else {
2762 ((ph as f32 * img_aspect_px / cell_aspect).round().max(1.0) as u16, ph)
2764 };
2765
2766 let off_x = (pw - fit_w) / 2;
2767 let off_y = (ph - fit_h) / 2;
2768 let place_x = (px as i32 + off_x as i32).max(0) as u16;
2769 let place_y = (py as i32 + off_y as i32).max(0) as u16;
2770
2771 let _ = out.queue(MoveTo(place_x, place_y));
2772 let _ = out.write_all(sixel);
2773 let place = format!("\x1b_Ga=p,i=0,c={fit_w},r={fit_h},m=0\x1b\\");
2774 let _ = out.write_all(place.as_bytes());
2775 }
2776 } else {
2777 let _ = out.queue(MoveTo(x, y));
2780 let _ = out.write_all(sixel);
2781 }
2782 }
2783 }
2784 app.sixel_pending.set(false);
2785 app.last_written_media_index.set(Some(current_idx));
2786 }
2787
2788 time::sleep(Duration::from_millis(16)).await;
2789 }
2790
2791 event_loop_running.store(false, Ordering::Relaxed);
2792 drop(rx);
2793 tokio::task::yield_now().await;
2794
2795 disable_raw_mode()?;
2796 terminal.show_cursor()?;
2797 crossterm::execute!(
2798 std::io::stdout(),
2799 DisableMouseCapture,
2800 DisableBracketedPaste,
2801 LeaveAlternateScreen,
2802 )?;
2803 tracing::info!("terminal shutdown: raw_mode disabled, screen restored");
2804
2805 Ok(())
2806}
2807
2808async fn event_loop(tx: mpsc::Sender<Event>, running: Arc<AtomicBool>) {
2809 tracing::debug!("event_loop started");
2810 while running.load(Ordering::Relaxed) {
2811 if crossterm::event::poll(Duration::from_millis(8)).unwrap() {
2812 if let Ok(event) = crossterm::event::read() {
2813 if tx.send(event).is_err() {
2814 break;
2815 }
2816 }
2817 }
2818 }
2819}
2820
2821fn strip_surrounding_quotes(s: &str) -> String {
2827 let s = s.trim();
2828 if s.len() >= 2 {
2829 let first = s.chars().next().unwrap();
2830 let last = s.chars().last().unwrap();
2831 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
2832 return s[1..s.len() - 1].to_string();
2833 }
2834 }
2835 s.to_string()
2836}
2837
2838fn expand_tilde(s: &str) -> String {
2840 if s == "~" {
2841 if let Some(home) = dirs::home_dir() {
2842 return home.to_string_lossy().to_string();
2843 }
2844 }
2845 if let Some(rest) = s.strip_prefix("~/") {
2846 if let Some(home) = dirs::home_dir() {
2847 return home.join(rest).to_string_lossy().to_string();
2848 }
2849 }
2850 s.to_string()
2851}
2852
2853fn decode_file_uri(s: &str) -> String {
2856 if let Some(rest) = s.strip_prefix("file:///") {
2857 if cfg!(windows) && rest.len() >= 2 {
2859 let chars: Vec<char> = rest.chars().collect();
2860 if chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':' {
2861 return rest.to_string();
2862 }
2863 }
2864 return format!("/{}", rest);
2866 }
2867 if let Some(rest) = s.strip_prefix("file://") {
2868 if let Some(slash_pos) = rest.find('/') {
2870 return rest[slash_pos..].to_string();
2871 }
2872 return rest.to_string();
2873 }
2874 s.to_string()
2875}
2876
2877fn percent_decode_path(s: &str) -> String {
2879 if !s.contains('%') {
2880 return s.to_string();
2881 }
2882 match percent_decode_str(s).decode_utf8() {
2883 Ok(decoded) => decoded.into_owned(),
2884 Err(_) => s.to_string(), }
2886}
2887
2888fn normalize_path(s: &str) -> String {
2890 if cfg!(windows) {
2891 if s.starts_with("\\\\") {
2893 return s.to_string();
2894 }
2895 s.replace('/', "\\")
2897 } else {
2898 s.to_string()
2899 }
2900}
2901
2902fn validate_path(s: &str) -> Option<String> {
2904 let path = std::path::Path::new(s);
2905
2906 if !path.exists() {
2908 tracing::debug!("path validation: does not exist: {}", s);
2909 return None;
2910 }
2911
2912 match path.canonicalize() {
2915 Ok(canonical) => Some(canonical.to_string_lossy().to_string()),
2916 Err(_) => {
2917 tracing::debug!("path validation: canonicalize failed, using original: {}", s);
2918 Some(s.to_string())
2919 }
2920 }
2921}
2922
2923async fn handle_event(app: &mut App, event: Event, _encoder: &Encoder, click_regions: &[ui::ClickRegion]) {
2924 match event {
2925 Event::Paste(pasted) => {
2929 tracing::trace!("drag-drop: raw pasted bytes={:?} len={}", pasted.as_bytes(), pasted.len());
2930
2931 let paths: Vec<String> = pasted
2932 .lines()
2933 .filter_map(|line| {
2934 let line = line.trim();
2935 if line.is_empty() {
2936 return None;
2937 }
2938
2939 let stripped = strip_surrounding_quotes(line);
2941
2942 let expanded = expand_tilde(&stripped);
2944
2945 let decoded = decode_file_uri(&expanded);
2947
2948 let percent_decoded = percent_decode_path(&decoded);
2950
2951 let normalized = normalize_path(&percent_decoded);
2953
2954 validate_path(&normalized)
2956 })
2957 .collect();
2958
2959 tracing::trace!("drag-drop: parsed {} paths: {:?}", paths.len(), paths);
2960
2961 if paths.is_empty() {
2962 app.status_message = "Drag-drop: no valid paths received".to_string();
2963 return;
2964 }
2965
2966 let mut mcraw_files: Vec<String> = Vec::new();
2968 let mut folders: Vec<String> = Vec::new();
2969
2970 for p in &paths {
2971 let path = std::path::Path::new(p);
2972 if path.is_dir() {
2973 folders.push(p.clone());
2974 } else if p.to_lowercase().ends_with(".mcraw") {
2975 mcraw_files.push(p.clone());
2976 }
2977 }
2978
2979 for folder in &folders {
2981 if let Ok(entries) = std::fs::read_dir(folder) {
2982 let mut files: Vec<String> = entries
2983 .filter_map(|e| e.ok())
2984 .map(|e| e.path())
2985 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2986 .map(|p| p.to_string_lossy().to_string())
2987 .collect();
2988 files.sort();
2989 mcraw_files.extend(files);
2990 }
2991 }
2992
2993 let mut seen = std::collections::HashSet::new();
2995 mcraw_files.retain(|f| seen.insert(f.clone()));
2996
2997 tracing::info!("drag-drop: {} .mcraw files, {} folders", mcraw_files.len(), folders.len());
2998
2999 if mcraw_files.is_empty() {
3000 app.status_message = "Drag-drop: no .mcraw files found in dropped items".to_string();
3001 return;
3002 }
3003
3004 app.drop_highlight = Some(Instant::now());
3006
3007 const ASYNC_THRESHOLD: usize = 3;
3010
3011 if mcraw_files.len() <= ASYNC_THRESHOLD && folders.is_empty() {
3012 app.start_async_import(mcraw_files);
3014 } else {
3015 if mcraw_files.len() == 1 {
3018 let file = &mcraw_files[0];
3019 let folder = std::path::Path::new(file)
3020 .parent()
3021 .map(|p| p.to_string_lossy().to_string())
3022 .unwrap_or_else(|| ".".to_string());
3023
3024 let all_in_folder: Vec<String> = if let Ok(entries) = std::fs::read_dir(&folder) {
3025 let mut files: Vec<String> = entries
3026 .filter_map(|e| e.ok())
3027 .map(|e| e.path())
3028 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
3029 .map(|p| p.to_string_lossy().to_string())
3030 .collect();
3031 files.sort();
3032 files
3033 } else {
3034 Vec::new()
3035 };
3036
3037 if all_in_folder.len() == 1 {
3039 app.start_async_import(mcraw_files);
3040 return;
3041 }
3042 }
3043
3044 let folder = if !folders.is_empty() {
3046 folders[0].clone()
3047 } else {
3048 std::path::Path::new(&mcraw_files[0])
3049 .parent()
3050 .map(|p| p.to_string_lossy().to_string())
3051 .unwrap_or_else(|| ".".to_string())
3052 };
3053
3054 let all_in_folder: Vec<String> = if let Ok(entries) = std::fs::read_dir(&folder) {
3056 let mut files: Vec<String> = entries
3057 .filter_map(|e| e.ok())
3058 .map(|e| e.path())
3059 .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
3060 .map(|p| p.to_string_lossy().to_string())
3061 .collect();
3062 files.sort();
3063 files
3064 } else {
3065 Vec::new()
3066 };
3067
3068 app.import_popup = ImportPopupState::DroppedFiles {
3070 files: mcraw_files,
3071 folder,
3072 all_in_folder,
3073 };
3074 }
3075 }
3076
3077 crossterm::event::Event::Resize(_, _) => {
3081 app.preview_state = PreviewState::Empty;
3082 if app.decoder.is_some() && !app.timestamps.is_empty() {
3083 app.request_frame_decode(app.frame_index.min(app.timestamps.len() - 1));
3084 }
3085 }
3086
3087 Event::Mouse(mouse_event) => {
3091 use crossterm::event::{MouseEventKind, MouseButton};
3092
3093 if app.import_popup != ImportPopupState::Hidden {
3095 let col = mouse_event.column;
3096 let row = mouse_event.row;
3097 match mouse_event.kind {
3098 MouseEventKind::Down(MouseButton::Left) => {
3099 for region in click_regions.iter().rev() {
3100 if col >= region.area.x && col < region.area.x + region.area.width
3101 && row >= region.area.y && row < region.area.y + region.area.height {
3102 match ®ion.action {
3103 ClickAction::ImportOption1 | ClickAction::ImportOption2 => {
3104 execute_click_action(app, region.action.clone());
3105 }
3106 _ => {}
3107 }
3108 break;
3109 }
3110 }
3111 }
3112 _ => {}
3113 }
3114 return;
3115 }
3116
3117 if app.show_full_info {
3119 return;
3120 }
3121
3122 match mouse_event.kind {
3123 MouseEventKind::ScrollUp => {
3124 if app.show_help {
3125 app.help_scroll = app.help_scroll.saturating_sub(1);
3126 } else if app.show_browser {
3127 if app.browsing_favourites {
3128 app.navigate_favourites(-1);
3129 } else if app.browser.selected_index > 0 {
3130 app.browser.selected_index -= 1;
3131 }
3132 } else {
3133 match app.focus_target {
3134 FocusTarget::MediaPool => { if app.media_pool_index > 0 { let ni = app.media_pool_index - 1; app.switch_media_pool_item(ni); } }
3135 FocusTarget::Queue => { if app.queue_index > 0 { app.queue_index -= 1; } }
3136 FocusTarget::ExportSettings => {
3137 match app.export_focus {
3139 ExportFocus::CodecFamily => app.cycle_codec(false),
3140 ExportFocus::ColorSpace => {
3141 app.export_color_space = app.export_color_space.prev();
3142 app.status_message = format!("Gamut: {}", app.export_color_space.name());
3143 }
3144 ExportFocus::TransferFunction => {
3145 app.export_transfer_function = app.export_transfer_function.prev();
3146 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
3147 }
3148 ExportFocus::Profile => app.cycle_profile(false),
3149 ExportFocus::RateControl => {
3150 app.active_rate_control = app.active_rate_control.prev();
3151 app.status_message = format!("Rate: {}", app.active_rate_control.name());
3152 }
3153 ExportFocus::Fps => app.cycle_export_fps(),
3154 }
3155 }
3156 FocusTarget::Grade => {
3157 let step = if mouse_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3158 GradeSliders::step_large(app.grade_focus)
3159 } else {
3160 GradeSliders::step_small(app.grade_focus)
3161 };
3162 app.grade_sliders.apply_delta(app.grade_focus, step);
3163 }
3164 }
3165 }
3166 }
3167 MouseEventKind::ScrollDown => {
3168 if app.show_help {
3169 app.help_scroll = app.help_scroll.saturating_add(1);
3170 } else if app.show_browser {
3171 if app.browsing_favourites {
3172 app.navigate_favourites(1);
3173 } else {
3174 let len = app.browser.entries.len();
3175 if len > 0 { app.browser.selected_index = (app.browser.selected_index + 1).min(len - 1); }
3176 }
3177 } else {
3178 match app.focus_target {
3179 FocusTarget::MediaPool => {
3180 let ni = (app.media_pool_index + 1).min(app.imported_files.len().saturating_sub(1));
3181 if ni != app.media_pool_index { app.switch_media_pool_item(ni); }
3182 }
3183 FocusTarget::Queue => {
3184 let len = app.queue.len();
3185 if len > 0 { app.queue_index = (app.queue_index + 1).min(len - 1); }
3186 }
3187 FocusTarget::ExportSettings => {
3188 match app.export_focus {
3189 ExportFocus::CodecFamily => app.cycle_codec(true),
3190 ExportFocus::ColorSpace => {
3191 app.export_color_space = app.export_color_space.next();
3192 app.status_message = format!("Gamut: {}", app.export_color_space.name());
3193 }
3194 ExportFocus::TransferFunction => {
3195 app.export_transfer_function = app.export_transfer_function.next();
3196 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
3197 }
3198 ExportFocus::Profile => app.cycle_profile(true),
3199 ExportFocus::RateControl => app.cycle_rate_control(),
3200 ExportFocus::Fps => app.cycle_export_fps(),
3201 }
3202 }
3203 FocusTarget::Grade => {
3204 let step = if mouse_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3205 GradeSliders::step_large(app.grade_focus)
3206 } else {
3207 GradeSliders::step_small(app.grade_focus)
3208 };
3209 app.grade_sliders.apply_delta(app.grade_focus, -step);
3210 }
3211 }
3212 }
3213 }
3214 MouseEventKind::Down(MouseButton::Left) => {
3215 let col = mouse_event.column;
3216 let row = mouse_event.row;
3217 for region in click_regions.iter().rev() {
3218 if col >= region.area.x && col < region.area.x + region.area.width
3219 && row >= region.area.y && row < region.area.y + region.area.height {
3220 match ®ion.action {
3221 ClickAction::GradeSlider(i) => {
3222 let now = Instant::now();
3223 let is_double = app.last_grade_click.as_ref()
3224 .map(|&(t, idx)| idx == *i && now.duration_since(t).as_millis() < 400)
3225 .unwrap_or(false);
3226 if is_double {
3227 let def = GradeSliders::default_val(*i);
3229 app.grade_sliders.set(*i, def);
3230 app.last_grade_click = None;
3231 app.status_message = format!("Reset {} to default", GradeSliders::name(*i));
3232 } else {
3233 let x_offset = col.saturating_sub(region.area.x);
3235 let norm = (x_offset as f32 / region.area.width.max(1) as f32).clamp(0.0, 1.0);
3236 let lo = GradeSliders::min(*i);
3237 let hi = GradeSliders::max(*i);
3238 app.grade_sliders.set(*i, lo + norm * (hi - lo));
3239 app.grade_focus = *i;
3240 app.grade_dragging = Some((*i, region.area.x, region.area.width));
3241 app.last_grade_click = Some((now, *i));
3242 }
3243 }
3244 _ => execute_click_action(app, region.action.clone()),
3245 }
3246 break;
3247 }
3248 }
3249 }
3250 MouseEventKind::Drag(MouseButton::Left) => {
3251 if let Some((i, track_x, track_w)) = app.grade_dragging {
3252 let col = mouse_event.column;
3253 let x_offset = col.saturating_sub(track_x);
3254 let norm = (x_offset as f32 / track_w.max(1) as f32).clamp(0.0, 1.0);
3255 let lo = GradeSliders::min(i);
3256 let hi = GradeSliders::max(i);
3257 app.grade_sliders.set(i, lo + norm * (hi - lo));
3258 }
3259 }
3260 MouseEventKind::Up(MouseButton::Left) => {
3261 app.grade_dragging = None;
3262 }
3263 _ => {}
3264 }
3265 }
3266
3267 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
3271 if let crossterm::event::KeyCode::Char('c') = key_event.code {
3272 if key_event.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
3273 tracing::info!("ctrl+c received, quitting");
3274 app.running = false;
3275 return;
3276 }
3277 }
3278 if let crossterm::event::KeyCode::Char('x') = key_event.code {
3281 if key_event.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
3282 if app.is_exporting {
3283 tracing::info!("ctrl+x received, cancelling export");
3284 app.cancel_export();
3285 }
3286 return;
3287 }
3288 }
3289
3290 tracing::debug!("key event: code={:?} modifiers={:?}", key_event.code, key_event.modifiers);
3291
3292 if app.preset_naming.is_some() {
3296 let naming = app.preset_naming.clone().unwrap();
3297 match key_event.code {
3298 crossterm::event::KeyCode::Char(c) => {
3299 if let Some(state) = app.preset_naming.as_mut() {
3300 state.name.push(c);
3301 }
3302 }
3303 crossterm::event::KeyCode::Backspace => {
3304 if let Some(state) = app.preset_naming.as_mut() {
3305 state.name.pop();
3306 }
3307 }
3308 crossterm::event::KeyCode::Enter => {
3309 app.commit_naming_preset();
3310 }
3311 crossterm::event::KeyCode::Esc => {
3312 app.cancel_naming_preset();
3313 app.status_message = "Preset save cancelled".to_string();
3314 }
3315 _ => {}
3316 }
3317 let _ = naming; return;
3319 }
3320
3321 if app.preset_picker.open {
3325 match key_event.code {
3326 crossterm::event::KeyCode::Esc => app.close_preset_picker(),
3327 crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
3328 if app.preset_picker.index > 0 {
3329 app.preset_picker.index -= 1;
3330 }
3331 app.preset_picker.message = None;
3332 }
3333 crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
3334 if app.preset_picker.index + 1 < app.presets.len() {
3335 app.preset_picker.index += 1;
3336 }
3337 app.preset_picker.message = None;
3338 }
3339 crossterm::event::KeyCode::Enter => {
3340 let idx = app.preset_picker.index;
3341 app.close_preset_picker();
3342 app.apply_preset(idx);
3343 }
3344 crossterm::event::KeyCode::Delete | crossterm::event::KeyCode::Backspace => {
3345 let idx = app.preset_picker.index;
3346 app.delete_preset(idx);
3347 }
3348 _ => {}
3349 }
3350 return;
3351 }
3352
3353 if app.import_popup != ImportPopupState::Hidden {
3357 let has_option2 = if let ImportPopupState::DroppedFiles { files, all_in_folder, .. } = &app.import_popup {
3358 all_in_folder.len() > files.len()
3359 } else {
3360 false
3361 };
3362
3363 match key_event.code {
3364 crossterm::event::KeyCode::Char('1') => {
3365 let files = if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
3366 files.clone()
3367 } else {
3368 Vec::new()
3369 };
3370 if !files.is_empty() {
3371 let count = files.len();
3372 app.status_message = format!("Importing {} file(s)...", count);
3373 let (imported, failed) = app.load_files_batch(&files);
3374 if failed > 0 {
3375 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
3376 } else {
3377 app.status_message = format!("Imported {} file(s)", imported);
3378 }
3379 }
3380 app.import_popup = ImportPopupState::Hidden;
3381 app.show_browser = false;
3382 }
3383 crossterm::event::KeyCode::Char('2') if has_option2 => {
3384 let all_in_folder = if let ImportPopupState::DroppedFiles { all_in_folder, .. } = &app.import_popup {
3385 all_in_folder.clone()
3386 } else {
3387 Vec::new()
3388 };
3389 if !all_in_folder.is_empty() {
3390 let count = all_in_folder.len();
3391 app.status_message = format!("Importing all {} file(s) from folder...", count);
3392 let (imported, failed) = app.load_files_batch(&all_in_folder);
3393 if failed > 0 {
3394 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
3395 } else {
3396 app.status_message = format!("Imported all {} file(s)", imported);
3397 }
3398 }
3399 app.import_popup = ImportPopupState::Hidden;
3400 app.show_browser = false;
3401 }
3402 crossterm::event::KeyCode::Enter => {
3403 let files = if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
3404 files.clone()
3405 } else {
3406 Vec::new()
3407 };
3408 if !files.is_empty() {
3409 let count = files.len();
3410 app.status_message = format!("Importing {} file(s)...", count);
3411 let (imported, failed) = app.load_files_batch(&files);
3412 if failed > 0 {
3413 app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
3414 } else {
3415 app.status_message = format!("Imported {} file(s)", imported);
3416 }
3417 }
3418 app.import_popup = ImportPopupState::Hidden;
3419 app.show_browser = false;
3420 }
3421 crossterm::event::KeyCode::Esc => {
3422 app.import_popup = ImportPopupState::Hidden;
3423 }
3424 _ => {}
3425 }
3426 return;
3427 }
3428
3429 if app.is_editing_custom_rate {
3433 match key_event.code {
3434 crossterm::event::KeyCode::Char(c) => {
3435 if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == 'M' || c == 'k' || c == 'm' {
3436 if let RateControl::Custom(ref mut val) = app.active_rate_control {
3437 val.push(c);
3438 }
3439 }
3440 }
3441 crossterm::event::KeyCode::Backspace => {
3442 if let RateControl::Custom(ref mut val) = app.active_rate_control {
3443 val.pop();
3444 }
3445 }
3446 crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Esc => {
3447 app.is_editing_custom_rate = false;
3448 app.status_message = format!("Rate: {}", app.active_rate_control.name());
3449 }
3450 _ => {}
3451 }
3452 return;
3453 }
3454
3455 if let crossterm::event::KeyCode::Char(c) = key_event.code {
3459 match c {
3460 'q' => {
3461 app.running = false;
3462 }
3463 '?' => {
3464 app.show_help = !app.show_help;
3465 }
3466 'b' => {
3467 if app.show_grade_screen || app.focus_target == FocusTarget::Grade {
3469 if app.grade_before_snapshot.is_none() {
3470 app.grade_before_snapshot = Some(app.grade_sliders);
3471 app.grade_sliders = GradeSliders::default();
3472 app.shockwave_ticks_remaining = 8;
3473 app.status_message = "BEFORE — holding original values".to_string();
3474 }
3475 } else {
3476 app.show_browser = !app.show_browser;
3477 app.status_message = if app.show_browser {
3478 "Browser shown"
3479 } else {
3480 "Browser hidden"
3481 }.to_string();
3482 }
3483 }
3484 'B' => {
3485 if let Some(snap) = app.grade_before_snapshot.take() {
3487 app.grade_sliders = snap;
3488 app.shockwave_ticks_remaining = 5;
3489 app.status_message = "AFTER — restored grade".to_string();
3490 }
3491 }
3492 'e' => {
3493 app.set_focus(FocusTarget::ExportSettings);
3494 }
3495 'a' => {
3496 app.add_selected_to_queue();
3497 }
3498 'A' => {
3499 app.add_all_to_queue();
3500 }
3501 'D' => {
3502 if app.focus_target == FocusTarget::MediaPool {
3503 app.remove_selected_from_media_pool();
3504 }
3505 }
3506 'd' => {
3507 if app.show_browser && app.show_favourites_bar {
3509 if let Some((ts, idx)) = app.last_clicked_favourite.take() {
3510 if ts.elapsed() < Duration::from_secs(2) && idx < app.favourite_folders.len() {
3511 app.favourite_folders.remove(idx);
3512 app.status_message = "Removed from favourites".to_string();
3513 app.save_favourites();
3514 return;
3515 }
3516 }
3517 }
3518 match app.focus_target {
3519 FocusTarget::MediaPool => app.remove_from_media_pool(),
3520 FocusTarget::Queue => app.remove_from_queue(),
3521 FocusTarget::ExportSettings => {}
3522 FocusTarget::Grade => {}
3523 }
3524 }
3525 'x' => {
3526 if app.is_exporting {
3529 app.cancel_export();
3530 } else {
3531 app.clear_completed_queue();
3532 }
3533 }
3534 'X' => {
3535 if app.is_exporting {
3536 app.cancel_export();
3537 } else {
3538 app.clear_completed_queue();
3539 }
3540 }
3541 'v' => {
3542 app.render_selected();
3543 }
3544 'R' => {
3545 app.render_all();
3546 }
3547 'r' => {
3548 if app.show_grade_screen || app.focus_target == FocusTarget::Grade {
3549 let def = GradeSliders::default_val(app.grade_focus);
3550 app.grade_sliders.set(app.grade_focus, def);
3551 app.status_message = format!("Reset {} to default", GradeSliders::name(app.grade_focus));
3552 app.grade_strip_active = true;
3553 app.grade_strip_idle_ticks = 15;
3554 } else if app.focus_target == FocusTarget::ExportSettings {
3555 app.export_focus = ExportFocus::RateControl;
3556 app.cycle_rate_control();
3557 }
3558 }
3559 't' => {
3560 if app.focus_target == FocusTarget::ExportSettings {
3561 app.export_focus = ExportFocus::TransferFunction;
3562 app.export_transfer_function = app.export_transfer_function.next();
3563 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
3564 }
3565 }
3566 'g' => {
3567 if app.focus_target == FocusTarget::ExportSettings {
3568 app.export_focus = ExportFocus::ColorSpace;
3569 app.export_color_space = app.export_color_space.next();
3570 app.status_message = format!("Gamut: {}", app.export_color_space.name());
3571 }
3572 }
3573 'c' => {
3574 if app.focus_target == FocusTarget::ExportSettings {
3575 app.cycle_codec(true);
3576 }
3577 }
3578 'o' => {
3579 if app.show_browser {
3580 app.set_export_folder(app.browser.current_path.clone());
3581 }
3582 }
3583 'f' => {
3584 if app.show_browser {
3585 if app.browsing_favourites {
3593 app.browsing_favourites = false;
3594 app.status_message = "Folder view".to_string();
3595 } else if app.favourite_folders.is_empty() {
3596 app.status_message = "No favourites yet — press [F] to add the current folder".to_string();
3597 } else {
3598 app.browsing_favourites = true;
3599 app.favourites_scroll_offset = Cell::new(0);
3600 app.status_message = "Favourites view (press [f] or [Esc] to return)".to_string();
3601 }
3602 } else if app.focus_target == FocusTarget::ExportSettings {
3603 app.cycle_export_fps();
3604 }
3605 }
3606 'F' => {
3607 if app.show_browser {
3608 app.toggle_favourite_folder(app.browser.current_path.clone());
3609 }
3610 }
3611 'i' => {
3612 if app.focus_target == FocusTarget::ExportSettings
3613 && matches!(app.active_rate_control, RateControl::Custom(_))
3614 {
3615 app.is_editing_custom_rate = !app.is_editing_custom_rate;
3616 if app.is_editing_custom_rate {
3617 app.status_message = "Type a rate value (e.g. 20, 400M, 50000k). Press Enter to confirm, Esc to cancel.".to_string();
3618 }
3619 } else {
3620 app.show_full_info = !app.show_full_info;
3621 if app.show_full_info {
3622 app.status_message = "Full file info shown (press i or Esc to close)".to_string();
3623 }
3624 }
3625 }
3626 'p' => {
3627 if app.focus_target == FocusTarget::ExportSettings {
3628 app.begin_naming_preset();
3630 } else {
3631 app.cycle_profile(true);
3632 }
3633 }
3634 'P' => {
3635 app.open_preset_picker();
3639 }
3640 's' => {
3641 app.toggle_select_all();
3642 }
3643 'n' => {
3644 if let Some(info) = app.focused_file_info().cloned().or_else(|| app.file_info.clone()) {
3645 let output_path = "naked_dump.raw";
3646 app.status_message = "Starting naked raw dump...".to_string();
3647 match crate::pipeline::run_naked(&info, output_path) {
3648 Ok(_) => {
3649 app.status_message = format!("Naked dump done: {}", output_path);
3650 }
3651 Err(e) => {
3652 app.status_message = format!("Naked dump failed: {}", e);
3653 }
3654 }
3655 }
3656 }
3657 '.' => {
3658 if app.show_browser {
3659 app.browser.toggle_hidden();
3660 app.status_message = if app.browser.show_hidden {
3661 "Showing hidden files"
3662 } else {
3663 "Hiding hidden files"
3664 }.to_string();
3665 }
3666 }
3667 'L' => {
3668 let folder = app.browser.current_path.clone();
3669 app.load_all_in_folder(&folder);
3670 app.show_browser = false;
3671 }
3672 'I' => {
3673 if app.show_browser {
3674 app.import_selected_from_browser();
3675 }
3676 }
3677 'C' => {
3678 if !app.imported_files.is_empty() {
3679 app.show_culling = !app.show_culling;
3680 app.status_message = if app.show_culling { "Culling mode" } else { "Normal mode" }.to_string();
3681 }
3682 }
3683 'G' => {
3684 app.show_grade_screen = !app.show_grade_screen;
3685 if app.show_grade_screen {
3686 if let Some((lx, ly, lw, lh)) = app.sixel_occupy_size.get() {
3688 let clear_line: Vec<u8> = vec![b' '; lw as usize];
3689 for row in ly..(ly + lh).min(9999) {
3690 let _ = std::io::stdout()
3691 .queue(MoveTo(lx, row))
3692 .and_then(|out| out.write_all(&clear_line));
3693 }
3694 app.sixel_occupy_size.set(None);
3695 }
3696 app.set_focus(FocusTarget::Grade);
3697 app.status_message = "Grade screen — Esc to exit".to_string();
3698 } else {
3699 app.grade_dragging = None;
3700 app.set_focus(FocusTarget::MediaPool);
3701 app.status_message = "Normal view".to_string();
3702 }
3703 }
3704 _ => {}
3705 }
3706 }
3707
3708 match key_event.code {
3712 crossterm::event::KeyCode::Esc => {
3713 if app.import_popup != ImportPopupState::Hidden {
3714 app.import_popup = ImportPopupState::Hidden;
3715 } else if app.show_full_info {
3716 app.show_full_info = false;
3717 } else if app.browsing_favourites {
3718 app.browsing_favourites = false;
3719 app.status_message = "Folder view".to_string();
3720 } else if app.show_browser {
3721 app.show_browser = false;
3722 } else if app.show_grade_screen {
3723 app.show_grade_screen = false;
3724 app.grade_dragging = None;
3725 app.set_focus(FocusTarget::MediaPool);
3726 app.status_message = "Normal view".to_string();
3727 } else if app.show_help {
3728 app.show_help = false;
3729 } else {
3730 app.running = false;
3731 }
3732 }
3733 crossterm::event::KeyCode::Delete => {
3734 if app.browsing_favourites {
3735 app.delete_selected_favourite();
3736 }
3737 }
3738 crossterm::event::KeyCode::Tab => {
3739 app.cycle_focus();
3740 }
3741 crossterm::event::KeyCode::Enter => {
3742 if app.focus_target == FocusTarget::ExportSettings
3743 && matches!(app.active_rate_control, RateControl::Custom(_))
3744 {
3745 app.is_editing_custom_rate = !app.is_editing_custom_rate;
3746 if app.is_editing_custom_rate {
3747 app.status_message = "Type a rate value. Enter to confirm, Esc to cancel.".to_string();
3748 }
3749 } else if app.browsing_favourites {
3750 app.open_selected_favourite();
3751 } else if app.show_browser {
3752 app.navigate_browser(BrowserDirection::Enter);
3753 }
3754 }
3755 crossterm::event::KeyCode::Right | crossterm::event::KeyCode::Char('l') => {
3756 if app.focus_target == FocusTarget::Grade {
3757 let step = if key_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3758 GradeSliders::step_large(app.grade_focus)
3759 } else {
3760 GradeSliders::step_small(app.grade_focus)
3761 };
3762 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3763 app.grade_sliders.apply_delta(app.grade_focus, step);
3764 app.phosphor_trail.push((old_norm, 4));
3765 app.grade_strip_active = true;
3766 app.grade_strip_idle_ticks = 15;
3767 } else if app.focus_target == FocusTarget::ExportSettings {
3768 match app.export_focus {
3769 ExportFocus::CodecFamily => app.cycle_codec(true),
3770 ExportFocus::ColorSpace => {
3771 app.export_color_space = app.export_color_space.next();
3772 app.status_message = format!("Gamut: {}", app.export_color_space.name());
3773 }
3774 ExportFocus::TransferFunction => {
3775 app.export_transfer_function = app.export_transfer_function.next();
3776 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
3777 }
3778 ExportFocus::Profile => app.cycle_profile(true),
3779 ExportFocus::RateControl => app.cycle_rate_control(),
3780 ExportFocus::Fps => app.cycle_export_fps(),
3781 }
3782 } else if !app.timestamps.is_empty() {
3783 let next = (app.frame_index + 1).min(app.timestamps.len() - 1);
3784 if next != app.frame_index {
3785 app.frame_index = next;
3786 app.request_frame_decode(app.frame_index);
3787 }
3788 }
3789 }
3790 crossterm::event::KeyCode::Left | crossterm::event::KeyCode::Char('h') => {
3791 if app.focus_target == FocusTarget::Grade {
3792 let step = if key_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3793 GradeSliders::step_large(app.grade_focus)
3794 } else {
3795 GradeSliders::step_small(app.grade_focus)
3796 };
3797 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3798 app.grade_sliders.apply_delta(app.grade_focus, -step);
3799 app.phosphor_trail.push((old_norm, 4));
3800 app.grade_strip_active = true;
3801 app.grade_strip_idle_ticks = 15;
3802 } else if app.focus_target == FocusTarget::ExportSettings {
3803 match app.export_focus {
3804 ExportFocus::CodecFamily => app.cycle_codec(false),
3805 ExportFocus::ColorSpace => {
3806 app.export_color_space = app.export_color_space.prev();
3807 app.status_message = format!("Gamut: {}", app.export_color_space.name());
3808 }
3809 ExportFocus::TransferFunction => {
3810 app.export_transfer_function = app.export_transfer_function.prev();
3811 app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
3812 }
3813 ExportFocus::Profile => app.cycle_profile(false),
3814 ExportFocus::RateControl => {
3815 app.active_rate_control = app.active_rate_control.prev();
3816 app.status_message = format!("Rate: {}", app.active_rate_control.name());
3817 }
3818 ExportFocus::Fps => app.cycle_export_fps(),
3819 }
3820 } else if !app.timestamps.is_empty() {
3821 let prev = app.frame_index.saturating_sub(1);
3822 if prev != app.frame_index {
3823 app.frame_index = prev;
3824 app.request_frame_decode(app.frame_index);
3825 }
3826 }
3827 }
3828 crossterm::event::KeyCode::Char('L') => {
3829 if app.focus_target == FocusTarget::Grade {
3830 let step = GradeSliders::step_large(app.grade_focus);
3831 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3832 app.grade_sliders.apply_delta(app.grade_focus, step);
3833 app.phosphor_trail.push((old_norm, 4));
3834 app.grade_strip_active = true;
3835 app.grade_strip_idle_ticks = 15;
3836 }
3837 }
3838 crossterm::event::KeyCode::Char('H') => {
3839 if app.focus_target == FocusTarget::Grade {
3840 let step = GradeSliders::step_large(app.grade_focus);
3841 let old_norm = app.grade_sliders.normalized(app.grade_focus);
3842 app.grade_sliders.apply_delta(app.grade_focus, -step);
3843 app.phosphor_trail.push((old_norm, 4));
3844 app.grade_strip_active = true;
3845 app.grade_strip_idle_ticks = 15;
3846 }
3847 }
3848 crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
3849 if app.show_help {
3850 app.help_scroll = app.help_scroll.saturating_sub(1);
3851 } else if app.browsing_favourites {
3852 app.navigate_favourites(-1);
3853 } else if app.show_browser {
3854 app.navigate_browser(BrowserDirection::Up);
3855 } else {
3856 match app.focus_target {
3857 FocusTarget::MediaPool => {
3858 if app.media_pool_index > 0 {
3859 app.switch_media_pool_item(app.media_pool_index - 1);
3860 }
3861 }
3862 FocusTarget::Queue => {
3863 if app.queue_index > 0 {
3864 app.queue_index -= 1;
3865 }
3866 }
3867 FocusTarget::ExportSettings => {
3868 let show_rate = !matches!(app.export_codec_family, crate::export::CodecFamily::ProRes | crate::export::CodecFamily::DNxHR);
3869 app.export_focus = match app.export_focus {
3870 ExportFocus::CodecFamily => if show_rate { ExportFocus::RateControl } else { ExportFocus::Fps },
3871 ExportFocus::RateControl => ExportFocus::Fps,
3872 ExportFocus::Fps => ExportFocus::Profile,
3873 ExportFocus::Profile => ExportFocus::TransferFunction,
3874 ExportFocus::TransferFunction => ExportFocus::ColorSpace,
3875 ExportFocus::ColorSpace => ExportFocus::CodecFamily,
3876 };
3877 }
3878 FocusTarget::Grade => {
3879 if app.grade_focus > 0 {
3880 app.grade_morph = Some((app.grade_focus, 4));
3881 app.grade_focus -= 1;
3882 app.grade_strip_active = true;
3883 app.grade_strip_idle_ticks = 15;
3884 }
3885 }
3886 }
3887 }
3888 }
3889 crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
3890 if app.show_help {
3891 app.help_scroll = app.help_scroll.saturating_add(1);
3892 } else if app.browsing_favourites {
3893 app.navigate_favourites(1);
3894 } else if app.show_browser {
3895 app.navigate_browser(BrowserDirection::Down);
3896 } else {
3897 match app.focus_target {
3898 FocusTarget::MediaPool => {
3899 if app.media_pool_index + 1 < app.imported_files.len() {
3900 app.switch_media_pool_item(app.media_pool_index + 1);
3901 }
3902 }
3903 FocusTarget::Queue => {
3904 if app.queue_index + 1 < app.queue.len() {
3905 app.queue_index += 1;
3906 }
3907 }
3908 FocusTarget::ExportSettings => {
3909 let show_rate = !matches!(app.export_codec_family, crate::export::CodecFamily::ProRes | crate::export::CodecFamily::DNxHR);
3910 app.export_focus = match app.export_focus {
3911 ExportFocus::CodecFamily => ExportFocus::ColorSpace,
3912 ExportFocus::ColorSpace => ExportFocus::TransferFunction,
3913 ExportFocus::TransferFunction => ExportFocus::Profile,
3914 ExportFocus::Profile => ExportFocus::Fps,
3915 ExportFocus::Fps => if show_rate { ExportFocus::RateControl } else { ExportFocus::CodecFamily },
3916 ExportFocus::RateControl => ExportFocus::CodecFamily,
3917 };
3918 }
3919 FocusTarget::Grade => {
3920 if app.grade_focus + 1 < GradeSliders::count() {
3921 app.grade_morph = Some((app.grade_focus, 4));
3922 app.grade_focus += 1;
3923 app.grade_strip_active = true;
3924 app.grade_strip_idle_ticks = 15;
3925 }
3926 }
3927 }
3928 }
3929 }
3930 crossterm::event::KeyCode::Char(' ') => {
3931 if app.show_browser {
3932 app.browser.toggle_selection();
3933 } else {
3934 match app.focus_target {
3935 FocusTarget::MediaPool => app.toggle_media_pool_selection(),
3936 FocusTarget::Queue => app.toggle_queue_selection(),
3937 FocusTarget::ExportSettings => {}
3938 FocusTarget::Grade => {}
3939 }
3940 }
3941 }
3942 crossterm::event::KeyCode::PageUp => {
3943 if app.show_help {
3944 app.help_scroll = app.help_scroll.saturating_sub(10);
3945 } else if app.browsing_favourites {
3946 app.navigate_favourites(-10);
3947 } else if app.show_browser {
3948 let entries_len = app.browser.entries.len();
3949 if entries_len > 0 {
3950 let new_index = app.browser.selected_index.saturating_sub(10.min(entries_len));
3951 app.browser.selected_index = new_index;
3952 }
3953 } else if app.focus_target == FocusTarget::MediaPool {
3954 let len = app.imported_files.len();
3955 if len > 0 {
3956 let new_index = app.media_pool_index.saturating_sub(10.min(len));
3957 app.switch_media_pool_item(new_index);
3958 }
3959 } else if app.focus_target == FocusTarget::Queue {
3960 let len = app.queue.len();
3961 if len > 0 {
3962 app.queue_index = app.queue_index.saturating_sub(10.min(len));
3963 }
3964 }
3965 }
3966 crossterm::event::KeyCode::PageDown => {
3967 if app.show_help {
3968 app.help_scroll = app.help_scroll.saturating_add(10);
3969 } else if app.browsing_favourites {
3970 app.navigate_favourites(10);
3971 } else if app.show_browser {
3972 let entries_len = app.browser.entries.len();
3973 if entries_len > 0 {
3974 let new_index = (app.browser.selected_index + 10).min(entries_len - 1);
3975 app.browser.selected_index = new_index;
3976 }
3977 } else if app.focus_target == FocusTarget::MediaPool {
3978 let len = app.imported_files.len();
3979 if len > 0 {
3980 let new_index = (app.media_pool_index + 10).min(len - 1);
3981 app.switch_media_pool_item(new_index);
3982 }
3983 } else if app.focus_target == FocusTarget::Queue {
3984 let len = app.queue.len();
3985 if len > 0 {
3986 app.queue_index = (app.queue_index + 10).min(len - 1);
3987 }
3988 }
3989 }
3990 crossterm::event::KeyCode::Home => {
3991 if app.browsing_favourites {
3992 app.favourites_scroll_offset = Cell::new(0);
3993 } else if app.show_browser {
3994 app.browser.selected_index = 0;
3995 } else if app.focus_target == FocusTarget::MediaPool {
3996 app.switch_media_pool_item(0);
3997 } else if app.focus_target == FocusTarget::Queue {
3998 app.queue_index = 0;
3999 }
4000 }
4001 crossterm::event::KeyCode::End => {
4002 if app.browsing_favourites {
4003 if !app.favourite_folders.is_empty() {
4004 app.favourites_scroll_offset
4005 .set(app.favourite_folders.len() - 1);
4006 }
4007 } else if app.show_browser {
4008 let entries_len = app.browser.entries.len();
4009 if entries_len > 0 {
4010 app.browser.selected_index = entries_len - 1;
4011 }
4012 } else if app.focus_target == FocusTarget::MediaPool {
4013 if !app.imported_files.is_empty() {
4014 app.switch_media_pool_item(app.imported_files.len() - 1);
4015 }
4016 } else if app.focus_target == FocusTarget::Queue {
4017 if !app.queue.is_empty() {
4018 app.queue_index = app.queue.len() - 1;
4019 }
4020 }
4021 }
4022 crossterm::event::KeyCode::Backspace => {
4023 if app.browsing_favourites {
4024 app.browsing_favourites = false;
4025 app.status_message = "Folder view".to_string();
4026 } else if app.show_browser {
4027 app.navigate_browser(BrowserDirection::GoUp);
4028 }
4029 }
4030 _ => {}
4031 }
4032 }
4033 _ => {}
4034 }
4035}
4036
4037