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,2 @@
|
||||
pub mod ports;
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,18 @@
|
||||
use anyhow::Result;
|
||||
use crate::domain::entities::MediaTrackInfo;
|
||||
use crate::domain::value_objects::FilePath;
|
||||
|
||||
/// Port para inspecionar faixas de um arquivo de mídia (implementado pelo FfprobeGateway).
|
||||
pub trait MediaInfoPort {
|
||||
fn probe(&self, path: &FilePath) -> Result<Vec<MediaTrackInfo>>;
|
||||
}
|
||||
|
||||
/// Port para executar o processamento final de mídia (implementado pelo FfmpegGateway).
|
||||
pub trait MediaProcessorPort {
|
||||
fn execute(&self, args: Vec<String>) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Port para abstrair acesso ao sistema de arquivos.
|
||||
pub trait FileSystemPort {
|
||||
fn exists(&self, path: &FilePath) -> bool;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use anyhow::Result;
|
||||
use crate::domain::entities::{AudioTrack, Project, Track};
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||
|
||||
/// Adiciona uma faixa de áudio externa ao projeto.
|
||||
pub struct AddAudioTrack;
|
||||
|
||||
impl AddAudioTrack {
|
||||
pub fn execute(
|
||||
project: &mut Project,
|
||||
path: FilePath,
|
||||
offset: SyncOffset,
|
||||
language: TrackLanguage,
|
||||
) -> Result<()> {
|
||||
let id = project.next_track_id();
|
||||
let track = AudioTrack::new(id, path, offset, language);
|
||||
project.tracks.push(Track::Audio(track));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::entities::{MkvOutput, VideoFile};
|
||||
use crate::domain::value_objects::FilePath;
|
||||
|
||||
#[test]
|
||||
fn adiciona_audio_no_projeto() {
|
||||
let mut project = Project::new(
|
||||
VideoFile::new(FilePath::from("input.mkv")),
|
||||
MkvOutput::new(FilePath::from("output.mkv")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let lang = TrackLanguage::new("por").unwrap();
|
||||
AddAudioTrack::execute(
|
||||
&mut project,
|
||||
FilePath::from("audio_pt.aac"),
|
||||
SyncOffset::default(),
|
||||
lang,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(project.tracks.len(), 1);
|
||||
assert!(matches!(project.tracks[0], Track::Audio(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use anyhow::Result;
|
||||
use crate::domain::entities::{Project, SubtitleTrack, Track};
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||
|
||||
/// Adiciona uma faixa de legenda externa ao projeto.
|
||||
pub struct AddSubtitle;
|
||||
|
||||
impl AddSubtitle {
|
||||
pub fn execute(
|
||||
project: &mut Project,
|
||||
path: FilePath,
|
||||
offset: SyncOffset,
|
||||
language: TrackLanguage,
|
||||
) -> Result<()> {
|
||||
let id = project.next_track_id();
|
||||
let track = SubtitleTrack::new(id, path, offset, language);
|
||||
project.tracks.push(Track::Subtitle(track));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::entities::{MkvOutput, VideoFile};
|
||||
|
||||
#[test]
|
||||
fn adiciona_legenda_no_projeto() {
|
||||
let mut project = Project::new(
|
||||
VideoFile::new(FilePath::from("input.mkv")),
|
||||
MkvOutput::new(FilePath::from("output.mkv")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let lang = TrackLanguage::new("eng").unwrap();
|
||||
AddSubtitle::execute(
|
||||
&mut project,
|
||||
FilePath::from("sub_en.srt"),
|
||||
SyncOffset::default(),
|
||||
lang,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(project.tracks.len(), 1);
|
||||
assert!(matches!(project.tracks[0], Track::Subtitle(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use crate::domain::entities::Project;
|
||||
use crate::domain::value_objects::{SyncOffset, TrackId};
|
||||
|
||||
/// Ajusta o offset de sincronização de uma faixa externa existente pelo TrackId.
|
||||
pub struct AdjustSync;
|
||||
|
||||
impl AdjustSync {
|
||||
pub fn execute(project: &mut Project, id: TrackId, offset: SyncOffset) -> Result<()> {
|
||||
let track = project
|
||||
.find_track_mut(id)
|
||||
.ok_or_else(|| anyhow!("Faixa não encontrada: {:?}", id))?;
|
||||
track.set_offset(offset);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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, TrackLanguage};
|
||||
|
||||
#[test]
|
||||
fn ajusta_offset_existente() {
|
||||
let mut project = Project::new(
|
||||
VideoFile::new(FilePath::from("input.mkv")),
|
||||
MkvOutput::new(FilePath::from("output.mkv")),
|
||||
)
|
||||
.unwrap();
|
||||
let lang = TrackLanguage::new("por").unwrap();
|
||||
AddAudioTrack::execute(
|
||||
&mut project,
|
||||
FilePath::from("audio.aac"),
|
||||
SyncOffset::default(),
|
||||
lang,
|
||||
)
|
||||
.unwrap();
|
||||
let id = project.tracks[0].id();
|
||||
|
||||
AdjustSync::execute(&mut project, id, SyncOffset::from_ms(1200)).unwrap();
|
||||
assert_eq!(project.tracks[0].offset().as_ms(), 1200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erro_se_id_inexistente() {
|
||||
let mut project = Project::new(
|
||||
VideoFile::new(FilePath::from("input.mkv")),
|
||||
MkvOutput::new(FilePath::from("output.mkv")),
|
||||
)
|
||||
.unwrap();
|
||||
let result = AdjustSync::execute(&mut project, TrackId::new(99), SyncOffset::from_ms(500));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use crate::domain::entities::Project;
|
||||
use crate::domain::value_objects::{SyncOffset, TrackId};
|
||||
|
||||
/// Ajusta o offset de sincronização de uma faixa já presente no arquivo original (existing_tracks).
|
||||
pub struct EditExistingTrackSync;
|
||||
|
||||
impl EditExistingTrackSync {
|
||||
pub fn execute(project: &mut Project, id: TrackId, offset: SyncOffset) -> Result<()> {
|
||||
let track = project
|
||||
.find_existing_track_mut(id)
|
||||
.ok_or_else(|| anyhow!("Faixa existente não encontrada: {:?}", id))?;
|
||||
track.offset = offset;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::entities::{MediaTrackInfo, MkvOutput, Project, TrackKind, VideoFile};
|
||||
use crate::domain::value_objects::{FilePath, TrackId};
|
||||
|
||||
#[test]
|
||||
fn edita_offset_de_faixa_existente() {
|
||||
let mut project = Project::new(
|
||||
VideoFile::new(FilePath::from("input.mkv")),
|
||||
MkvOutput::new(FilePath::from("output.mkv")),
|
||||
)
|
||||
.unwrap();
|
||||
let id = TrackId::new(1);
|
||||
project.existing_tracks.push(MediaTrackInfo::new(id, TrackKind::Audio, "aac", None, 1));
|
||||
|
||||
EditExistingTrackSync::execute(&mut project, id, SyncOffset::from_ms(-500)).unwrap();
|
||||
assert_eq!(project.existing_tracks[0].offset.as_ms(), -500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erro_se_faixa_existente_nao_encontrada() {
|
||||
let mut project = Project::new(
|
||||
VideoFile::new(FilePath::from("input.mkv")),
|
||||
MkvOutput::new(FilePath::from("output.mkv")),
|
||||
)
|
||||
.unwrap();
|
||||
let result =
|
||||
EditExistingTrackSync::execute(&mut project, TrackId::new(99), SyncOffset::from_ms(0));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use anyhow::Result;
|
||||
use crate::application::ports::MediaProcessorPort;
|
||||
use crate::domain::entities::Project;
|
||||
|
||||
/// Constrói o comando FFmpeg a partir do Project e delega a execução ao MediaProcessorPort.
|
||||
pub struct GenerateOutput;
|
||||
|
||||
impl GenerateOutput {
|
||||
pub fn execute(project: &Project, port: &dyn MediaProcessorPort) -> Result<()> {
|
||||
use crate::adapters::ffmpeg::command_builder::FfmpegCommandBuilder;
|
||||
let args = FfmpegCommandBuilder::build(project);
|
||||
port.execute(args)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use crate::application::ports::MediaProcessorPort;
|
||||
use crate::application::use_cases::add_audio_track::AddAudioTrack;
|
||||
use crate::domain::entities::{MkvOutput, Project, VideoFile};
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct MockProcessor {
|
||||
captured_args: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MediaProcessorPort for MockProcessor {
|
||||
fn execute(&self, args: Vec<String>) -> Result<()> {
|
||||
*self.captured_args.borrow_mut() = args;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sempre_inclui_c_copy() {
|
||||
let mut project = Project::new(
|
||||
VideoFile::new(FilePath::from("input.mkv")),
|
||||
MkvOutput::new(FilePath::from("output.mkv")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
AddAudioTrack::execute(
|
||||
&mut project,
|
||||
FilePath::from("audio_pt.aac"),
|
||||
SyncOffset::from_ms(1200),
|
||||
TrackLanguage::new("por").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mock = MockProcessor {
|
||||
captured_args: RefCell::new(vec![]),
|
||||
};
|
||||
GenerateOutput::execute(&project, &mock).unwrap();
|
||||
|
||||
let args = mock.captured_args.borrow();
|
||||
assert!(
|
||||
args.contains(&"-c".to_string()) && args.contains(&"copy".to_string()),
|
||||
"Comando não continha '-c copy': {:?}",
|
||||
args
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use anyhow::Result;
|
||||
use crate::application::ports::MediaInfoPort;
|
||||
use crate::domain::entities::Project;
|
||||
|
||||
/// Carrega as faixas existentes do arquivo de vídeo fonte e popula `Project::existing_tracks`.
|
||||
pub struct LoadMediaInfo;
|
||||
|
||||
impl LoadMediaInfo {
|
||||
pub fn execute(project: &mut Project, port: &dyn MediaInfoPort) -> Result<()> {
|
||||
let tracks = port.probe(&project.source.path)?;
|
||||
project.existing_tracks = tracks;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use crate::application::ports::MediaInfoPort;
|
||||
use crate::domain::entities::{MediaTrackInfo, MkvOutput, Project, TrackKind, VideoFile};
|
||||
use crate::domain::value_objects::{FilePath, TrackId};
|
||||
|
||||
struct MockMediaInfoPort {
|
||||
tracks: Vec<MediaTrackInfo>,
|
||||
}
|
||||
|
||||
impl MediaInfoPort for MockMediaInfoPort {
|
||||
fn probe(&self, _path: &FilePath) -> Result<Vec<MediaTrackInfo>> {
|
||||
Ok(self.tracks.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carrega_faixas_no_projeto() {
|
||||
let mut project = Project::new(
|
||||
VideoFile::new(FilePath::from("input.mkv")),
|
||||
MkvOutput::new(FilePath::from("output.mkv")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mock = MockMediaInfoPort {
|
||||
tracks: vec![MediaTrackInfo::new(
|
||||
TrackId::new(1),
|
||||
TrackKind::Audio,
|
||||
"aac",
|
||||
None,
|
||||
0,
|
||||
)],
|
||||
};
|
||||
|
||||
LoadMediaInfo::execute(&mut project, &mock).unwrap();
|
||||
assert_eq!(project.existing_tracks.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod add_audio_track;
|
||||
pub mod add_subtitle;
|
||||
pub mod adjust_sync;
|
||||
pub mod edit_existing_track_sync;
|
||||
pub mod generate_output;
|
||||
pub mod load_media_info;
|
||||
pub mod set_track_language;
|
||||
@@ -0,0 +1,44 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use crate::domain::entities::Project;
|
||||
use crate::domain::value_objects::{TrackId, TrackLanguage};
|
||||
|
||||
/// Altera o idioma de uma faixa externa pelo TrackId.
|
||||
pub struct SetTrackLanguage;
|
||||
|
||||
impl SetTrackLanguage {
|
||||
pub fn execute(project: &mut Project, id: TrackId, language: TrackLanguage) -> Result<()> {
|
||||
let track = project
|
||||
.find_track_mut(id)
|
||||
.ok_or_else(|| anyhow!("Faixa não encontrada: {:?}", id))?;
|
||||
track.set_language(language);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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};
|
||||
|
||||
#[test]
|
||||
fn altera_idioma_da_faixa() {
|
||||
let mut project = Project::new(
|
||||
VideoFile::new(FilePath::from("input.mkv")),
|
||||
MkvOutput::new(FilePath::from("output.mkv")),
|
||||
)
|
||||
.unwrap();
|
||||
AddAudioTrack::execute(
|
||||
&mut project,
|
||||
FilePath::from("audio.aac"),
|
||||
SyncOffset::default(),
|
||||
TrackLanguage::new("por").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let id = project.tracks[0].id();
|
||||
|
||||
SetTrackLanguage::execute(&mut project, id, TrackLanguage::new("eng").unwrap()).unwrap();
|
||||
assert_eq!(project.tracks[0].language().as_str(), "eng");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user