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
+247
View File
@@ -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);
}
});
}
}