414 lines
16 KiB
Rust
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
|
|
);
|
|
}
|
|
} |