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:
Generated
+4712
File diff suppressed because it is too large
Load Diff
@@ -4,3 +4,9 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
eframe = "0.27"
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "rt-multi-thread", "macros", "io-util", "sync"] }
|
||||
rfd = "0.14"
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::process::Command;
|
||||
use crate::application::ports::MediaProcessorPort;
|
||||
|
||||
/// Implementa MediaProcessorPort executando o binário `ffmpeg`.
|
||||
pub struct FfmpegGateway;
|
||||
|
||||
impl MediaProcessorPort for FfmpegGateway {
|
||||
fn execute(&self, args: Vec<String>) -> Result<()> {
|
||||
let output = Command::new("ffmpeg")
|
||||
.args(&args)
|
||||
.output()
|
||||
.context("Falha ao executar ffmpeg. Verifique se está instalado e no PATH.")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"FFmpeg encerrou com erro (código {:?}):\n{}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::process::Command;
|
||||
use crate::application::ports::MediaInfoPort;
|
||||
use crate::domain::entities::{MediaTrackInfo, TrackKind};
|
||||
use crate::domain::value_objects::{FilePath, TrackId, TrackLanguage};
|
||||
|
||||
/// Implementa MediaInfoPort executando `ffprobe` e parseando a saída JSON.
|
||||
pub struct FfprobeGateway;
|
||||
|
||||
// ── Structs de deserialização do JSON do ffprobe ─────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FfprobeOutput {
|
||||
streams: Vec<FfprobeStream>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FfprobeStream {
|
||||
index: u32,
|
||||
codec_type: Option<String>,
|
||||
codec_name: Option<String>,
|
||||
tags: Option<FfprobeTags>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FfprobeTags {
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
impl MediaInfoPort for FfprobeGateway {
|
||||
fn probe(&self, path: &FilePath) -> Result<Vec<MediaTrackInfo>> {
|
||||
let output = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
path.to_str().ok_or_else(|| anyhow!("Caminho inválido"))?,
|
||||
])
|
||||
.output()
|
||||
.context("Falha ao executar ffprobe. Verifique se está instalado e no PATH.")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("ffprobe retornou erro:\n{}", stderr));
|
||||
}
|
||||
|
||||
let json = String::from_utf8_lossy(&output.stdout);
|
||||
let parsed: FfprobeOutput =
|
||||
serde_json::from_str(&json).context("Falha ao interpretar saída do ffprobe")?;
|
||||
|
||||
let tracks = parsed
|
||||
.streams
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
let kind = match s.codec_type.as_deref() {
|
||||
Some("video") => TrackKind::Video,
|
||||
Some("audio") => TrackKind::Audio,
|
||||
Some("subtitle") => TrackKind::Subtitle,
|
||||
Some("data") => TrackKind::Data,
|
||||
_ => return None,
|
||||
};
|
||||
let codec = s.codec_name.unwrap_or_else(|| "unknown".to_string());
|
||||
let language = s
|
||||
.tags
|
||||
.and_then(|t| t.language)
|
||||
.and_then(|l| TrackLanguage::new(l).ok());
|
||||
let id = TrackId::new(s.index);
|
||||
Some(MediaTrackInfo::new(id, kind, codec, language, s.index))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(tracks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod command_builder;
|
||||
pub mod ffmpeg_gateway;
|
||||
pub mod ffprobe_gateway;
|
||||
|
||||
pub use command_builder::FfmpegCommandBuilder;
|
||||
pub use ffmpeg_gateway::FfmpegGateway;
|
||||
pub use ffprobe_gateway::FfprobeGateway;
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::path::PathBuf;
|
||||
use crate::domain::value_objects::FilePath;
|
||||
|
||||
/// Abre diálogos de seleção de arquivo usando a crate `rfd`.
|
||||
pub struct FilePickerAdapter;
|
||||
|
||||
impl FilePickerAdapter {
|
||||
/// Abre diálogo de seleção de arquivo de vídeo.
|
||||
pub fn pick_video() -> Option<FilePath> {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Vídeo", &["mkv", "mp4", "avi", "mov", "ts"])
|
||||
.pick_file()
|
||||
.map(|p: PathBuf| FilePath::from(p))
|
||||
}
|
||||
|
||||
/// Abre diálogo de seleção de arquivo de áudio.
|
||||
pub fn pick_audio() -> Option<FilePath> {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Áudio", &["aac", "mp3", "flac", "ogg", "m4a", "wav", "ac3", "dts"])
|
||||
.pick_file()
|
||||
.map(|p: PathBuf| FilePath::from(p))
|
||||
}
|
||||
|
||||
/// Abre diálogo de seleção de arquivo de legenda.
|
||||
pub fn pick_subtitle() -> Option<FilePath> {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Legenda", &["srt", "ass", "ssa", "vtt", "sub"])
|
||||
.pick_file()
|
||||
.map(|p: PathBuf| FilePath::from(p))
|
||||
}
|
||||
|
||||
/// Abre diálogo de salvar arquivo MKV de saída.
|
||||
pub fn pick_output() -> Option<FilePath> {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("MKV", &["mkv"])
|
||||
.set_file_name("output.mkv")
|
||||
.save_file()
|
||||
.map(|mut p: PathBuf| {
|
||||
// Força extensão .mkv
|
||||
p.set_extension("mkv");
|
||||
FilePath::from(p)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::application::ports::FileSystemPort;
|
||||
use crate::domain::value_objects::FilePath;
|
||||
|
||||
pub mod file_picker;
|
||||
pub use file_picker::FilePickerAdapter;
|
||||
|
||||
/// Implementação concreta do FileSystemPort.
|
||||
pub struct RealFileSystem;
|
||||
|
||||
impl FileSystemPort for RealFileSystem {
|
||||
fn exists(&self, path: &FilePath) -> bool {
|
||||
path.as_path().exists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod ffmpeg;
|
||||
pub mod filesystem;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
|
||||
|
||||
/// Faixa de áudio externa adicionada pelo usuário.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AudioTrack {
|
||||
pub id: TrackId,
|
||||
pub path: FilePath,
|
||||
pub offset: SyncOffset,
|
||||
pub language: TrackLanguage,
|
||||
}
|
||||
|
||||
impl AudioTrack {
|
||||
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self {
|
||||
AudioTrack { id, path, offset, language }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::value_objects::{SyncOffset, TrackId, TrackLanguage};
|
||||
|
||||
/// Tipo de stream de mídia.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TrackKind {
|
||||
Video,
|
||||
Audio,
|
||||
Subtitle,
|
||||
Data,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TrackKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TrackKind::Video => write!(f, "Vídeo"),
|
||||
TrackKind::Audio => write!(f, "Áudio"),
|
||||
TrackKind::Subtitle => write!(f, "Legenda"),
|
||||
TrackKind::Data => write!(f, "Dados"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Informação de uma faixa já presente no arquivo de mídia (lida via ffprobe).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MediaTrackInfo {
|
||||
pub id: TrackId,
|
||||
pub kind: TrackKind,
|
||||
pub codec: String,
|
||||
pub language: Option<TrackLanguage>,
|
||||
/// Offset de sincronização — pode ser ajustado via EditExistingTrackSync.
|
||||
pub offset: SyncOffset,
|
||||
/// Índice do stream no container original (usado internamente pelo adapter).
|
||||
pub stream_index: u32,
|
||||
}
|
||||
|
||||
impl MediaTrackInfo {
|
||||
pub fn new(
|
||||
id: TrackId,
|
||||
kind: TrackKind,
|
||||
codec: impl Into<String>,
|
||||
language: Option<TrackLanguage>,
|
||||
stream_index: u32,
|
||||
) -> Self {
|
||||
MediaTrackInfo {
|
||||
id,
|
||||
kind,
|
||||
codec: codec.into(),
|
||||
language,
|
||||
offset: SyncOffset::default(),
|
||||
stream_index,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::value_objects::FilePath;
|
||||
|
||||
/// Arquivo MKV de saída.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MkvOutput {
|
||||
pub path: FilePath,
|
||||
}
|
||||
|
||||
impl MkvOutput {
|
||||
pub fn new(path: FilePath) -> Self {
|
||||
MkvOutput { path }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
pub mod audio_track;
|
||||
pub mod media_track_info;
|
||||
pub mod mkv_output;
|
||||
pub mod project;
|
||||
pub mod subtitle_track;
|
||||
pub mod track;
|
||||
pub mod video_file;
|
||||
|
||||
pub use audio_track::AudioTrack;
|
||||
pub use media_track_info::{MediaTrackInfo, TrackKind};
|
||||
pub use mkv_output::MkvOutput;
|
||||
pub use project::Project;
|
||||
pub use subtitle_track::SubtitleTrack;
|
||||
pub use track::Track;
|
||||
pub use video_file::VideoFile;
|
||||
@@ -0,0 +1,81 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::entities::{MediaTrackInfo, MkvOutput, Track, VideoFile};
|
||||
use crate::domain::value_objects::TrackId;
|
||||
|
||||
/// Entidade raiz do domínio — representa a sessão de edição completa do usuário.
|
||||
/// É a única fonte de verdade do estado em memória.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub source: VideoFile,
|
||||
/// Faixas externas adicionadas pelo usuário.
|
||||
pub tracks: Vec<Track>,
|
||||
/// Faixas existentes lidas do arquivo via ffprobe.
|
||||
pub existing_tracks: Vec<MediaTrackInfo>,
|
||||
pub output: MkvOutput,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Cria um novo projeto. Retorna erro se output e source apontam para o mesmo arquivo.
|
||||
pub fn new(source: VideoFile, output: MkvOutput) -> Result<Self> {
|
||||
if source.path == output.path {
|
||||
return Err(anyhow!(
|
||||
"O arquivo de saída não pode ser o mesmo que o arquivo de origem: '{}'",
|
||||
source.path
|
||||
));
|
||||
}
|
||||
Ok(Project {
|
||||
source,
|
||||
tracks: Vec::new(),
|
||||
existing_tracks: Vec::new(),
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
/// Retorna o próximo TrackId disponível para uso.
|
||||
pub fn next_track_id(&self) -> TrackId {
|
||||
let max = self.tracks.iter().map(|t| t.id().val()).max().unwrap_or(0);
|
||||
TrackId::new(max + 1)
|
||||
}
|
||||
|
||||
/// Busca uma faixa externa pelo TrackId.
|
||||
pub fn find_track_mut(&mut self, id: TrackId) -> Option<&mut Track> {
|
||||
self.tracks.iter_mut().find(|t| t.id() == id)
|
||||
}
|
||||
|
||||
/// Busca uma faixa existente pelo TrackId.
|
||||
pub fn find_existing_track_mut(&mut self, id: TrackId) -> Option<&mut MediaTrackInfo> {
|
||||
self.existing_tracks.iter_mut().find(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::value_objects::FilePath;
|
||||
|
||||
fn make_project(src: &str, out: &str) -> Result<Project> {
|
||||
Project::new(
|
||||
VideoFile::new(FilePath::from(src)),
|
||||
MkvOutput::new(FilePath::from(out)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projeto_valido() {
|
||||
assert!(make_project("input.mkv", "output.mkv").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projeto_invalido_mesmo_caminho() {
|
||||
assert!(make_project("same.mkv", "same.mkv").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_id_opaco() {
|
||||
let id = TrackId::new(42);
|
||||
// TrackId não expõe indexação interna — apenas comparação e cópia
|
||||
let id2 = id;
|
||||
assert_eq!(id, id2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
|
||||
|
||||
/// Faixa de legenda externa adicionada pelo usuário.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SubtitleTrack {
|
||||
pub id: TrackId,
|
||||
pub path: FilePath,
|
||||
pub offset: SyncOffset,
|
||||
pub language: TrackLanguage,
|
||||
}
|
||||
|
||||
impl SubtitleTrack {
|
||||
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self {
|
||||
SubtitleTrack { id, path, offset, language }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::entities::{AudioTrack, SubtitleTrack};
|
||||
use crate::domain::value_objects::{SyncOffset, TrackId, TrackLanguage};
|
||||
|
||||
/// Faixa externa adicionada pelo usuário ao projeto.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum Track {
|
||||
Audio(AudioTrack),
|
||||
Subtitle(SubtitleTrack),
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn id(&self) -> TrackId {
|
||||
match self {
|
||||
Track::Audio(t) => t.id,
|
||||
Track::Subtitle(t) => t.id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> SyncOffset {
|
||||
match self {
|
||||
Track::Audio(t) => t.offset,
|
||||
Track::Subtitle(t) => t.offset,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn language(&self) -> &TrackLanguage {
|
||||
match self {
|
||||
Track::Audio(t) => &t.language,
|
||||
Track::Subtitle(t) => &t.language,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_offset(&mut self, offset: SyncOffset) {
|
||||
match self {
|
||||
Track::Audio(t) => t.offset = offset,
|
||||
Track::Subtitle(t) => t.offset = offset,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_language(&mut self, language: TrackLanguage) {
|
||||
match self {
|
||||
Track::Audio(t) => t.language = language,
|
||||
Track::Subtitle(t) => t.language = language,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::value_objects::FilePath;
|
||||
|
||||
/// Arquivo de vídeo base do projeto.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct VideoFile {
|
||||
pub path: FilePath,
|
||||
}
|
||||
|
||||
impl VideoFile {
|
||||
pub fn new(path: FilePath) -> Self {
|
||||
VideoFile { path }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod entities;
|
||||
pub mod value_objects;
|
||||
@@ -0,0 +1,42 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Newtype sobre PathBuf representando um caminho de arquivo validado.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FilePath(PathBuf);
|
||||
|
||||
impl FilePath {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
FilePath(path.into())
|
||||
}
|
||||
|
||||
pub fn as_path(&self) -> &std::path::Path {
|
||||
self.0.as_path()
|
||||
}
|
||||
|
||||
pub fn to_str(&self) -> Option<&str> {
|
||||
self.0.to_str()
|
||||
}
|
||||
|
||||
pub fn to_string_lossy(&self) -> std::borrow::Cow<str> {
|
||||
self.0.to_string_lossy()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FilePath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0.display())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PathBuf> for FilePath {
|
||||
fn from(p: PathBuf) -> Self {
|
||||
FilePath(p)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for FilePath {
|
||||
fn from(s: &str) -> Self {
|
||||
FilePath(PathBuf::from(s))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod file_path;
|
||||
pub mod sync_offset;
|
||||
pub mod track_id;
|
||||
pub mod track_language;
|
||||
|
||||
pub use file_path::FilePath;
|
||||
pub use sync_offset::SyncOffset;
|
||||
pub use track_id::TrackId;
|
||||
pub use track_language::TrackLanguage;
|
||||
@@ -0,0 +1,75 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Offset de sincronização armazenado em milissegundos como inteiro.
|
||||
/// Nunca usa f64 — a conversão de/para segundos com vírgula é feita aqui.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SyncOffset(i64);
|
||||
|
||||
impl SyncOffset {
|
||||
/// Cria um SyncOffset a partir de milissegundos.
|
||||
pub fn from_ms(ms: i64) -> Self {
|
||||
SyncOffset(ms)
|
||||
}
|
||||
|
||||
/// Cria um SyncOffset a partir de uma string de segundos (ex: "1.2", "-0.5").
|
||||
pub fn from_seconds_str(s: &str) -> Result<Self> {
|
||||
let trimmed = s.trim().trim_end_matches('s');
|
||||
let seconds: f64 = trimmed
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("Offset inválido: '{}'", s))?;
|
||||
let ms = (seconds * 1000.0).round() as i64;
|
||||
Ok(SyncOffset(ms))
|
||||
}
|
||||
|
||||
/// Retorna o offset em milissegundos.
|
||||
pub fn as_ms(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Retorna true se o offset é zero (sem deslocamento).
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SyncOffset {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let seconds = self.0 as f64 / 1000.0;
|
||||
write!(f, "{:.3}s", seconds)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_seconds_str_positivo() {
|
||||
let offset = SyncOffset::from_seconds_str("1.2").unwrap();
|
||||
assert_eq!(offset.as_ms(), 1200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_seconds_str_negativo() {
|
||||
let offset = SyncOffset::from_seconds_str("-0.5").unwrap();
|
||||
assert_eq!(offset.as_ms(), -500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_seconds_str_com_sufixo_s() {
|
||||
let offset = SyncOffset::from_seconds_str("2.0s").unwrap();
|
||||
assert_eq!(offset.as_ms(), 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_seconds_str_invalido() {
|
||||
assert!(SyncOffset::from_seconds_str("abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_seconds_str_zero() {
|
||||
let offset = SyncOffset::from_seconds_str("0").unwrap();
|
||||
assert!(offset.is_zero());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Identificador opaco de uma faixa de mídia.
|
||||
/// Não expõe indexação interna do FFmpeg — isso é responsabilidade do adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct TrackId(u32);
|
||||
|
||||
impl TrackId {
|
||||
pub fn new(id: u32) -> Self {
|
||||
TrackId(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackId {
|
||||
/// Retorna o valor interno (uso restrito a crates internas).
|
||||
pub(crate) fn val(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TrackId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "TrackId({})", self.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Código de idioma ISO 639-2 (3 letras minúsculas), ex: "por", "eng".
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TrackLanguage(String);
|
||||
|
||||
impl TrackLanguage {
|
||||
/// Cria um TrackLanguage validando o formato ISO 639-2 (3 letras minúsculas).
|
||||
pub fn new(code: impl Into<String>) -> Result<Self> {
|
||||
let code = code.into();
|
||||
if code.len() == 3 && code.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
Ok(TrackLanguage(code))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Código de idioma inválido: '{}'. Esperado formato ISO 639-2 (3 letras minúsculas, ex: 'por', 'eng')",
|
||||
code
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TrackLanguage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn idioma_valido() {
|
||||
assert!(TrackLanguage::new("por").is_ok());
|
||||
assert!(TrackLanguage::new("eng").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idioma_invalido_curto() {
|
||||
assert!(TrackLanguage::new("pt").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idioma_invalido_maiusculo() {
|
||||
assert!(TrackLanguage::new("POR").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idioma_invalido_longo() {
|
||||
assert!(TrackLanguage::new("port").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod process;
|
||||
@@ -0,0 +1,59 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Executa o FFmpeg de forma assíncrona, capturando stderr em tempo real.
|
||||
/// As linhas de saída são enviadas pelo canal `progress_tx`.
|
||||
pub async fn run_ffmpeg_async(
|
||||
args: Vec<String>,
|
||||
progress_tx: mpsc::UnboundedSender<String>,
|
||||
) -> Result<()> {
|
||||
let mut child = Command::new("ffmpeg")
|
||||
.args(&args)
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.context("Falha ao iniciar ffmpeg")?;
|
||||
|
||||
// Lê stderr em stream para exibir progresso em tempo real
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
let tx = progress_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let _ = tx.send(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let status = child.wait().await.context("Erro ao aguardar ffmpeg")?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"FFmpeg encerrou com código de saída: {:?}",
|
||||
status.code()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifica se um binário está disponível no PATH.
|
||||
pub fn check_binary_available(binary: &str) -> Result<()> {
|
||||
std::process::Command::new(binary)
|
||||
.arg("-version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map_err(|_| anyhow!("'{}' não encontrado no PATH. Por favor, instale o FFmpeg.", binary))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifica se ffmpeg e ffprobe estão disponíveis.
|
||||
pub fn validate_dependencies() -> Result<()> {
|
||||
check_binary_available("ffmpeg")?;
|
||||
check_binary_available("ffprobe")?;
|
||||
Ok(())
|
||||
}
|
||||
+21
-2
@@ -1,3 +1,22 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
mod adapters;
|
||||
mod application;
|
||||
mod domain;
|
||||
mod infrastructure;
|
||||
mod ui;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
// Configurações nativas da janela
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: eframe::egui::ViewportBuilder::default()
|
||||
.with_title("Editor de Faixas de Mídia")
|
||||
.with_inner_size([900.0, 700.0])
|
||||
.with_min_inner_size([600.0, 400.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Editor de Faixas de Mídia",
|
||||
native_options,
|
||||
Box::new(|cc| Box::new(ui::app::App::new(cc)) as Box<dyn eframe::App>),
|
||||
)
|
||||
}
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
use eframe::egui;
|
||||
use std::sync::mpsc;
|
||||
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
|
||||
use crate::application::ports::MediaProcessorPort;
|
||||
use crate::application::use_cases::{
|
||||
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
|
||||
edit_existing_track_sync::EditExistingTrackSync, load_media_info::LoadMediaInfo,
|
||||
};
|
||||
use crate::domain::entities::{MkvOutput, Project, VideoFile};
|
||||
use crate::domain::value_objects::FilePath;
|
||||
use crate::ui::components::{
|
||||
add_audio_track_form::AddAudioTrackForm, add_subtitle_form::AddSubtitleForm,
|
||||
execution_panel::{ExecutionPanel, ExecutionState}, existing_track_list::ExistingTrackList,
|
||||
output_selector::OutputSelector, video_selector::VideoSelector,
|
||||
};
|
||||
|
||||
/// Mensagens enviadas do background thread para a UI.
|
||||
enum BackgroundMsg {
|
||||
LogLine(String),
|
||||
Done,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Estado da aplicação — contém o `Project` como única fonte de verdade.
|
||||
pub struct App {
|
||||
project: Option<Project>,
|
||||
pending_source: Option<FilePath>,
|
||||
pending_output: Option<FilePath>,
|
||||
|
||||
// Componentes de UI com estado próprio
|
||||
video_selector: VideoSelector,
|
||||
output_selector: OutputSelector,
|
||||
existing_track_list: ExistingTrackList,
|
||||
add_audio_form: AddAudioTrackForm,
|
||||
add_subtitle_form: AddSubtitleForm,
|
||||
execution_panel: ExecutionPanel,
|
||||
|
||||
// Canal de comunicação do background thread
|
||||
bg_rx: Option<mpsc::Receiver<BackgroundMsg>>,
|
||||
|
||||
// Mensagem de erro global (ex: dependências ausentes)
|
||||
global_error: Option<String>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||
// Verifica dependências na inicialização
|
||||
let global_error = crate::infrastructure::process::validate_dependencies().err()
|
||||
.map(|e| e.to_string());
|
||||
|
||||
App {
|
||||
project: None,
|
||||
pending_source: None,
|
||||
pending_output: None,
|
||||
video_selector: VideoSelector::new(),
|
||||
output_selector: OutputSelector::new(),
|
||||
existing_track_list: ExistingTrackList::new(),
|
||||
add_audio_form: AddAudioTrackForm::new(),
|
||||
add_subtitle_form: AddSubtitleForm::new(),
|
||||
execution_panel: ExecutionPanel::new(),
|
||||
bg_rx: None,
|
||||
global_error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tenta construir ou atualizar o Project quando source e output estão disponíveis.
|
||||
fn try_build_project(&mut self) {
|
||||
let source = match &self.pending_source {
|
||||
Some(p) => p.clone(),
|
||||
None => return,
|
||||
};
|
||||
let output = match &self.pending_output {
|
||||
Some(p) => p.clone(),
|
||||
None => return,
|
||||
};
|
||||
match Project::new(VideoFile::new(source.clone()), MkvOutput::new(output)) {
|
||||
Ok(mut project) => {
|
||||
// Carrega faixas existentes via ffprobe
|
||||
let probe = FfprobeGateway;
|
||||
match LoadMediaInfo::execute(&mut project, &probe) {
|
||||
Ok(_) => {
|
||||
self.existing_track_list.sync_tracks(&project.existing_tracks);
|
||||
}
|
||||
Err(e) => {
|
||||
self.execution_panel.state = ExecutionState::Error(e.to_string());
|
||||
}
|
||||
}
|
||||
self.project = Some(project);
|
||||
}
|
||||
Err(e) => {
|
||||
self.execution_panel.state = ExecutionState::Error(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Processa mensagens pendentes do background thread.
|
||||
fn poll_background(&mut self) {
|
||||
let mut done = false;
|
||||
if let Some(rx) = &self.bg_rx {
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(BackgroundMsg::LogLine(line)) => {
|
||||
self.execution_panel.add_log(line);
|
||||
}
|
||||
Ok(BackgroundMsg::Done) => {
|
||||
self.execution_panel.state = ExecutionState::Success;
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
Ok(BackgroundMsg::Error(e)) => {
|
||||
self.execution_panel.state = ExecutionState::Error(e);
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => break,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if done {
|
||||
self.bg_rx = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Inicia geração do MKV em background thread.
|
||||
fn start_generation(&mut self, ctx: egui::Context) {
|
||||
let project = match &self.project {
|
||||
Some(p) => p.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let args = FfmpegCommandBuilder::build(&project);
|
||||
let (tx, rx) = mpsc::channel::<BackgroundMsg>();
|
||||
self.bg_rx = Some(rx);
|
||||
self.execution_panel.state = ExecutionState::Running;
|
||||
self.execution_panel.log_lines.clear();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let gateway = FfmpegGateway;
|
||||
match gateway.execute(args) {
|
||||
Ok(_) => {
|
||||
let _ = tx.send(BackgroundMsg::Done);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(BackgroundMsg::Error(e.to_string()));
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Consome mensagens assíncronas
|
||||
self.poll_background();
|
||||
if self.bg_rx.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
// Erro global de dependências
|
||||
if let Some(ref err) = self.global_error {
|
||||
ui.colored_label(
|
||||
egui::Color32::RED,
|
||||
format!("⚠ Dependência ausente: {}", err),
|
||||
);
|
||||
ui.separator();
|
||||
}
|
||||
|
||||
ui.heading("Editor de Faixas de Mídia");
|
||||
ui.add_space(8.0);
|
||||
|
||||
// ── Seleção de vídeo ──────────────────────────────────────────────
|
||||
if let Some(path) = self.video_selector.ui(ui) {
|
||||
self.pending_source = Some(path);
|
||||
if self.pending_output.is_some() {
|
||||
self.try_build_project();
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// ── Seleção de saída ──────────────────────────────────────────────
|
||||
if let Some(path) = self.output_selector.ui(ui) {
|
||||
self.pending_output = Some(path);
|
||||
if self.pending_source.is_some() {
|
||||
self.try_build_project();
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
if let Some(project) = &mut self.project {
|
||||
// ── Faixas existentes ─────────────────────────────────────────
|
||||
let events =
|
||||
self.existing_track_list.ui(ui, &project.existing_tracks);
|
||||
for event in events {
|
||||
use crate::ui::components::existing_track_list::ExistingTrackEvent;
|
||||
match event {
|
||||
ExistingTrackEvent::OffsetChanged(id, offset) => {
|
||||
let _ = EditExistingTrackSync::execute(project, id, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// ── Adicionar áudio ───────────────────────────────────────────
|
||||
if let Some(req) = self.add_audio_form.ui(ui) {
|
||||
let _ = AddAudioTrack::execute(project, req.path, req.offset, req.language);
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// ── Adicionar legenda ─────────────────────────────────────────
|
||||
if let Some(req) = self.add_subtitle_form.ui(ui) {
|
||||
let _ = AddSubtitle::execute(project, req.path, req.offset, req.language);
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// ── Painel de execução ────────────────────────────────────────
|
||||
let can_generate =
|
||||
self.pending_source.is_some() && self.pending_output.is_some();
|
||||
if self.execution_panel.ui(ui, can_generate) {
|
||||
let ctx_clone = ctx.clone();
|
||||
self.start_generation(ctx_clone);
|
||||
}
|
||||
} else {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Selecione o arquivo de vídeo e o destino de saída para começar.",
|
||||
)
|
||||
.italics(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
let can_generate = self.pending_source.is_some() && self.pending_output.is_some();
|
||||
self.execution_panel.ui(ui, can_generate);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use eframe::egui;
|
||||
use crate::adapters::filesystem::FilePickerAdapter;
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
|
||||
|
||||
/// Evento emitido ao confirmar adição de faixa de áudio.
|
||||
pub struct AddAudioTrackRequest {
|
||||
pub path: FilePath,
|
||||
pub offset: SyncOffset,
|
||||
pub language: TrackLanguage,
|
||||
}
|
||||
|
||||
/// Formulário para adicionar uma faixa de áudio externa.
|
||||
pub struct AddAudioTrackForm {
|
||||
selected_path: Option<FilePath>,
|
||||
offset_field: SyncOffsetField,
|
||||
language_field: LanguageField,
|
||||
}
|
||||
|
||||
impl AddAudioTrackForm {
|
||||
pub fn new() -> Self {
|
||||
AddAudioTrackForm {
|
||||
selected_path: None,
|
||||
offset_field: SyncOffsetField::new(SyncOffset::default()),
|
||||
language_field: LanguageField::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<AddAudioTrackRequest> {
|
||||
let mut result = None;
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.heading("Adicionar faixa de áudio");
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let label = self
|
||||
.selected_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Nenhum arquivo".to_string());
|
||||
ui.label(&label);
|
||||
if ui.button("Selecionar áudio…").clicked() {
|
||||
self.selected_path = FilePickerAdapter::pick_audio();
|
||||
}
|
||||
});
|
||||
|
||||
self.offset_field.ui(ui, "Atraso:");
|
||||
self.language_field.ui(ui);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
let can_add = self.selected_path.is_some();
|
||||
if ui
|
||||
.add_enabled(can_add, egui::Button::new("Adicionar"))
|
||||
.clicked()
|
||||
{
|
||||
if let (Some(path), Some(offset), Some(language)) = (
|
||||
self.selected_path.clone(),
|
||||
self.offset_field.parse(),
|
||||
self.language_field.parse(),
|
||||
) {
|
||||
result = Some(AddAudioTrackRequest { path, offset, language });
|
||||
// Reset form
|
||||
self.selected_path = None;
|
||||
self.offset_field = SyncOffsetField::new(SyncOffset::default());
|
||||
self.language_field = LanguageField::new(None);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use eframe::egui;
|
||||
use crate::adapters::filesystem::FilePickerAdapter;
|
||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
|
||||
|
||||
/// Evento emitido ao confirmar adição de legenda.
|
||||
pub struct AddSubtitleRequest {
|
||||
pub path: FilePath,
|
||||
pub offset: SyncOffset,
|
||||
pub language: TrackLanguage,
|
||||
}
|
||||
|
||||
/// Formulário para adicionar uma faixa de legenda externa.
|
||||
pub struct AddSubtitleForm {
|
||||
selected_path: Option<FilePath>,
|
||||
offset_field: SyncOffsetField,
|
||||
language_field: LanguageField,
|
||||
}
|
||||
|
||||
impl AddSubtitleForm {
|
||||
pub fn new() -> Self {
|
||||
AddSubtitleForm {
|
||||
selected_path: None,
|
||||
offset_field: SyncOffsetField::new(SyncOffset::default()),
|
||||
language_field: LanguageField::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<AddSubtitleRequest> {
|
||||
let mut result = None;
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.heading("Adicionar legenda");
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let label = self
|
||||
.selected_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Nenhum arquivo".to_string());
|
||||
ui.label(&label);
|
||||
if ui.button("Selecionar legenda…").clicked() {
|
||||
self.selected_path = FilePickerAdapter::pick_subtitle();
|
||||
}
|
||||
});
|
||||
|
||||
self.offset_field.ui(ui, "Atraso:");
|
||||
self.language_field.ui(ui);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
let can_add = self.selected_path.is_some();
|
||||
if ui
|
||||
.add_enabled(can_add, egui::Button::new("Adicionar"))
|
||||
.clicked()
|
||||
{
|
||||
if let (Some(path), Some(offset), Some(language)) = (
|
||||
self.selected_path.clone(),
|
||||
self.offset_field.parse(),
|
||||
self.language_field.parse(),
|
||||
) {
|
||||
result = Some(AddSubtitleRequest { path, offset, language });
|
||||
self.selected_path = None;
|
||||
self.offset_field = SyncOffsetField::new(SyncOffset::default());
|
||||
self.language_field = LanguageField::new(None);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use eframe::egui;
|
||||
|
||||
/// Estado da execução do FFmpeg.
|
||||
#[derive(Default, PartialEq)]
|
||||
pub enum ExecutionState {
|
||||
#[default]
|
||||
Idle,
|
||||
Running,
|
||||
Success,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Painel de execução: botão "Gerar MKV", progresso e erros.
|
||||
pub struct ExecutionPanel {
|
||||
pub state: ExecutionState,
|
||||
pub log_lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl ExecutionPanel {
|
||||
pub fn new() -> Self {
|
||||
ExecutionPanel {
|
||||
state: ExecutionState::Idle,
|
||||
log_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_log(&mut self, line: String) {
|
||||
self.log_lines.push(line);
|
||||
// Limita o histórico de log
|
||||
if self.log_lines.len() > 200 {
|
||||
self.log_lines.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderiza o painel. Retorna `true` se o botão "Gerar MKV" foi clicado.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, can_generate: bool) -> bool {
|
||||
let mut clicked = false;
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.heading("Gerar arquivo");
|
||||
|
||||
let is_running = self.state == ExecutionState::Running;
|
||||
|
||||
if ui
|
||||
.add_enabled(
|
||||
can_generate && !is_running,
|
||||
egui::Button::new(if is_running {
|
||||
"Processando…"
|
||||
} else {
|
||||
"Gerar MKV"
|
||||
}),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
clicked = true;
|
||||
}
|
||||
|
||||
match &self.state {
|
||||
ExecutionState::Idle => {}
|
||||
ExecutionState::Running => {
|
||||
ui.spinner();
|
||||
ui.label("Processando...");
|
||||
}
|
||||
ExecutionState::Success => {
|
||||
ui.colored_label(egui::Color32::GREEN, "✓ Arquivo gerado com sucesso!");
|
||||
}
|
||||
ExecutionState::Error(msg) => {
|
||||
ui.colored_label(egui::Color32::RED, "✗ Erro durante o processamento:");
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut msg.as_str())
|
||||
.desired_rows(4)
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Log de progresso
|
||||
if !self.log_lines.is_empty() {
|
||||
ui.collapsing("Log detalhado", |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(150.0)
|
||||
.show(ui, |ui| {
|
||||
for line in &self.log_lines {
|
||||
ui.label(egui::RichText::new(line).monospace().size(11.0));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
clicked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use eframe::egui;
|
||||
use crate::domain::entities::{MediaTrackInfo, TrackKind};
|
||||
use crate::domain::value_objects::{SyncOffset, TrackId};
|
||||
use crate::ui::components::sync_offset_field::SyncOffsetField;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Evento emitido por interações com a lista de faixas existentes.
|
||||
pub enum ExistingTrackEvent {
|
||||
OffsetChanged(TrackId, SyncOffset),
|
||||
}
|
||||
|
||||
/// Lista as faixas detectadas no arquivo de vídeo e permite editar o offset de cada uma.
|
||||
pub struct ExistingTrackList {
|
||||
offset_fields: HashMap<u32, SyncOffsetField>,
|
||||
}
|
||||
|
||||
impl ExistingTrackList {
|
||||
pub fn new() -> Self {
|
||||
ExistingTrackList {
|
||||
offset_fields: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sincroniza os campos de offset com as faixas atuais do projeto.
|
||||
pub fn sync_tracks(&mut self, tracks: &[MediaTrackInfo]) {
|
||||
for track in tracks {
|
||||
self.offset_fields
|
||||
.entry(track.id.val())
|
||||
.or_insert_with(|| SyncOffsetField::new(track.offset));
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderiza a lista. Retorna eventos de alteração de offset.
|
||||
pub fn ui(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
tracks: &[MediaTrackInfo],
|
||||
) -> Vec<ExistingTrackEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.heading("Faixas existentes");
|
||||
|
||||
if tracks.is_empty() {
|
||||
ui.label("Nenhuma faixa detectada.");
|
||||
return;
|
||||
}
|
||||
|
||||
egui::Grid::new("existing_tracks_grid")
|
||||
.num_columns(4)
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("#");
|
||||
ui.strong("Tipo");
|
||||
ui.strong("Codec");
|
||||
ui.strong("Atraso");
|
||||
ui.end_row();
|
||||
|
||||
for track in tracks {
|
||||
let kind_label = match track.kind {
|
||||
TrackKind::Video => "Vídeo",
|
||||
TrackKind::Audio => "Áudio",
|
||||
TrackKind::Subtitle => "Legenda",
|
||||
TrackKind::Data => "Dados",
|
||||
};
|
||||
let lang = track
|
||||
.language
|
||||
.as_ref()
|
||||
.map(|l| format!(" ({})", l))
|
||||
.unwrap_or_default();
|
||||
|
||||
ui.label(format!("{}", track.stream_index));
|
||||
ui.label(format!("{}{}", kind_label, lang));
|
||||
ui.label(&track.codec);
|
||||
|
||||
// Offset field
|
||||
let field = self
|
||||
.offset_fields
|
||||
.entry(track.id.val())
|
||||
.or_insert_with(|| SyncOffsetField::new(track.offset));
|
||||
|
||||
field.ui(ui, "");
|
||||
if let Some(offset) = field.parse() {
|
||||
if offset != track.offset {
|
||||
events.push(ExistingTrackEvent::OffsetChanged(track.id, offset));
|
||||
}
|
||||
}
|
||||
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
events
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use eframe::egui;
|
||||
use crate::domain::value_objects::TrackLanguage;
|
||||
|
||||
/// Campo de seleção/entrada de idioma ISO 639-2.
|
||||
pub struct LanguageField {
|
||||
pub raw: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl LanguageField {
|
||||
pub fn new(initial: Option<&TrackLanguage>) -> Self {
|
||||
LanguageField {
|
||||
raw: initial.map(|l| l.as_str().to_string()).unwrap_or_default(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderiza o campo. Retorna true se o valor mudou.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) -> bool {
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Idioma (ex: por, eng):");
|
||||
if ui
|
||||
.add(egui::TextEdit::singleline(&mut self.raw).desired_width(60.0))
|
||||
.changed()
|
||||
{
|
||||
changed = true;
|
||||
self.error = None;
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(ref err) = self.error {
|
||||
ui.colored_label(egui::Color32::RED, err);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Tenta converter para TrackLanguage.
|
||||
pub fn parse(&mut self) -> Option<TrackLanguage> {
|
||||
match TrackLanguage::new(self.raw.trim().to_lowercase()) {
|
||||
Ok(lang) => {
|
||||
self.error = None;
|
||||
Some(lang)
|
||||
}
|
||||
Err(e) => {
|
||||
self.error = Some(format!("{}", e));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod add_audio_track_form;
|
||||
pub mod add_subtitle_form;
|
||||
pub mod execution_panel;
|
||||
pub mod existing_track_list;
|
||||
pub mod language_field;
|
||||
pub mod output_selector;
|
||||
pub mod sync_offset_field;
|
||||
pub mod video_selector;
|
||||
@@ -0,0 +1,41 @@
|
||||
use eframe::egui;
|
||||
use crate::adapters::filesystem::FilePickerAdapter;
|
||||
use crate::domain::value_objects::FilePath;
|
||||
|
||||
/// Componente para seleção do arquivo MKV de saída.
|
||||
pub struct OutputSelector {
|
||||
pub selected_path: Option<FilePath>,
|
||||
}
|
||||
|
||||
impl OutputSelector {
|
||||
pub fn new() -> Self {
|
||||
OutputSelector { selected_path: None }
|
||||
}
|
||||
|
||||
/// Renderiza o seletor. Retorna `Some(FilePath)` se um novo caminho foi selecionado.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<FilePath> {
|
||||
let mut selected = None;
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.heading("Arquivo de saída (.mkv)");
|
||||
ui.horizontal(|ui| {
|
||||
let label = self
|
||||
.selected_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Nenhum destino selecionado".to_string());
|
||||
|
||||
ui.label(&label);
|
||||
|
||||
if ui.button("Salvar como…").clicked() {
|
||||
if let Some(path) = FilePickerAdapter::pick_output() {
|
||||
self.selected_path = Some(path.clone());
|
||||
selected = Some(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
selected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use eframe::egui;
|
||||
use crate::domain::value_objects::SyncOffset;
|
||||
|
||||
/// Campo de entrada para offset de sincronização em segundos (ex: "-1.2").
|
||||
/// Converte para SyncOffset(ms) internamente ao confirmar.
|
||||
pub struct SyncOffsetField {
|
||||
pub raw: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl SyncOffsetField {
|
||||
pub fn new(initial: SyncOffset) -> Self {
|
||||
let seconds = initial.as_ms() as f64 / 1000.0;
|
||||
SyncOffsetField {
|
||||
raw: format!("{:.1}", seconds),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderiza o campo e retorna true se o valor mudou.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, label: &str) -> bool {
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
if ui.text_edit_singleline(&mut self.raw).changed() {
|
||||
changed = true;
|
||||
self.error = None;
|
||||
}
|
||||
ui.label("s");
|
||||
});
|
||||
|
||||
if let Some(ref err) = self.error {
|
||||
ui.colored_label(egui::Color32::RED, err);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Tenta converter o valor atual para SyncOffset.
|
||||
pub fn parse(&mut self) -> Option<SyncOffset> {
|
||||
match SyncOffset::from_seconds_str(&self.raw) {
|
||||
Ok(offset) => {
|
||||
self.error = None;
|
||||
Some(offset)
|
||||
}
|
||||
Err(e) => {
|
||||
self.error = Some(format!("Valor inválido: {}", e));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use eframe::egui;
|
||||
use crate::adapters::filesystem::FilePickerAdapter;
|
||||
use crate::domain::value_objects::FilePath;
|
||||
|
||||
/// Componente para seleção do arquivo de vídeo base.
|
||||
/// Ao confirmar, dispara LoadMediaInfo via callback.
|
||||
pub struct VideoSelector {
|
||||
pub selected_path: Option<FilePath>,
|
||||
}
|
||||
|
||||
impl VideoSelector {
|
||||
pub fn new() -> Self {
|
||||
VideoSelector { selected_path: None }
|
||||
}
|
||||
|
||||
/// Renderiza o seletor. Retorna `Some(FilePath)` se um novo arquivo foi selecionado.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<FilePath> {
|
||||
let mut selected = None;
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.heading("Arquivo de vídeo");
|
||||
ui.horizontal(|ui| {
|
||||
let label = self
|
||||
.selected_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Nenhum arquivo selecionado".to_string());
|
||||
|
||||
ui.label(&label);
|
||||
|
||||
if ui.button("Selecionar…").clicked() {
|
||||
if let Some(path) = FilePickerAdapter::pick_video() {
|
||||
self.selected_path = Some(path.clone());
|
||||
selected = Some(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
selected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod app;
|
||||
pub mod components;
|
||||
Reference in New Issue
Block a user