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. /// /// Invariantes: /// - `-c copy` é **sempre** emitido (RNF-01 — nunca opcional) /// - `SyncOffset(ms)` → `-itsoffset {seconds:.3}` (conversão feita somente aqui) /// - `TrackId` → `-map N:tipo` (mapeamento feito somente aqui) pub struct FfmpegCommandBuilder; impl FfmpegCommandBuilder { /// Constrói o vetor de argumentos para o FFmpeg. Não inclui o binário "ffmpeg" no início. pub fn build(project: &Project) -> Vec { let mut args: Vec = Vec::new(); // Sobrescreve o arquivo de saída sem prompt interativo. // Sem isso o FFmpeg aguarda [y/N] no stdin e o processo trava. args.push("-y".to_string()); // ── Inputs ────────────────────────────────────────────────────────────── // Input 0: arquivo fonte (sempre sem itsoffset próprio) args.push("-i".to_string()); args.push(project.source.path.to_string_lossy().to_string()); // Para faixas existentes com offset não-zero, adicionamos o source novamente // como input extra com itsoffset. Agrupamos por valor de offset para minimizar inputs. let mut offset_inputs: Vec<(i64, usize)> = Vec::new(); let mut next_input_idx = 1usize; for track in &project.existing_tracks { if !track.offset.is_zero() { let ms = track.offset.as_ms(); if !offset_inputs.iter().any(|(v, _)| *v == ms) { let seconds = ms as f64 / 1000.0; args.push("-itsoffset".to_string()); args.push(format!("{:.3}", seconds)); args.push("-i".to_string()); args.push(project.source.path.to_string_lossy().to_string()); offset_inputs.push((ms, next_input_idx)); next_input_idx += 1; // contador de inputs usados } } } // Inputs externos (faixas adicionadas pelo usuário) let external_input_start = next_input_idx; for track in &project.tracks { let offset = track.offset(); if !offset.is_zero() { let seconds = offset.as_ms() as f64 / 1000.0; args.push("-itsoffset".to_string()); args.push(format!("{:.3}", seconds)); } args.push("-i".to_string()); let path = match track { Track::Audio(t) => t.path.to_string_lossy().to_string(), Track::Subtitle(t) => t.path.to_string_lossy().to_string(), }; args.push(path); } // ── Maps ──────────────────────────────────────────────────────────────── // 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)); } else { let ms = track.offset.as_ms(); if let Some((_, idx)) = offset_inputs.iter().find(|(v, _)| *v == ms) { args.push(format!("{}:{}", idx, track.stream_index)); } } } // 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()); } // Faixas externas do usuário let mut ext_idx = external_input_start; for track in &project.tracks { args.push("-map".to_string()); match track { Track::Audio(_) => args.push(format!("{}:a", ext_idx)), Track::Subtitle(_) => args.push(format!("{}:s", ext_idx)), } ext_idx += 1; } // ── Opções de muxing ───────────────────────────────────────────────────── // -max_interleave_delta 0: remove o limite do buffer de intercalação (padrão: 10s). // Sem isso, quando o áudio externo fica à frente do vídeo, o FFmpeg descarta // pacotes silenciosamente, gerando trechos mudos no output. Com 0, nenhum // pacote é descartado por excesso de delta — apenas memória RAM é consumida. // -avoid_negative_ts make_zero: normaliza timestamps negativos comuns em M4A/AAC // de serviços de streaming (DASH), que causam dessincronização no muxer MKV. args.push("-max_interleave_delta".to_string()); args.push("0".to_string()); args.push("-avoid_negative_ts".to_string()); args.push("make_zero".to_string()); // ── -c copy (SEMPRE — invariante central do produto) ──────────────────── args.push("-c".to_string()); args.push("copy".to_string()); // ── Metadados de idioma ────────────────────────────────────────────────── let existing_audio_count = project .existing_tracks .iter() .filter(|t| matches!(t.kind, TrackKind::Audio)) .count(); let existing_sub_count = project .existing_tracks .iter() .filter(|t| matches!(t.kind, TrackKind::Subtitle)) .count(); let mut ext_audio_idx = existing_audio_count; let mut ext_sub_idx = existing_sub_count; for track in &project.tracks { match track { Track::Audio(t) => { args.push(format!("-metadata:s:a:{}", ext_audio_idx)); args.push(format!("language={}", t.language)); // Sempre emite title (mesmo vazio) para sobrescrever qualquer título // herdado do arquivo fonte (ex: "ISO Media file produced by Google Inc."). // Players como Jellyfin mobile usam o title para identificar a faixa. args.push(format!("-metadata:s:a:{}", ext_audio_idx)); args.push(format!("title={}", t.title)); ext_audio_idx += 1; } Track::Subtitle(t) => { args.push(format!("-metadata:s:s:{}", ext_sub_idx)); args.push(format!("language={}", t.language)); args.push(format!("-metadata:s:s:{}", ext_sub_idx)); args.push(format!("title={}", t.title)); ext_sub_idx += 1; } } } // ── Disposições (faixa padrão) ──────────────────────────────────────── // Quando o usuário marca uma faixa externa como "padrão", é necessário: // 1. Remover o flag `default` de todas as faixas existentes do mesmo tipo // (o arquivo fonte geralmente já tem `disposition:default=1` na sua // primeira faixa de áudio, e Jellyfin/Kodi respeitam esse flag). // 2. Emitir `-disposition:a/s:{idx} default` para a faixa externa marcada. // // Índices de disposição seguem a ordem de streams do tipo no output: // - Faixas existentes (existing_tracks) vêm primeiro, na ordem do map. // - Faixas externas (tracks) vêm depois, na ordem de adição. let existing_audio_count_disp = project .existing_tracks .iter() .filter(|t| matches!(t.kind, TrackKind::Audio)) .count(); let existing_sub_count_disp = project .existing_tracks .iter() .filter(|t| matches!(t.kind, TrackKind::Subtitle)) .count(); let any_ext_audio_default = project .tracks .iter() .any(|t| matches!(t, Track::Audio(a) if a.is_default)); let any_ext_sub_default = project .tracks .iter() .any(|t| matches!(t, Track::Subtitle(s) if s.is_default)); // Remove default das faixas existentes quando uma faixa externa é padrão if any_ext_audio_default { for i in 0..existing_audio_count_disp { args.push(format!("-disposition:a:{}", i)); args.push("0".to_string()); } } if any_ext_sub_default { for i in 0..existing_sub_count_disp { args.push(format!("-disposition:s:{}", i)); args.push("0".to_string()); } } // Emite `default` para as faixas externas marcadas let mut disp_audio_idx = 0usize; let mut disp_sub_idx = 0usize; for track in &project.tracks { match track { Track::Audio(a) => { if a.is_default { args.push(format!( "-disposition:a:{}", existing_audio_count_disp + disp_audio_idx )); args.push("default".to_string()); } disp_audio_idx += 1; } Track::Subtitle(s) => { if s.is_default { args.push(format!( "-disposition:s:{}", existing_sub_count_disp + disp_sub_idx )); args.push("default".to_string()); } disp_sub_idx += 1; } } } // ── Output ────────────────────────────────────────────────────────────── args.push(project.output.path.to_string_lossy().to_string()); 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}; fn base_project() -> Project { Project::new( VideoFile::new(FilePath::from("input.mkv")), MkvOutput::new(FilePath::from("output.mkv")), ) .unwrap() } #[test] fn sempre_contem_c_copy() { let project = base_project(); let args = FfmpegCommandBuilder::build(&project); let pos_c = args.iter().position(|a| a == "-c"); assert!(pos_c.is_some(), "faltou -c"); assert_eq!(args[pos_c.unwrap() + 1], "copy", "faltou copy após -c"); } #[test] fn output_e_o_ultimo_argumento() { let project = base_project(); let args = FfmpegCommandBuilder::build(&project); assert_eq!(args.last().unwrap(), "output.mkv"); } #[test] fn itsoffset_formato_correto() { let mut project = base_project(); AddAudioTrack::execute( &mut project, FilePath::from("audio.aac"), SyncOffset::from_ms(1200), TrackLanguage::new("por").unwrap(), false, String::new(), 1.0, ) .unwrap(); let args = FfmpegCommandBuilder::build(&project); let pos = args.iter().position(|a| a == "-itsoffset"); assert!(pos.is_some(), "faltou -itsoffset"); assert_eq!(args[pos.unwrap() + 1], "1.200"); } #[test] fn mapa_faixa_externa_de_audio() { let mut project = base_project(); AddAudioTrack::execute( &mut project, FilePath::from("audio.aac"), SyncOffset::default(), TrackLanguage::new("por").unwrap(), false, String::new(), 1.0, ) .unwrap(); let args = FfmpegCommandBuilder::build(&project); assert!( args.contains(&"1:a".to_string()), "faltou map 1:a — args: {:?}", args ); } #[test] fn metadata_idioma_audio() { let mut project = base_project(); AddAudioTrack::execute( &mut project, FilePath::from("audio.aac"), SyncOffset::default(), TrackLanguage::new("por").unwrap(), false, String::new(), 1.0, ) .unwrap(); let args = FfmpegCommandBuilder::build(&project); assert!( args.contains(&"-metadata:s:a:0".to_string()), "faltou metadata — args: {:?}", args ); 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 ); } }