diff --git a/src/ui/app.rs b/src/ui/app.rs index 0b8ddbd..ad5e95a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -7,7 +7,7 @@ use crate::application::use_cases::{ edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack, load_media_info::LoadMediaInfo, remove_track::RemoveTrack, }; -use crate::domain::entities::{MkvOutput, Project, VideoFile}; +use crate::domain::entities::{MkvOutput, Project, TrackKind, VideoFile}; use crate::domain::value_objects::FilePath; use crate::infrastructure::process::{run_ffmpeg_async, run_mkvmerge_async}; use crate::ui::components::{ @@ -135,6 +135,12 @@ impl App { Ok(_) => { self.existing_track_list .sync_tracks(&project.existing_tracks); + // Propaga duração do vídeo para os formulários (sugestão de velocidade) + let video_dur = project.existing_tracks.iter() + .find(|t| matches!(t.kind, TrackKind::Video)) + .and_then(|t| t.duration_ms); + self.add_audio_form.set_video_duration(video_dur); + self.add_subtitle_form.set_video_duration(video_dur); } Err(e) => { self.execution_panel.state = ExecutionState::Error(e.to_string()); diff --git a/src/ui/components/add_audio_track_form.rs b/src/ui/components/add_audio_track_form.rs index bcfc288..d87fab9 100644 --- a/src/ui/components/add_audio_track_form.rs +++ b/src/ui/components/add_audio_track_form.rs @@ -1,4 +1,7 @@ +use crate::adapters::ffmpeg::{FfprobeGateway}; use crate::adapters::filesystem::FilePickerAdapter; +use crate::application::ports::MediaInfoPort; +use crate::domain::entities::TrackKind; use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField}; use eframe::egui; @@ -21,6 +24,10 @@ pub struct AddAudioTrackForm { language_field: LanguageField, is_default: bool, title_input: String, + /// Duração do vídeo base (ms) — definida por App após carregar o projeto. + pub video_duration_ms: Option, + /// Duração da faixa de áudio selecionada (ms) — obtida via ffprobe ao escolher o arquivo. + selected_duration_ms: Option, } impl AddAudioTrackForm { @@ -31,9 +38,25 @@ impl AddAudioTrackForm { language_field: LanguageField::new(None), is_default: false, title_input: String::new(), + video_duration_ms: None, + selected_duration_ms: None, } } + /// Atualiza a duração do vídeo base e recalcula a sugestão de velocidade. + pub fn set_video_duration(&mut self, dur: Option) { + self.video_duration_ms = dur; + self.update_suggestion(); + } + + fn update_suggestion(&mut self) { + let suggestion = self.video_duration_ms + .zip(self.selected_duration_ms) + .filter(|(_, td)| *td > 0) + .map(|(vd, td)| vd as f64 / td as f64 * 100.0); + self.offset_field.set_suggestion(suggestion); + } + /// Renderiza o formulário. Retorna `Some(request)` ao confirmar. pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option { let mut result = None; @@ -50,6 +73,15 @@ impl AddAudioTrackForm { ui.label(&label); if ui.button("Selecionar áudio…").clicked() { self.selected_path = FilePickerAdapter::pick_audio(); + // Proba a duração do arquivo selecionado para sugestão de velocidade + self.selected_duration_ms = self.selected_path.as_ref().and_then(|p| { + let tracks = FfprobeGateway.probe(p).ok()?; + tracks.iter() + .find(|t| matches!(t.kind, TrackKind::Audio) && t.duration_ms.is_some()) + .or_else(|| tracks.iter().find(|t| t.duration_ms.is_some())) + .and_then(|t| t.duration_ms) + }); + self.update_suggestion(); } }); @@ -96,6 +128,8 @@ impl AddAudioTrackForm { // Reset form self.selected_path = None; self.offset_field = SyncOffsetField::new(SyncOffset::default()); + self.selected_duration_ms = None; + self.update_suggestion(); self.language_field = LanguageField::new(None); self.is_default = false; self.title_input = String::new(); diff --git a/src/ui/components/add_subtitle_form.rs b/src/ui/components/add_subtitle_form.rs index 9ead213..c831001 100644 --- a/src/ui/components/add_subtitle_form.rs +++ b/src/ui/components/add_subtitle_form.rs @@ -1,4 +1,7 @@ +use crate::adapters::ffmpeg::FfprobeGateway; use crate::adapters::filesystem::FilePickerAdapter; +use crate::application::ports::MediaInfoPort; +use crate::domain::entities::TrackKind; use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField}; use eframe::egui; @@ -21,6 +24,10 @@ pub struct AddSubtitleForm { language_field: LanguageField, is_default: bool, title_input: String, + /// Duração do vídeo base (ms) — definida por App após carregar o projeto. + pub video_duration_ms: Option, + /// Duração da legenda selecionada (ms) — obtida via ffprobe ao escolher o arquivo. + selected_duration_ms: Option, } impl AddSubtitleForm { @@ -31,9 +38,25 @@ impl AddSubtitleForm { language_field: LanguageField::new(None), is_default: false, title_input: String::new(), + video_duration_ms: None, + selected_duration_ms: None, } } + /// Atualiza a duração do vídeo base e recalcula a sugestão de velocidade. + pub fn set_video_duration(&mut self, dur: Option) { + self.video_duration_ms = dur; + self.update_suggestion(); + } + + fn update_suggestion(&mut self) { + let suggestion = self.video_duration_ms + .zip(self.selected_duration_ms) + .filter(|(_, td)| *td > 0) + .map(|(vd, td)| vd as f64 / td as f64 * 100.0); + self.offset_field.set_suggestion(suggestion); + } + /// Renderiza o formulário. Retorna `Some(request)` ao confirmar. pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option { let mut result = None; @@ -50,6 +73,15 @@ impl AddSubtitleForm { ui.label(&label); if ui.button("Selecionar legenda…").clicked() { self.selected_path = FilePickerAdapter::pick_subtitle(); + // Proba a duração do arquivo selecionado para sugestão de velocidade + self.selected_duration_ms = self.selected_path.as_ref().and_then(|p| { + let tracks = FfprobeGateway.probe(p).ok()?; + tracks.iter() + .find(|t| matches!(t.kind, TrackKind::Subtitle) && t.duration_ms.is_some()) + .or_else(|| tracks.iter().find(|t| t.duration_ms.is_some())) + .and_then(|t| t.duration_ms) + }); + self.update_suggestion(); } }); @@ -94,6 +126,8 @@ impl AddSubtitleForm { }); self.selected_path = None; self.offset_field = SyncOffsetField::new(SyncOffset::default()); + self.selected_duration_ms = None; + self.update_suggestion(); self.language_field = LanguageField::new(None); self.is_default = false; self.title_input = String::new(); diff --git a/src/ui/components/existing_track_list.rs b/src/ui/components/existing_track_list.rs index fb8fafe..ab325b1 100644 --- a/src/ui/components/existing_track_list.rs +++ b/src/ui/components/existing_track_list.rs @@ -59,6 +59,11 @@ impl ExistingTrackList { return; } + // Duração do vídeo base para sugestão de velocidade + let video_duration_ms = tracks.iter() + .find(|t| matches!(t.kind, TrackKind::Video)) + .and_then(|t| t.duration_ms); + let num_cols = if mkvmerge_available { 7 } else { 6 }; egui::Grid::new("existing_tracks_grid") .num_columns(num_cols) @@ -115,14 +120,59 @@ impl ExistingTrackList { // Drift field (somente para áudio e legenda quando mkvmerge disponível) if mkvmerge_available { if editable { + let suggestion_pct = video_duration_ms + .zip(track.duration_ms) + .filter(|(_, td)| *td > 0) + .map(|(vd, td)| vd as f64 / td as f64 * 100.0); + let drift_raw = self .drift_fields .entry(track.id.val()) .or_insert_with(|| format!("{:.5}", track.drift_scale * 100.0)); - let resp = ui.add( - egui::TextEdit::singleline(drift_raw).desired_width(80.0), - ); - if resp.changed() { + + let mut text_changed = false; + let mut suggestion_applied: Option = None; + + ui.horizontal(|ui| { + let resp = ui.add( + egui::TextEdit::singleline(drift_raw).desired_width(72.0), + ); + text_changed = resp.changed(); + + if let Some(pct) = suggestion_pct { + let warn = (pct - 100.0).abs() > 0.5; + let hover = format!( + "Sugerido: {:.3}%{}", + pct, + if warn { + "\n⚠ Diferença >0.5% — verifique se é o mesmo conteúdo" + } else { + " — clique para aplicar" + } + ); + let color = if warn { + egui::Color32::from_rgb(255, 160, 0) + } else { + egui::Color32::from_rgb(80, 180, 80) + }; + if ui + .add(egui::Button::new( + egui::RichText::new("💡").color(color).small(), + )) + .on_hover_text(hover) + .clicked() + { + suggestion_applied = Some(pct); + } + } + }); + + if let Some(pct) = suggestion_applied { + *drift_raw = format!("{:.3}", pct); + events.push(ExistingTrackEvent::DriftChanged( + track.id, pct / 100.0, + )); + } else if text_changed { if let Ok(pct) = drift_raw.trim().parse::() { if pct >= 1.0 && pct <= 999.99 { let scale = pct / 100.0; diff --git a/src/ui/components/sync_offset_field.rs b/src/ui/components/sync_offset_field.rs index 6f7dcb3..db17fe6 100644 --- a/src/ui/components/sync_offset_field.rs +++ b/src/ui/components/sync_offset_field.rs @@ -10,6 +10,8 @@ pub struct SyncOffsetField { /// Percentual de velocidade original (ex: "99.983" → scale 0.99983). Padrão: "100.00". pub drift_raw: String, drift_error: Option, + /// Velocidade sugerida calculada externamente (% inteiro). Exibida como botão 💡. + suggestion_pct: Option, } impl SyncOffsetField { @@ -20,9 +22,16 @@ impl SyncOffsetField { error: None, drift_raw: "100.00".to_string(), drift_error: None, + suggestion_pct: None, } } + /// Define a velocidade sugerida (%) calculada a partir das durações do vídeo e da faixa. + /// `None` remove o botão de sugestão. + pub fn set_suggestion(&mut self, pct: Option) { + self.suggestion_pct = pct; + } + /// Renderiza o campo de offset (sem o campo de drift). pub fn ui(&mut self, ui: &mut egui::Ui, label: &str) -> bool { let mut changed = false; @@ -60,6 +69,35 @@ impl SyncOffsetField { "Instale MKVToolNix para usar correção de drift de velocidade.", ); } + // Botão de sugestão calculada a partir das durações + if mkvmerge_available { + if let Some(pct) = self.suggestion_pct { + let warn = (pct - 100.0).abs() > 0.5; + let hover = format!( + "Velocidade sugerida: {:.3}%{}", + pct, + if warn { + "\n⚠ Diferença >0.5% — verifique se é o mesmo conteúdo" + } else { + " — clique para aplicar" + } + ); + let color = if warn { + egui::Color32::from_rgb(255, 160, 0) + } else { + egui::Color32::from_rgb(80, 180, 80) + }; + if ui + .add(egui::Button::new( + egui::RichText::new("💡").color(color).small(), + )) + .on_hover_text(hover) + .clicked() + { + self.drift_raw = format!("{:.3}", pct); + } + } + } if let Ok(pct) = self.drift_raw.trim().parse::() { if (pct - 100.0).abs() > 0.001 { ui.colored_label(