feat: adiciona suporte para excluir faixas de áudio e legenda do arquivo de saída
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,28 +97,39 @@ 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));
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drift field (somente para áudio e legenda quando mkvmerge disponível)
|
||||
if mkvmerge_available {
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user