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,
|
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());
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 mut text_changed = false;
|
||||||
|
let mut suggestion_applied: Option<f64> = None;
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
let resp = ui.add(
|
let resp = ui.add(
|
||||||
egui::TextEdit::singleline(drift_raw).desired_width(80.0),
|
egui::TextEdit::singleline(drift_raw).desired_width(72.0),
|
||||||
);
|
);
|
||||||
if resp.changed() {
|
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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user