feat: adiciona suporte para excluir faixas de áudio e legenda do arquivo de saída

This commit is contained in:
2026-03-01 15:47:01 -03:00
parent e70612f5ea
commit 8b031f07ec
6 changed files with 205 additions and 20 deletions
+50 -3
View File
@@ -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
);
}
}
+58 -3
View File
@@ -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<u32> = 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::<Vec<_>>()
.join(",");
args.push("--audio-tracks".to_string());
args.push(tids);
}
}
let sub_included: Vec<u32> = 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::<Vec<_>>()
.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);
+5
View File
@@ -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<u64>,
/// 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,
}
}
}
+39
View File
@@ -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);
}
}
+3
View File
@@ -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()
+49 -13
View File
@@ -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();
}
});