diff --git a/PROGRESS.md b/PROGRESS.md index c59999c..e08dc14 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,7 @@ # Progresso de Implementação **Data:** 28/02/2026 -**Status:** Fases 1–6 concluídas — compilando, testes passando, aplicação executável +**Status:** Fases 1–6 concluídas + exportação de faixa implementada — compilando, testes passando, aplicação executável **Referência:** DEVELOPMENT_PLAN.md v1.0 --- @@ -9,7 +9,10 @@ ## Resumo Executivo Todas as 6 fases do plano de desenvolvimento foram implementadas. O projeto compila sem erros, -26 testes unitários passam e a aplicação pode ser executada com `cargo run`. +31 testes unitários passam e a aplicação pode ser executada com `cargo run`. + +Após a conclusão das fases, foi implementada a funcionalidade de exportação de faixa de áudio +ou legenda diretamente da lista de faixas existentes. --- @@ -61,6 +64,7 @@ src/ │ ├── add_subtitle.rs — adiciona SubtitleTrack ao Project │ ├── adjust_sync.rs — altera SyncOffset de faixa externa │ ├── edit_existing_track_sync.rs — altera SyncOffset de faixa existente +│ ├── export_track.rs — exporta faixa de áudio ou legenda para arquivo │ ├── set_track_language.rs — altera idioma de faixa externa │ └── generate_output.rs — constrói comando e delega ao MediaProcessorPort │ @@ -68,12 +72,12 @@ src/ │ ├── mod.rs │ ├── ffmpeg/ │ │ ├── mod.rs -│ │ ├── command_builder.rs — FfmpegCommandBuilder: Project → Vec +│ │ ├── command_builder.rs — FfmpegCommandBuilder: Project → Vec; build_export() │ │ ├── ffprobe_gateway.rs — FfprobeGateway impl MediaInfoPort │ │ └── ffmpeg_gateway.rs — FfmpegGateway impl MediaProcessorPort │ └── filesystem/ │ ├── mod.rs — RealFileSystem impl FileSystemPort -│ └── file_picker.rs — FilePickerAdapter (diálogos nativos via rfd) +│ └── file_picker.rs — FilePickerAdapter; save_audio(codec), save_subtitle(codec) │ ├── infrastructure/ │ ├── mod.rs @@ -97,7 +101,7 @@ src/ --- -## Testes Unitários (26/26 passando) +## Testes Unitários (31/31 passando) ### Domain — Value Objects | Teste | Resultado | @@ -141,6 +145,15 @@ src/ | `mapa_faixa_externa_de_audio` — `map 1:a` | ✅ | | `metadata_idioma_audio` — `-metadata:s:a:0 language=por` | ✅ | +### Application — ExportTrack +| Teste | Resultado | +|-------|-----------| +| `export_track::exporta_audio_com_c_a_copy` — `-c:a copy` presente, sem `-c copy` genérico | ✅ | +| `export_track::exporta_audio_mapeia_stream_correto` — `0:stream_index` correto | ✅ | +| `export_track::exporta_legenda_sem_c_copy` — nenhuma forma de `-c` para legendas | ✅ | +| `export_track::exporta_legenda_output_e_ultimo_argumento` | ✅ | +| `export_track::rejeita_faixa_de_video` — retorna erro para `TrackKind::Video` | ✅ | + --- ## Dependências (Cargo.toml) @@ -163,12 +176,14 @@ rfd = "0.14" - **`TrackId` é opaco** — não expõe índice interno; mapeamento para `-map N:tipo` feito apenas no adapter - **`Project` rejeita `output == source`** — validado no construtor - **Termos FFmpeg nunca aparecem na UI** — a interface usa linguagem do usuário final +- **Exceção documentada ao RNF-01** — `build_export()` omite `-c copy` exclusivamente para legendas (conversão de container de texto, sem processamento de mídia); comentário inline explica a exceção --- ## O Que Falta (Backlog de Refinamento) ### Funcional +- [x] Exportar faixa de áudio ou legenda existente para arquivo separado - [ ] Exibição de faixas externas adicionadas com opção de remover - [ ] Progresso em tempo real via `run_ffmpeg_async` (infrastructure já implementada, UI usa thread bloqueante por ora) - [ ] Exibição do comando gerado em modo debug diff --git a/src/adapters/ffmpeg/command_builder.rs b/src/adapters/ffmpeg/command_builder.rs index 78d072c..c9533b2 100644 --- a/src/adapters/ffmpeg/command_builder.rs +++ b/src/adapters/ffmpeg/command_builder.rs @@ -1,4 +1,5 @@ -use crate::domain::entities::{Project, Track, TrackKind}; +use crate::domain::entities::{MediaTrackInfo, Project, Track, TrackKind}; +use crate::domain::value_objects::FilePath; /// Constrói os argumentos do FFmpeg a partir de um Project. /// @@ -126,14 +127,59 @@ impl FfmpegCommandBuilder { args } + + /// Constrói args para exportar uma faixa individual de um arquivo de mídia. + /// + /// - Áudio: usa `-c:a copy` para preservar o stream sem reencoding (RNF-01 mantido). + /// - Legenda: **omite** `-c copy` — o FFmpeg precisa converter o container de texto + /// (ex: stream `subrip` dentro de MKV → arquivo `.srt`). Essa é a única + /// exceção documentada ao invariante RNF-01, restrita a este método. + /// - Vídeo/outros: usa `-c copy`. + /// + /// O `-y` é incluído para evitar que o processo bloqueie ao sobrescrever um arquivo + /// (a confirmação é responsabilidade do diálogo de salvamento da UI). + pub fn build_export( + source: &FilePath, + track: &MediaTrackInfo, + output: &FilePath, + ) -> Vec { + let mut args: Vec = Vec::new(); + + args.push("-i".to_string()); + args.push(source.to_string_lossy().to_string()); + + args.push("-map".to_string()); + args.push(format!("0:{}", track.stream_index)); + + match &track.kind { + TrackKind::Audio => { + args.push("-c:a".to_string()); + args.push("copy".to_string()); + } + TrackKind::Subtitle => { + // Sem -c copy: o FFmpeg extrai a legenda convertendo o container de texto + // automaticamente. Não há reencoding de mídia envolvido. + } + _ => { + args.push("-c".to_string()); + args.push("copy".to_string()); + } + } + + // Sobrescrever sem prompt interativo (o diálogo de salvamento já confirmou) + args.push("-y".to_string()); + args.push(output.to_string_lossy().to_string()); + + args + } } #[cfg(test)] mod tests { use super::*; + use crate::application::use_cases::add_audio_track::AddAudioTrack; use crate::domain::entities::{MkvOutput, Project, VideoFile}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; - use crate::application::use_cases::add_audio_track::AddAudioTrack; fn base_project() -> Project { Project::new( @@ -188,7 +234,11 @@ mod tests { .unwrap(); let args = FfmpegCommandBuilder::build(&project); - assert!(args.contains(&"1:a".to_string()), "faltou map 1:a — args: {:?}", args); + assert!( + args.contains(&"1:a".to_string()), + "faltou map 1:a — args: {:?}", + args + ); } #[test] diff --git a/src/adapters/filesystem/file_picker.rs b/src/adapters/filesystem/file_picker.rs index 9eb65e1..0af06cb 100644 --- a/src/adapters/filesystem/file_picker.rs +++ b/src/adapters/filesystem/file_picker.rs @@ -1,5 +1,5 @@ -use std::path::PathBuf; use crate::domain::value_objects::FilePath; +use std::path::PathBuf; /// Abre diálogos de seleção de arquivo usando a crate `rfd`. pub struct FilePickerAdapter; @@ -16,7 +16,10 @@ impl FilePickerAdapter { /// Abre diálogo de seleção de arquivo de áudio. pub fn pick_audio() -> Option { rfd::FileDialog::new() - .add_filter("Áudio", &["aac", "mp3", "flac", "ogg", "m4a", "wav", "ac3", "dts"]) + .add_filter( + "Áudio", + &["aac", "mp3", "flac", "ogg", "m4a", "wav", "ac3", "dts"], + ) .pick_file() .map(|p: PathBuf| FilePath::from(p)) } @@ -41,4 +44,48 @@ impl FilePickerAdapter { FilePath::from(p) }) } + + /// Abre diálogo de salvar faixa de áudio exportada. + /// + /// A extensão padrão é inferida a partir do codec detectado pelo ffprobe + /// (ex: `aac` → `.aac`, `vorbis` → `.ogg`). Cai em `.mka` para codecs desconhecidos. + pub fn save_audio(codec: &str) -> Option { + let (ext, label) = match codec { + "aac" => ("aac", "AAC"), + "ac3" => ("ac3", "AC3"), + "eac3" => ("eac3", "E-AC3"), + "dts" => ("dts", "DTS"), + "mp3" => ("mp3", "MP3"), + "vorbis" => ("ogg", "Ogg Vorbis"), + "opus" => ("opus", "Opus"), + "flac" => ("flac", "FLAC"), + "truehd" => ("thd", "TrueHD"), + "pcm_s16le" | "pcm_s24le" | "pcm_s32le" => ("wav", "WAV"), + _ => ("mka", "Matroska Audio"), + }; + rfd::FileDialog::new() + .add_filter(label, &[ext]) + .set_file_name(format!("track.{}", ext)) + .save_file() + .map(FilePath::from) + } + + /// Abre diálogo de salvar faixa de legenda exportada. + /// + /// A extensão padrão é inferida a partir do codec detectado pelo ffprobe + /// (ex: `subrip` → `.srt`, `ass` → `.ass`). Cai em `.srt` para codecs desconhecidos. + pub fn save_subtitle(codec: &str) -> Option { + let (ext, label) = match codec { + "ass" | "ssa" => ("ass", "SubStation Alpha"), + "webvtt" => ("vtt", "WebVTT"), + "dvd_subtitle" => ("sub", "DVD Subtitle"), + "hdmv_pgs_subtitle" => ("sup", "PGS Subtitle"), + _ => ("srt", "SubRip"), + }; + rfd::FileDialog::new() + .add_filter(label, &[ext]) + .set_file_name(format!("subtitle.{}", ext)) + .save_file() + .map(FilePath::from) + } } diff --git a/src/application/use_cases/export_track.rs b/src/application/use_cases/export_track.rs new file mode 100644 index 0000000..99a30d8 --- /dev/null +++ b/src/application/use_cases/export_track.rs @@ -0,0 +1,154 @@ +use anyhow::{bail, Result}; +use crate::application::ports::MediaProcessorPort; +use crate::domain::entities::{MediaTrackInfo, TrackKind}; +use crate::domain::value_objects::FilePath; + +/// Exporta uma faixa de áudio ou legenda já presente no arquivo de mídia para um arquivo separado. +/// +/// O comando gerado delega todo o trabalho ao FFmpeg via `MediaProcessorPort`. +/// Nenhum reencoding é realizado para faixas de áudio (RNF-01 mantido). +/// Para legendas, o FFmpeg realiza apenas conversão de container de texto — +/// não há processamento de mídia envolvido. +pub struct ExportTrack; + +impl ExportTrack { + /// Executa a exportação. + /// + /// # Parâmetros + /// - `source` — caminho do arquivo de mídia de origem + /// - `track` — faixa a ser exportada (deve ser `Audio` ou `Subtitle`) + /// - `output` — caminho do arquivo de destino + /// - `processor` — port de execução do FFmpeg + /// + /// # Erros + /// Retorna erro se a faixa for do tipo `Video` ou `Data`, ou se o FFmpeg falhar. + pub fn execute( + source: &FilePath, + track: &MediaTrackInfo, + output: &FilePath, + processor: &dyn MediaProcessorPort, + ) -> Result<()> { + match track.kind { + TrackKind::Video | TrackKind::Data => { + bail!( + "Exportação não suportada para faixas do tipo '{}'. \ + Apenas Áudio e Legenda podem ser exportados.", + track.kind + ); + } + TrackKind::Audio | TrackKind::Subtitle => {} + } + + use crate::adapters::ffmpeg::command_builder::FfmpegCommandBuilder; + let args = FfmpegCommandBuilder::build_export(source, track, output); + processor.execute(args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use crate::application::ports::MediaProcessorPort; + use crate::domain::entities::{MediaTrackInfo, TrackKind}; + use crate::domain::value_objects::{FilePath, TrackId}; + use std::cell::RefCell; + + struct MockProcessor { + captured_args: RefCell>, + } + + impl MediaProcessorPort for MockProcessor { + fn execute(&self, args: Vec) -> Result<()> { + *self.captured_args.borrow_mut() = args; + Ok(()) + } + } + + fn audio_track() -> MediaTrackInfo { + MediaTrackInfo::new( + TrackId::new(1), + TrackKind::Audio, + "aac", + None, + 1, + ) + } + + fn subtitle_track() -> MediaTrackInfo { + MediaTrackInfo::new( + TrackId::new(2), + TrackKind::Subtitle, + "subrip", + None, + 2, + ) + } + + #[test] + fn exporta_audio_com_c_a_copy() { + let mock = MockProcessor { captured_args: RefCell::new(Vec::new()) }; + let source = FilePath::from("video.mkv"); + let output = FilePath::from("track.aac"); + + ExportTrack::execute(&source, &audio_track(), &output, &mock).unwrap(); + + let args = mock.captured_args.borrow(); + assert!(args.contains(&"-c:a".to_string()), "faltou -c:a — args: {:?}", args); + assert!(args.contains(&"copy".to_string()), "faltou copy — args: {:?}", args); + // Não deve ter -c copy genérico junto com -c:a copy + let c_pos = args.iter().position(|a| a == "-c"); + assert!(c_pos.is_none(), "não deve haver -c genérico para áudio — args: {:?}", args); + } + + #[test] + fn exporta_audio_mapeia_stream_correto() { + let mock = MockProcessor { captured_args: RefCell::new(Vec::new()) }; + let source = FilePath::from("video.mkv"); + let output = FilePath::from("track.aac"); + let track = audio_track(); // stream_index = 1 + + ExportTrack::execute(&source, &track, &output, &mock).unwrap(); + + let args = mock.captured_args.borrow(); + let map_pos = args.iter().position(|a| a == "-map").expect("faltou -map"); + assert_eq!(args[map_pos + 1], "0:1", "stream index incorreto — args: {:?}", args); + } + + #[test] + fn exporta_legenda_sem_c_copy() { + let mock = MockProcessor { captured_args: RefCell::new(Vec::new()) }; + let source = FilePath::from("video.mkv"); + let output = FilePath::from("subtitle.srt"); + + ExportTrack::execute(&source, &subtitle_track(), &output, &mock).unwrap(); + + let args = mock.captured_args.borrow(); + // Nenhuma forma de -c deve aparecer para legendas + assert!(!args.contains(&"-c:a".to_string()), "-c:a não deve aparecer para legenda"); + assert!(!args.iter().any(|a| a == "-c"), "-c não deve aparecer para legenda — args: {:?}", args); + } + + #[test] + fn exporta_legenda_output_e_ultimo_argumento() { + let mock = MockProcessor { captured_args: RefCell::new(Vec::new()) }; + let source = FilePath::from("video.mkv"); + let output = FilePath::from("subtitle.srt"); + + ExportTrack::execute(&source, &subtitle_track(), &output, &mock).unwrap(); + + let args = mock.captured_args.borrow(); + assert_eq!(args.last().unwrap(), "subtitle.srt", "output deve ser o último arg"); + } + + #[test] + fn rejeita_faixa_de_video() { + let mock = MockProcessor { captured_args: RefCell::new(Vec::new()) }; + let source = FilePath::from("video.mkv"); + let output = FilePath::from("video_out.mkv"); + let video_track = MediaTrackInfo::new(TrackId::new(0), TrackKind::Video, "h264", None, 0); + + let result = ExportTrack::execute(&source, &video_track, &output, &mock); + assert!(result.is_err(), "deveria rejeitar faixa de vídeo"); + } +} diff --git a/src/application/use_cases/mod.rs b/src/application/use_cases/mod.rs index 6543083..c0457d2 100644 --- a/src/application/use_cases/mod.rs +++ b/src/application/use_cases/mod.rs @@ -2,6 +2,7 @@ pub mod add_audio_track; pub mod add_subtitle; pub mod adjust_sync; pub mod edit_existing_track_sync; +pub mod export_track; pub mod generate_output; pub mod load_media_info; pub mod set_track_language; diff --git a/src/ui/app.rs b/src/ui/app.rs index d3f4de6..332f487 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,10 +1,12 @@ use eframe::egui; use std::sync::mpsc; use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway}; +use crate::adapters::filesystem::file_picker::FilePickerAdapter; use crate::application::ports::MediaProcessorPort; use crate::application::use_cases::{ add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle, - edit_existing_track_sync::EditExistingTrackSync, load_media_info::LoadMediaInfo, + edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack, + load_media_info::LoadMediaInfo, }; use crate::domain::entities::{MkvOutput, Project, VideoFile}; use crate::domain::value_objects::FilePath; @@ -200,10 +202,48 @@ impl eframe::App for App { self.existing_track_list.ui(ui, &project.existing_tracks); for event in events { use crate::ui::components::existing_track_list::ExistingTrackEvent; + use crate::domain::entities::TrackKind; match event { ExistingTrackEvent::OffsetChanged(id, offset) => { let _ = EditExistingTrackSync::execute(project, id, offset); } + ExistingTrackEvent::ExportRequested(id) => { + // Localiza a faixa pelo id + if let Some(track) = project + .existing_tracks + .iter() + .find(|t| t.id == id) + .cloned() + { + let maybe_output = match track.kind { + TrackKind::Audio => FilePickerAdapter::save_audio(&track.codec), + TrackKind::Subtitle => FilePickerAdapter::save_subtitle(&track.codec), + _ => None, + }; + if let Some(output_path) = maybe_output { + let gateway = FfmpegGateway; + match ExportTrack::execute( + &project.source.path, + &track, + &output_path, + &gateway, + ) { + Ok(_) => { + self.execution_panel.state = + ExecutionState::Success; + self.execution_panel.add_log(format!( + "Faixa exportada para: {}", + output_path.to_string_lossy() + )); + } + Err(e) => { + self.execution_panel.state = + ExecutionState::Error(e.to_string()); + } + } + } + } + } } } diff --git a/src/ui/components/existing_track_list.rs b/src/ui/components/existing_track_list.rs index 837f0f3..a484bcc 100644 --- a/src/ui/components/existing_track_list.rs +++ b/src/ui/components/existing_track_list.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; /// Evento emitido por interações com a lista de faixas existentes. pub enum ExistingTrackEvent { OffsetChanged(TrackId, SyncOffset), + /// O usuário solicitou exportar a faixa identificada por `TrackId`. + ExportRequested(TrackId), } /// Lista as faixas detectadas no arquivo de vídeo e permite editar o offset de cada uma. @@ -47,13 +49,14 @@ impl ExistingTrackList { } egui::Grid::new("existing_tracks_grid") - .num_columns(4) + .num_columns(5) .striped(true) .show(ui, |ui| { ui.strong("#"); ui.strong("Tipo"); ui.strong("Codec"); ui.strong("Atraso"); + ui.strong(""); ui.end_row(); for track in tracks { @@ -86,6 +89,18 @@ impl ExistingTrackList { } } + // Botão de exportar (somente Áudio e Legenda) + match track.kind { + TrackKind::Audio | TrackKind::Subtitle => { + if ui.small_button("⬇ Exportar").clicked() { + events.push(ExistingTrackEvent::ExportRequested(track.id)); + } + } + _ => { + ui.label(""); // célula vazia para alinhamento + } + } + ui.end_row(); } });