feat: adiciona suporte para sugestão de velocidade com base nas durações de vídeo e faixas de áudio/legenda

This commit is contained in:
2026-03-01 14:54:49 -03:00
parent 1ef47b3a07
commit e70612f5ea
5 changed files with 167 additions and 5 deletions
+7 -1
View File
@@ -7,7 +7,7 @@ use crate::application::use_cases::{
edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack, edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack,
load_media_info::LoadMediaInfo, remove_track::RemoveTrack, 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::domain::value_objects::FilePath;
use crate::infrastructure::process::{run_ffmpeg_async, run_mkvmerge_async}; use crate::infrastructure::process::{run_ffmpeg_async, run_mkvmerge_async};
use crate::ui::components::{ use crate::ui::components::{
@@ -135,6 +135,12 @@ impl App {
Ok(_) => { Ok(_) => {
self.existing_track_list self.existing_track_list
.sync_tracks(&project.existing_tracks); .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) => { Err(e) => {
self.execution_panel.state = ExecutionState::Error(e.to_string()); self.execution_panel.state = ExecutionState::Error(e.to_string());
+34
View File
@@ -1,4 +1,7 @@
use crate::adapters::ffmpeg::{FfprobeGateway};
use crate::adapters::filesystem::FilePickerAdapter; 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::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField}; use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui; use eframe::egui;
@@ -21,6 +24,10 @@ pub struct AddAudioTrackForm {
language_field: LanguageField, language_field: LanguageField,
is_default: bool, is_default: bool,
title_input: String, title_input: String,
/// Duração do vídeo base (ms) — definida por App após carregar o projeto.
pub video_duration_ms: Option<u64>,
/// Duração da faixa de áudio selecionada (ms) — obtida via ffprobe ao escolher o arquivo.
selected_duration_ms: Option<u64>,
} }
impl AddAudioTrackForm { impl AddAudioTrackForm {
@@ -31,9 +38,25 @@ impl AddAudioTrackForm {
language_field: LanguageField::new(None), language_field: LanguageField::new(None),
is_default: false, is_default: false,
title_input: String::new(), 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<u64>) {
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. /// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddAudioTrackRequest> { pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddAudioTrackRequest> {
let mut result = None; let mut result = None;
@@ -50,6 +73,15 @@ impl AddAudioTrackForm {
ui.label(&label); ui.label(&label);
if ui.button("Selecionar áudio…").clicked() { if ui.button("Selecionar áudio…").clicked() {
self.selected_path = FilePickerAdapter::pick_audio(); 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 // Reset form
self.selected_path = None; self.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default()); self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.selected_duration_ms = None;
self.update_suggestion();
self.language_field = LanguageField::new(None); self.language_field = LanguageField::new(None);
self.is_default = false; self.is_default = false;
self.title_input = String::new(); self.title_input = String::new();
+34
View File
@@ -1,4 +1,7 @@
use crate::adapters::ffmpeg::FfprobeGateway;
use crate::adapters::filesystem::FilePickerAdapter; 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::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField}; use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui; use eframe::egui;
@@ -21,6 +24,10 @@ pub struct AddSubtitleForm {
language_field: LanguageField, language_field: LanguageField,
is_default: bool, is_default: bool,
title_input: String, title_input: String,
/// Duração do vídeo base (ms) — definida por App após carregar o projeto.
pub video_duration_ms: Option<u64>,
/// Duração da legenda selecionada (ms) — obtida via ffprobe ao escolher o arquivo.
selected_duration_ms: Option<u64>,
} }
impl AddSubtitleForm { impl AddSubtitleForm {
@@ -31,9 +38,25 @@ impl AddSubtitleForm {
language_field: LanguageField::new(None), language_field: LanguageField::new(None),
is_default: false, is_default: false,
title_input: String::new(), 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<u64>) {
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. /// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddSubtitleRequest> { pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddSubtitleRequest> {
let mut result = None; let mut result = None;
@@ -50,6 +73,15 @@ impl AddSubtitleForm {
ui.label(&label); ui.label(&label);
if ui.button("Selecionar legenda…").clicked() { if ui.button("Selecionar legenda…").clicked() {
self.selected_path = FilePickerAdapter::pick_subtitle(); 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.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default()); self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.selected_duration_ms = None;
self.update_suggestion();
self.language_field = LanguageField::new(None); self.language_field = LanguageField::new(None);
self.is_default = false; self.is_default = false;
self.title_input = String::new(); self.title_input = String::new();
+54 -4
View File
@@ -59,6 +59,11 @@ impl ExistingTrackList {
return; 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 }; let num_cols = if mkvmerge_available { 7 } else { 6 };
egui::Grid::new("existing_tracks_grid") egui::Grid::new("existing_tracks_grid")
.num_columns(num_cols) .num_columns(num_cols)
@@ -115,14 +120,59 @@ impl ExistingTrackList {
// Drift field (somente para áudio e legenda quando mkvmerge disponível) // Drift field (somente para áudio e legenda quando mkvmerge disponível)
if mkvmerge_available { if mkvmerge_available {
if editable { 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 let drift_raw = self
.drift_fields .drift_fields
.entry(track.id.val()) .entry(track.id.val())
.or_insert_with(|| format!("{:.5}", track.drift_scale * 100.0)); .or_insert_with(|| format!("{:.5}", track.drift_scale * 100.0));
let resp = ui.add(
egui::TextEdit::singleline(drift_raw).desired_width(80.0), let mut text_changed = false;
); let mut suggestion_applied: Option<f64> = None;
if resp.changed() {
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::<f64>() { if let Ok(pct) = drift_raw.trim().parse::<f64>() {
if pct >= 1.0 && pct <= 999.99 { if pct >= 1.0 && pct <= 999.99 {
let scale = pct / 100.0; let scale = pct / 100.0;
+38
View File
@@ -10,6 +10,8 @@ pub struct SyncOffsetField {
/// Percentual de velocidade original (ex: "99.983" → scale 0.99983). Padrão: "100.00". /// Percentual de velocidade original (ex: "99.983" → scale 0.99983). Padrão: "100.00".
pub drift_raw: String, pub drift_raw: String,
drift_error: Option<String>, drift_error: Option<String>,
/// Velocidade sugerida calculada externamente (% inteiro). Exibida como botão 💡.
suggestion_pct: Option<f64>,
} }
impl SyncOffsetField { impl SyncOffsetField {
@@ -20,9 +22,16 @@ impl SyncOffsetField {
error: None, error: None,
drift_raw: "100.00".to_string(), drift_raw: "100.00".to_string(),
drift_error: None, 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<f64>) {
self.suggestion_pct = pct;
}
/// Renderiza o campo de offset (sem o campo de drift). /// Renderiza o campo de offset (sem o campo de drift).
pub fn ui(&mut self, ui: &mut egui::Ui, label: &str) -> bool { pub fn ui(&mut self, ui: &mut egui::Ui, label: &str) -> bool {
let mut changed = false; let mut changed = false;
@@ -60,6 +69,35 @@ impl SyncOffsetField {
"Instale MKVToolNix para usar correção de drift de velocidade.", "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::<f64>() { if let Ok(pct) = self.drift_raw.trim().parse::<f64>() {
if (pct - 100.0).abs() > 0.001 { if (pct - 100.0).abs() > 0.001 {
ui.colored_label( ui.colored_label(