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:
2026-02-28 13:42:23 -03:00
parent eff779a1df
commit ce236eca5d
47 changed files with 6818 additions and 2 deletions
+213
View File
@@ -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()));
}
}