feat: implement project management and media track handling
- Added domain entities for audio, subtitle, and video tracks. - Created a Project entity to manage media editing sessions. - Implemented value objects for file paths, sync offsets, track IDs, and languages. - Developed infrastructure for asynchronous FFmpeg process execution. - Built a user interface for selecting video files, adding audio and subtitle tracks, and managing output settings. - Integrated error handling and logging for media processing tasks.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
use crate::domain::entities::{Project, Track, TrackKind};
|
||||
|
||||
/// 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();
|
||||
|
||||
// ── 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);
|
||||
next_input_idx += 1;
|
||||
}
|
||||
|
||||
// ── Maps ────────────────────────────────────────────────────────────────
|
||||
// Faixas existentes do source
|
||||
for track in &project.existing_tracks {
|
||||
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 mapeadas, mapeia tudo do source
|
||||
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;
|
||||
}
|
||||
|
||||
// ── -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));
|
||||
ext_audio_idx += 1;
|
||||
}
|
||||
Track::Subtitle(t) => {
|
||||
args.push(format!("-metadata:s:s:{}", ext_sub_idx));
|
||||
args.push(format!("language={}", t.language));
|
||||
ext_sub_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output ──────────────────────────────────────────────────────────────
|
||||
args.push(project.output.path.to_string_lossy().to_string());
|
||||
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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(
|
||||
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(),
|
||||
)
|
||||
.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(),
|
||||
)
|
||||
.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(),
|
||||
)
|
||||
.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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user