Files
simple-multimidia-track-aud…/src/adapters/ffmpeg/command_builder.rs
T

414 lines
16 KiB
Rust

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<String> {
let mut args: Vec<String> = 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<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)]
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
);
}
}