feat: implementa funcionalidade de exportação de faixas de áudio e legenda
This commit is contained in:
+20
-5
@@ -1,7 +1,7 @@
|
|||||||
# Progresso de Implementação
|
# Progresso de Implementação
|
||||||
|
|
||||||
**Data:** 28/02/2026
|
**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
|
**Referência:** DEVELOPMENT_PLAN.md v1.0
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -9,7 +9,10 @@
|
|||||||
## Resumo Executivo
|
## Resumo Executivo
|
||||||
|
|
||||||
Todas as 6 fases do plano de desenvolvimento foram implementadas. O projeto compila sem erros,
|
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
|
│ ├── add_subtitle.rs — adiciona SubtitleTrack ao Project
|
||||||
│ ├── adjust_sync.rs — altera SyncOffset de faixa externa
|
│ ├── adjust_sync.rs — altera SyncOffset de faixa externa
|
||||||
│ ├── edit_existing_track_sync.rs — altera SyncOffset de faixa existente
|
│ ├── 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
|
│ ├── set_track_language.rs — altera idioma de faixa externa
|
||||||
│ └── generate_output.rs — constrói comando e delega ao MediaProcessorPort
|
│ └── generate_output.rs — constrói comando e delega ao MediaProcessorPort
|
||||||
│
|
│
|
||||||
@@ -68,12 +72,12 @@ src/
|
|||||||
│ ├── mod.rs
|
│ ├── mod.rs
|
||||||
│ ├── ffmpeg/
|
│ ├── ffmpeg/
|
||||||
│ │ ├── mod.rs
|
│ │ ├── mod.rs
|
||||||
│ │ ├── command_builder.rs — FfmpegCommandBuilder: Project → Vec<String>
|
│ │ ├── command_builder.rs — FfmpegCommandBuilder: Project → Vec<String>; build_export()
|
||||||
│ │ ├── ffprobe_gateway.rs — FfprobeGateway impl MediaInfoPort
|
│ │ ├── ffprobe_gateway.rs — FfprobeGateway impl MediaInfoPort
|
||||||
│ │ └── ffmpeg_gateway.rs — FfmpegGateway impl MediaProcessorPort
|
│ │ └── ffmpeg_gateway.rs — FfmpegGateway impl MediaProcessorPort
|
||||||
│ └── filesystem/
|
│ └── filesystem/
|
||||||
│ ├── mod.rs — RealFileSystem impl FileSystemPort
|
│ ├── 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/
|
├── infrastructure/
|
||||||
│ ├── mod.rs
|
│ ├── mod.rs
|
||||||
@@ -97,7 +101,7 @@ src/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testes Unitários (26/26 passando)
|
## Testes Unitários (31/31 passando)
|
||||||
|
|
||||||
### Domain — Value Objects
|
### Domain — Value Objects
|
||||||
| Teste | Resultado |
|
| Teste | Resultado |
|
||||||
@@ -141,6 +145,15 @@ src/
|
|||||||
| `mapa_faixa_externa_de_audio` — `map 1:a` | ✅ |
|
| `mapa_faixa_externa_de_audio` — `map 1:a` | ✅ |
|
||||||
| `metadata_idioma_audio` — `-metadata:s:a:0 language=por` | ✅ |
|
| `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)
|
## 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
|
- **`TrackId` é opaco** — não expõe índice interno; mapeamento para `-map N:tipo` feito apenas no adapter
|
||||||
- **`Project` rejeita `output == source`** — validado no construtor
|
- **`Project` rejeita `output == source`** — validado no construtor
|
||||||
- **Termos FFmpeg nunca aparecem na UI** — a interface usa linguagem do usuário final
|
- **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)
|
## O Que Falta (Backlog de Refinamento)
|
||||||
|
|
||||||
### Funcional
|
### Funcional
|
||||||
|
- [x] Exportar faixa de áudio ou legenda existente para arquivo separado
|
||||||
- [ ] Exibição de faixas externas adicionadas com opção de remover
|
- [ ] 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)
|
- [ ] 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
|
- [ ] Exibição do comando gerado em modo debug
|
||||||
|
|||||||
@@ -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.
|
/// Constrói os argumentos do FFmpeg a partir de um Project.
|
||||||
///
|
///
|
||||||
@@ -126,14 +127,59 @@ impl FfmpegCommandBuilder {
|
|||||||
|
|
||||||
args
|
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<String> {
|
||||||
|
let mut args: Vec<String> = 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::application::use_cases::add_audio_track::AddAudioTrack;
|
||||||
use crate::domain::entities::{MkvOutput, Project, VideoFile};
|
use crate::domain::entities::{MkvOutput, Project, VideoFile};
|
||||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||||
use crate::application::use_cases::add_audio_track::AddAudioTrack;
|
|
||||||
|
|
||||||
fn base_project() -> Project {
|
fn base_project() -> Project {
|
||||||
Project::new(
|
Project::new(
|
||||||
@@ -188,7 +234,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let args = FfmpegCommandBuilder::build(&project);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use crate::domain::value_objects::FilePath;
|
use crate::domain::value_objects::FilePath;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Abre diálogos de seleção de arquivo usando a crate `rfd`.
|
/// Abre diálogos de seleção de arquivo usando a crate `rfd`.
|
||||||
pub struct FilePickerAdapter;
|
pub struct FilePickerAdapter;
|
||||||
@@ -16,7 +16,10 @@ impl FilePickerAdapter {
|
|||||||
/// Abre diálogo de seleção de arquivo de áudio.
|
/// Abre diálogo de seleção de arquivo de áudio.
|
||||||
pub fn pick_audio() -> Option<FilePath> {
|
pub fn pick_audio() -> Option<FilePath> {
|
||||||
rfd::FileDialog::new()
|
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()
|
.pick_file()
|
||||||
.map(|p: PathBuf| FilePath::from(p))
|
.map(|p: PathBuf| FilePath::from(p))
|
||||||
}
|
}
|
||||||
@@ -41,4 +44,48 @@ impl FilePickerAdapter {
|
|||||||
FilePath::from(p)
|
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<FilePath> {
|
||||||
|
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<FilePath> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaProcessorPort for MockProcessor {
|
||||||
|
fn execute(&self, args: Vec<String>) -> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod add_audio_track;
|
|||||||
pub mod add_subtitle;
|
pub mod add_subtitle;
|
||||||
pub mod adjust_sync;
|
pub mod adjust_sync;
|
||||||
pub mod edit_existing_track_sync;
|
pub mod edit_existing_track_sync;
|
||||||
|
pub mod export_track;
|
||||||
pub mod generate_output;
|
pub mod generate_output;
|
||||||
pub mod load_media_info;
|
pub mod load_media_info;
|
||||||
pub mod set_track_language;
|
pub mod set_track_language;
|
||||||
|
|||||||
+41
-1
@@ -1,10 +1,12 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
|
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
|
||||||
|
use crate::adapters::filesystem::file_picker::FilePickerAdapter;
|
||||||
use crate::application::ports::MediaProcessorPort;
|
use crate::application::ports::MediaProcessorPort;
|
||||||
use crate::application::use_cases::{
|
use crate::application::use_cases::{
|
||||||
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
|
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::entities::{MkvOutput, Project, VideoFile};
|
||||||
use crate::domain::value_objects::FilePath;
|
use crate::domain::value_objects::FilePath;
|
||||||
@@ -200,10 +202,48 @@ impl eframe::App for App {
|
|||||||
self.existing_track_list.ui(ui, &project.existing_tracks);
|
self.existing_track_list.ui(ui, &project.existing_tracks);
|
||||||
for event in events {
|
for event in events {
|
||||||
use crate::ui::components::existing_track_list::ExistingTrackEvent;
|
use crate::ui::components::existing_track_list::ExistingTrackEvent;
|
||||||
|
use crate::domain::entities::TrackKind;
|
||||||
match event {
|
match event {
|
||||||
ExistingTrackEvent::OffsetChanged(id, offset) => {
|
ExistingTrackEvent::OffsetChanged(id, offset) => {
|
||||||
let _ = EditExistingTrackSync::execute(project, 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use std::collections::HashMap;
|
|||||||
/// Evento emitido por interações com a lista de faixas existentes.
|
/// Evento emitido por interações com a lista de faixas existentes.
|
||||||
pub enum ExistingTrackEvent {
|
pub enum ExistingTrackEvent {
|
||||||
OffsetChanged(TrackId, SyncOffset),
|
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.
|
/// 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")
|
egui::Grid::new("existing_tracks_grid")
|
||||||
.num_columns(4)
|
.num_columns(5)
|
||||||
.striped(true)
|
.striped(true)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.strong("#");
|
ui.strong("#");
|
||||||
ui.strong("Tipo");
|
ui.strong("Tipo");
|
||||||
ui.strong("Codec");
|
ui.strong("Codec");
|
||||||
ui.strong("Atraso");
|
ui.strong("Atraso");
|
||||||
|
ui.strong("");
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
||||||
for track in tracks {
|
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();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user