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:
+7
-1
@@ -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());
|
||||
|
||||
@@ -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<u64>,
|
||||
/// Duração da faixa de áudio selecionada (ms) — obtida via ffprobe ao escolher o arquivo.
|
||||
selected_duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
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<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.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddAudioTrackRequest> {
|
||||
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();
|
||||
|
||||
@@ -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<u64>,
|
||||
/// Duração da legenda selecionada (ms) — obtida via ffprobe ao escolher o arquivo.
|
||||
selected_duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
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<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.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddSubtitleRequest> {
|
||||
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();
|
||||
|
||||
@@ -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<f64> = 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::<f64>() {
|
||||
if pct >= 1.0 && pct <= 999.99 {
|
||||
let scale = pct / 100.0;
|
||||
|
||||
@@ -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<String>,
|
||||
/// Velocidade sugerida calculada externamente (% inteiro). Exibida como botão 💡.
|
||||
suggestion_pct: Option<f64>,
|
||||
}
|
||||
|
||||
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<f64>) {
|
||||
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::<f64>() {
|
||||
if (pct - 100.0).abs() > 0.001 {
|
||||
ui.colored_label(
|
||||
|
||||
Reference in New Issue
Block a user