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:
+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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user