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 ────────────────────────────────────────────────────────────────
|
// ── Maps ────────────────────────────────────────────────────────────────
|
||||||
// Faixas existentes do source
|
// Faixas existentes do source (excluídas pelo usuário são omitidas)
|
||||||
for track in &project.existing_tracks {
|
for track in project.existing_tracks.iter().filter(|t| !t.excluded) {
|
||||||
args.push("-map".to_string());
|
args.push("-map".to_string());
|
||||||
if track.offset.is_zero() {
|
if track.offset.is_zero() {
|
||||||
args.push(format!("0:{}", track.stream_index));
|
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() {
|
if project.existing_tracks.is_empty() {
|
||||||
args.push("-map".to_string());
|
args.push("-map".to_string());
|
||||||
args.push("0".to_string());
|
args.push("0".to_string());
|
||||||
@@ -364,4 +366,49 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(args.contains(&"language=por".to_string()));
|
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;
|
use crate::domain::value_objects::SyncOffset;
|
||||||
|
|
||||||
/// Constrói os argumentos de linha de comando para o `mkvmerge`.
|
/// Constrói os argumentos de linha de comando para o `mkvmerge`.
|
||||||
@@ -22,8 +22,63 @@ impl MkvmergeCommandBuilder {
|
|||||||
args.push("-o".to_string());
|
args.push("-o".to_string());
|
||||||
args.push(project.output.path.to_string());
|
args.push(project.output.path.to_string());
|
||||||
|
|
||||||
// 2. Opções de faixas existentes (stream_index é o TID no arquivo fonte)
|
// 2. Seleção de faixas existentes (exclusão pelo usuário)
|
||||||
for track in &project.existing_tracks {
|
// 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;
|
let needs_sync = track.offset.as_ms() != 0 || (track.drift_scale - 1.0).abs() > 1e-9;
|
||||||
if needs_sync {
|
if needs_sync {
|
||||||
let (num, den) = scale_to_rational(track.drift_scale);
|
let (num, den) = scale_to_rational(track.drift_scale);
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ pub struct MediaTrackInfo {
|
|||||||
pub drift_scale: f64,
|
pub drift_scale: f64,
|
||||||
/// Duração da faixa em milissegundos (lida via ffprobe). `None` se não disponível.
|
/// Duração da faixa em milissegundos (lida via ffprobe). `None` se não disponível.
|
||||||
pub duration_ms: Option<u64>,
|
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 {
|
impl MediaTrackInfo {
|
||||||
@@ -56,6 +60,7 @@ impl MediaTrackInfo {
|
|||||||
stream_index,
|
stream_index,
|
||||||
drift_scale: 1.0,
|
drift_scale: 1.0,
|
||||||
duration_ms: None,
|
duration_ms: None,
|
||||||
|
excluded: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ impl Project {
|
|||||||
self.existing_tracks.iter_mut().find(|t| t.id == id)
|
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.
|
/// Remove uma faixa externa pelo TrackId. Retorna true se a faixa foi encontrada e removida.
|
||||||
pub fn remove_track(&mut self, id: TrackId) -> bool {
|
pub fn remove_track(&mut self, id: TrackId) -> bool {
|
||||||
let before = self.tracks.len();
|
let before = self.tracks.len();
|
||||||
@@ -156,4 +169,30 @@ mod tests {
|
|||||||
fn sync_transform_has_drift_true() {
|
fn sync_transform_has_drift_true() {
|
||||||
assert!(SyncTransform::new(0, 0.99983).has_drift());
|
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) => {
|
ExistingTrackEvent::DriftChanged(id, scale) => {
|
||||||
let _ = AdjustExistingTrackDrift::execute(project, id, scale);
|
let _ = AdjustExistingTrackDrift::execute(project, id, scale);
|
||||||
}
|
}
|
||||||
|
ExistingTrackEvent::ExcludeToggled(id) => {
|
||||||
|
project.toggle_existing_track_excluded(id);
|
||||||
|
}
|
||||||
ExistingTrackEvent::ExportRequested(id) => {
|
ExistingTrackEvent::ExportRequested(id) => {
|
||||||
if let Some(track) =
|
if let Some(track) =
|
||||||
project.existing_tracks.iter().find(|t| t.id == id).cloned()
|
project.existing_tracks.iter().find(|t| t.id == id).cloned()
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ pub enum ExistingTrackEvent {
|
|||||||
DriftChanged(TrackId, f64),
|
DriftChanged(TrackId, f64),
|
||||||
/// O usuário solicitou exportar a faixa identificada por `TrackId`.
|
/// O usuário solicitou exportar a faixa identificada por `TrackId`.
|
||||||
ExportRequested(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.
|
/// 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))
|
.find(|t| matches!(t.kind, TrackKind::Video))
|
||||||
.and_then(|t| t.duration_ms);
|
.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")
|
egui::Grid::new("existing_tracks_grid")
|
||||||
.num_columns(num_cols)
|
.num_columns(num_cols)
|
||||||
.striped(true)
|
.striped(true)
|
||||||
@@ -77,11 +79,12 @@ impl ExistingTrackList {
|
|||||||
if mkvmerge_available {
|
if mkvmerge_available {
|
||||||
ui.strong("Velocidade (%)");
|
ui.strong("Velocidade (%)");
|
||||||
}
|
}
|
||||||
ui.strong("");
|
ui.strong(""); // exportar
|
||||||
|
ui.strong(""); // excluir
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
||||||
for track in tracks {
|
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 {
|
let kind_label = match track.kind {
|
||||||
TrackKind::Video => "Vídeo",
|
TrackKind::Video => "Vídeo",
|
||||||
TrackKind::Audio => "Áudio",
|
TrackKind::Audio => "Áudio",
|
||||||
@@ -94,26 +97,37 @@ impl ExistingTrackList {
|
|||||||
.map(|l| format!(" ({})", l))
|
.map(|l| format!(" ({})", l))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
ui.label(format!("{}", track.stream_index));
|
// Cor de texto: esmaecida quando a faixa está excluída do output
|
||||||
ui.label(format!("{}{}", kind_label, lang));
|
let dim_color = egui::Color32::from_gray(120);
|
||||||
ui.label(&track.codec);
|
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
|
// Duração
|
||||||
match track.duration_ms {
|
match track.duration_ms {
|
||||||
Some(ms) => ui.label(format_duration(ms)),
|
Some(ms) => ui.label(cell_text(format_duration(ms))),
|
||||||
None => ui.label("-"),
|
None => ui.label(cell_text("-".to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Offset field
|
// Offset field (desabilitado quando a faixa está excluída)
|
||||||
let field = self
|
let field = self
|
||||||
.offset_fields
|
.offset_fields
|
||||||
.entry(track.id.val())
|
.entry(track.id.val())
|
||||||
.or_insert_with(|| SyncOffsetField::new(track.offset));
|
.or_insert_with(|| SyncOffsetField::new(track.offset));
|
||||||
|
|
||||||
field.ui(ui, "");
|
ui.add_enabled_ui(!track.excluded, |ui| {
|
||||||
if let Some(offset) = field.parse() {
|
field.ui(ui, "");
|
||||||
if offset != track.offset {
|
});
|
||||||
events.push(ExistingTrackEvent::OffsetChanged(track.id, offset));
|
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();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user