diff --git a/src/adapters/ffmpeg/command_builder.rs b/src/adapters/ffmpeg/command_builder.rs index b4952d2..e1660b8 100644 --- a/src/adapters/ffmpeg/command_builder.rs +++ b/src/adapters/ffmpeg/command_builder.rs @@ -61,8 +61,8 @@ impl FfmpegCommandBuilder { } // ── Maps ──────────────────────────────────────────────────────────────── - // Faixas existentes do source - for track in &project.existing_tracks { + // Faixas existentes do source (excluídas pelo usuário são omitidas) + for track in project.existing_tracks.iter().filter(|t| !t.excluded) { args.push("-map".to_string()); if track.offset.is_zero() { args.push(format!("0:{}", track.stream_index)); @@ -74,7 +74,9 @@ impl FfmpegCommandBuilder { } } - // Se não há faixas existentes mapeadas, mapeia tudo do source + // Se não há faixas existentes conhecidas (ffprobe não foi executado), mapeia tudo do source. + // Quando existing_tracks está populado mas todas foram excluídas, o usuário optou + // conscientemente por não incluir nenhuma faixa original — não emitimos -map 0. if project.existing_tracks.is_empty() { args.push("-map".to_string()); args.push("0".to_string()); @@ -364,4 +366,49 @@ mod tests { ); assert!(args.contains(&"language=por".to_string())); } -} + + #[test] + fn faixa_existente_excluida_nao_aparece_em_map() { + use crate::domain::entities::{MediaTrackInfo, TrackKind}; + use crate::domain::value_objects::TrackId; + let mut project = base_project(); + let mut audio = MediaTrackInfo::new( + TrackId::new(1), + TrackKind::Audio, + "aac", + None, + 1, + ); + audio.excluded = true; + project.existing_tracks.push(audio); + + let args = FfmpegCommandBuilder::build(&project); + // -map 0:1 não deve aparecer pois a faixa está excluída + assert!( + !args.contains(&"0:1".to_string()), + "faixa excluída não deve aparecer em -map — args: {:?}", + args + ); + } + + #[test] + fn faixa_existente_nao_excluida_aparece_em_map() { + use crate::domain::entities::{MediaTrackInfo, TrackKind}; + use crate::domain::value_objects::TrackId; + let mut project = base_project(); + project.existing_tracks.push(MediaTrackInfo::new( + TrackId::new(1), + TrackKind::Audio, + "aac", + None, + 1, + )); + + let args = FfmpegCommandBuilder::build(&project); + assert!( + args.contains(&"0:1".to_string()), + "faixa não excluída deve aparecer em -map — args: {:?}", + args + ); + } +} \ No newline at end of file diff --git a/src/adapters/mkvmerge/command_builder.rs b/src/adapters/mkvmerge/command_builder.rs index 5dcb1dd..db34eee 100644 --- a/src/adapters/mkvmerge/command_builder.rs +++ b/src/adapters/mkvmerge/command_builder.rs @@ -1,4 +1,4 @@ -use crate::domain::entities::{Project, Track}; +use crate::domain::entities::{Project, Track, TrackKind}; use crate::domain::value_objects::SyncOffset; /// Constrói os argumentos de linha de comando para o `mkvmerge`. @@ -22,8 +22,63 @@ impl MkvmergeCommandBuilder { args.push("-o".to_string()); args.push(project.output.path.to_string()); - // 2. Opções de faixas existentes (stream_index é o TID no arquivo fonte) - for track in &project.existing_tracks { + // 2. Seleção de faixas existentes (exclusão pelo usuário) + // Coleta TIDs de áudio e legenda não excluídos para --audio-tracks / --subtitle-tracks. + // Se todos de um tipo foram excluídos, emite --no-audio ou --no-subtitles. + let audio_included: Vec = project + .existing_tracks + .iter() + .filter(|t| matches!(t.kind, TrackKind::Audio) && !t.excluded) + .map(|t| t.stream_index) + .collect(); + let audio_total = project + .existing_tracks + .iter() + .filter(|t| matches!(t.kind, TrackKind::Audio)) + .count(); + + if audio_total > 0 { + if audio_included.is_empty() { + args.push("--no-audio".to_string()); + } else if audio_included.len() < audio_total { + let tids = audio_included + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + args.push("--audio-tracks".to_string()); + args.push(tids); + } + } + + let sub_included: Vec = project + .existing_tracks + .iter() + .filter(|t| matches!(t.kind, TrackKind::Subtitle) && !t.excluded) + .map(|t| t.stream_index) + .collect(); + let sub_total = project + .existing_tracks + .iter() + .filter(|t| matches!(t.kind, TrackKind::Subtitle)) + .count(); + + if sub_total > 0 { + if sub_included.is_empty() { + args.push("--no-subtitles".to_string()); + } else if sub_included.len() < sub_total { + let tids = sub_included + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + args.push("--subtitle-tracks".to_string()); + args.push(tids); + } + } + + // Opções de faixas existentes não excluídas (--sync, --language) + for track in project.existing_tracks.iter().filter(|t| !t.excluded) { let needs_sync = track.offset.as_ms() != 0 || (track.drift_scale - 1.0).abs() > 1e-9; if needs_sync { let (num, den) = scale_to_rational(track.drift_scale); diff --git a/src/domain/entities/media_track_info.rs b/src/domain/entities/media_track_info.rs index 54e45a6..795f017 100644 --- a/src/domain/entities/media_track_info.rs +++ b/src/domain/entities/media_track_info.rs @@ -37,6 +37,10 @@ pub struct MediaTrackInfo { pub drift_scale: f64, /// Duração da faixa em milissegundos (lida via ffprobe). `None` se não disponível. pub duration_ms: Option, + /// Quando `true`, a faixa é omitida do arquivo de saída (não é mapeada). + /// A faixa permanece visível na UI para que o usuário possa reverter a decisão. + #[serde(default)] + pub excluded: bool, } impl MediaTrackInfo { @@ -56,6 +60,7 @@ impl MediaTrackInfo { stream_index, drift_scale: 1.0, duration_ms: None, + excluded: false, } } } diff --git a/src/domain/entities/project.rs b/src/domain/entities/project.rs index 62749cb..04ef24b 100644 --- a/src/domain/entities/project.rs +++ b/src/domain/entities/project.rs @@ -49,6 +49,19 @@ impl Project { self.existing_tracks.iter_mut().find(|t| t.id == id) } + /// Alterna o estado `excluded` de uma faixa existente pelo TrackId. + /// Retorna `true` se a faixa foi encontrada. Faixas de vídeo são ignoradas. + pub fn toggle_existing_track_excluded(&mut self, id: TrackId) -> bool { + use crate::domain::entities::TrackKind; + match self.find_existing_track_mut(id) { + Some(t) if !matches!(t.kind, TrackKind::Video) => { + t.excluded = !t.excluded; + true + } + _ => false, + } + } + /// Remove uma faixa externa pelo TrackId. Retorna true se a faixa foi encontrada e removida. pub fn remove_track(&mut self, id: TrackId) -> bool { let before = self.tracks.len(); @@ -156,4 +169,30 @@ mod tests { fn sync_transform_has_drift_true() { assert!(SyncTransform::new(0, 0.99983).has_drift()); } + + #[test] + fn toggle_excluded_alterna_estado() { + use crate::domain::entities::TrackKind; + let mut p = make_project("input.mkv", "output.mkv").unwrap(); + let id = TrackId::new(10); + p.existing_tracks.push(MediaTrackInfo::new(id, TrackKind::Audio, "aac", None, 1)); + + assert!(!p.existing_tracks[0].excluded); + assert!(p.toggle_existing_track_excluded(id)); + assert!(p.existing_tracks[0].excluded); + assert!(p.toggle_existing_track_excluded(id)); + assert!(!p.existing_tracks[0].excluded); + } + + #[test] + fn toggle_excluded_ignora_faixa_de_video() { + use crate::domain::entities::TrackKind; + let mut p = make_project("input.mkv", "output.mkv").unwrap(); + let id = TrackId::new(20); + p.existing_tracks.push(MediaTrackInfo::new(id, TrackKind::Video, "h264", None, 0)); + + // retorna false: faixa de vídeo não pode ser excluída + assert!(!p.toggle_existing_track_excluded(id)); + assert!(!p.existing_tracks[0].excluded); + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index ad5e95a..c0076cc 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -662,6 +662,9 @@ impl eframe::App for App { ExistingTrackEvent::DriftChanged(id, scale) => { let _ = AdjustExistingTrackDrift::execute(project, id, scale); } + ExistingTrackEvent::ExcludeToggled(id) => { + project.toggle_existing_track_excluded(id); + } ExistingTrackEvent::ExportRequested(id) => { if let Some(track) = project.existing_tracks.iter().find(|t| t.id == id).cloned() diff --git a/src/ui/components/existing_track_list.rs b/src/ui/components/existing_track_list.rs index ab325b1..e286c0a 100644 --- a/src/ui/components/existing_track_list.rs +++ b/src/ui/components/existing_track_list.rs @@ -11,6 +11,8 @@ pub enum ExistingTrackEvent { DriftChanged(TrackId, f64), /// O usuário solicitou exportar a faixa identificada por `TrackId`. ExportRequested(TrackId), + /// O usuário alternou o estado de exclusão da faixa (não será incluída no arquivo de saída). + ExcludeToggled(TrackId), } /// Lista as faixas detectadas no arquivo de vídeo e permite editar o offset e o drift de cada uma. @@ -64,7 +66,7 @@ impl ExistingTrackList { .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 { 8 } else { 7 }; egui::Grid::new("existing_tracks_grid") .num_columns(num_cols) .striped(true) @@ -77,11 +79,12 @@ impl ExistingTrackList { if mkvmerge_available { ui.strong("Velocidade (%)"); } - ui.strong(""); + ui.strong(""); // exportar + ui.strong(""); // excluir ui.end_row(); for track in tracks { - let editable = matches!(track.kind, TrackKind::Audio | TrackKind::Subtitle); + let editable = matches!(track.kind, TrackKind::Audio | TrackKind::Subtitle) && !track.excluded; let kind_label = match track.kind { TrackKind::Video => "Vídeo", TrackKind::Audio => "Áudio", @@ -94,26 +97,37 @@ impl ExistingTrackList { .map(|l| format!(" ({})", l)) .unwrap_or_default(); - ui.label(format!("{}", track.stream_index)); - ui.label(format!("{}{}", kind_label, lang)); - ui.label(&track.codec); + // Cor de texto: esmaecida quando a faixa está excluída do output + let dim_color = egui::Color32::from_gray(120); + let cell_text = |s: String| -> egui::RichText { + let t = egui::RichText::new(s); + if track.excluded { t.color(dim_color).strikethrough() } else { t } + }; + + ui.label(cell_text(track.stream_index.to_string())); + ui.label(cell_text(format!("{}{}", kind_label, lang))); + ui.label(cell_text(track.codec.clone())); // Duração match track.duration_ms { - Some(ms) => ui.label(format_duration(ms)), - None => ui.label("-"), + Some(ms) => ui.label(cell_text(format_duration(ms))), + None => ui.label(cell_text("-".to_string())), }; - // Offset field + // Offset field (desabilitado quando a faixa está excluída) let field = self .offset_fields .entry(track.id.val()) .or_insert_with(|| SyncOffsetField::new(track.offset)); - field.ui(ui, ""); - if let Some(offset) = field.parse() { - if offset != track.offset { - events.push(ExistingTrackEvent::OffsetChanged(track.id, offset)); + ui.add_enabled_ui(!track.excluded, |ui| { + field.ui(ui, ""); + }); + if !track.excluded { + if let Some(offset) = field.parse() { + if offset != track.offset { + events.push(ExistingTrackEvent::OffsetChanged(track.id, offset)); + } } } @@ -201,6 +215,28 @@ impl ExistingTrackList { } } + // Botão de excluir (somente Áudio e Legenda) + match track.kind { + TrackKind::Audio | TrackKind::Subtitle => { + let label = if track.excluded { + egui::RichText::new("↺ Restaurar").color(egui::Color32::from_rgb(255, 160, 0)) + } else { + egui::RichText::new("✖ Excluir").color(egui::Color32::from_rgb(210, 70, 70)) + }; + let tooltip = if track.excluded { + "Restaurar: faixa será incluída no arquivo de saída" + } else { + "Excluir: faixa não será incluída no arquivo de saída" + }; + if ui.add(egui::Button::new(label).small()).on_hover_text(tooltip).clicked() { + events.push(ExistingTrackEvent::ExcludeToggled(track.id)); + } + } + _ => { + ui.label(""); // célula vazia para vídeo/dados + } + } + ui.end_row(); } });