feat: adiciona funcionalidade de remoção de faixas externas e atualiza o layout da UI

This commit is contained in:
2026-02-28 14:48:02 -03:00
parent 4aa7cf2dd4
commit 02c6d8ede2
9 changed files with 401 additions and 138 deletions
+1
View File
@@ -5,4 +5,5 @@ pub mod edit_existing_track_sync;
pub mod export_track;
pub mod generate_output;
pub mod load_media_info;
pub mod remove_track;
pub mod set_track_language;
+54
View File
@@ -0,0 +1,54 @@
use crate::domain::entities::Project;
use crate::domain::value_objects::TrackId;
use anyhow::{Result, anyhow};
/// Remove uma faixa externa do projeto pelo seu TrackId.
pub struct RemoveTrack;
impl RemoveTrack {
pub fn execute(project: &mut Project, id: TrackId) -> Result<()> {
if project.remove_track(id) {
Ok(())
} else {
Err(anyhow!("Faixa com id {:?} não encontrada.", id))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entities::{AudioTrack, MkvOutput, Project, Track, VideoFile};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
fn make_project() -> Project {
Project::new(
VideoFile::new(FilePath::from("input.mkv")),
MkvOutput::new(FilePath::from("output.mkv")),
)
.unwrap()
}
#[test]
fn remove_faixa_existente() {
let mut project = make_project();
let id = project.next_track_id();
project.tracks.push(Track::Audio(AudioTrack::new(
id,
FilePath::from("audio.aac"),
SyncOffset::from_ms(0),
TrackLanguage::new("por").unwrap(),
)));
assert_eq!(project.tracks.len(), 1);
RemoveTrack::execute(&mut project, id).unwrap();
assert!(project.tracks.is_empty());
}
#[test]
fn erro_se_id_inexistente() {
let mut project = make_project();
let result = RemoveTrack::execute(&mut project, TrackId::new(99));
assert!(result.is_err());
}
}
+7
View File
@@ -47,6 +47,13 @@ impl Project {
pub fn find_existing_track_mut(&mut self, id: TrackId) -> Option<&mut MediaTrackInfo> {
self.existing_tracks.iter_mut().find(|t| t.id == id)
}
/// Remove uma faixa externa pelo TrackId. Retorna true se a faixa foi encontrada e removida.
pub fn remove_track(&mut self, id: TrackId) -> bool {
let before = self.tracks.len();
self.tracks.retain(|t| t.id() != id);
self.tracks.len() < before
}
}
#[cfg(test)]
+10 -8
View File
@@ -1,14 +1,11 @@
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result, anyhow};
use std::sync::mpsc::Sender;
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<()> {
/// As linhas de saída são enviadas pelo canal `progress_tx` (std::sync::mpsc).
pub async fn run_ffmpeg_async(args: Vec<String>, progress_tx: Sender<String>) -> Result<()> {
let mut child = Command::new("ffmpeg")
.args(&args)
.stderr(std::process::Stdio::piped())
@@ -47,7 +44,12 @@ pub fn check_binary_available(binary: &str) -> Result<()> {
.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))?;
.map_err(|_| {
anyhow!(
"'{}' não encontrado no PATH. Por favor, instale o FFmpeg.",
binary
)
})?;
Ok(())
}
+2 -2
View File
@@ -9,8 +9,8 @@ fn main() -> eframe::Result<()> {
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]),
.with_inner_size([1100.0, 720.0])
.with_min_inner_size([700.0, 500.0]),
..Default::default()
};
+222 -119
View File
@@ -1,20 +1,24 @@
use eframe::egui;
use std::sync::mpsc;
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
use crate::adapters::filesystem::file_picker::FilePickerAdapter;
use crate::application::ports::MediaProcessorPort;
use crate::application::use_cases::{
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack,
load_media_info::LoadMediaInfo,
load_media_info::LoadMediaInfo, remove_track::RemoveTrack,
};
use crate::domain::entities::{MkvOutput, Project, VideoFile};
use crate::domain::value_objects::FilePath;
use crate::infrastructure::process::run_ffmpeg_async;
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,
add_audio_track_form::AddAudioTrackForm,
add_subtitle_form::AddSubtitleForm,
added_track_list::AddedTrackList,
execution_panel::{ExecutionPanel, ExecutionState},
existing_track_list::ExistingTrackList,
output_selector::OutputSelector,
video_selector::VideoSelector,
};
use eframe::egui;
use std::sync::mpsc;
/// Mensagens enviadas do background thread para a UI.
enum BackgroundMsg {
@@ -33,6 +37,7 @@ pub struct App {
video_selector: VideoSelector,
output_selector: OutputSelector,
existing_track_list: ExistingTrackList,
added_track_list: AddedTrackList,
add_audio_form: AddAudioTrackForm,
add_subtitle_form: AddSubtitleForm,
execution_panel: ExecutionPanel,
@@ -47,7 +52,8 @@ pub struct App {
impl App {
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
// Verifica dependências na inicialização
let global_error = crate::infrastructure::process::validate_dependencies().err()
let global_error = crate::infrastructure::process::validate_dependencies()
.err()
.map(|e| e.to_string());
App {
@@ -57,6 +63,7 @@ impl App {
video_selector: VideoSelector::new(),
output_selector: OutputSelector::new(),
existing_track_list: ExistingTrackList::new(),
added_track_list: AddedTrackList::new(),
add_audio_form: AddAudioTrackForm::new(),
add_subtitle_form: AddSubtitleForm::new(),
execution_panel: ExecutionPanel::new(),
@@ -81,7 +88,8 @@ impl App {
let probe = FfprobeGateway;
match LoadMediaInfo::execute(&mut project, &probe) {
Ok(_) => {
self.existing_track_list.sync_tracks(&project.existing_tracks);
self.existing_track_list
.sync_tracks(&project.existing_tracks);
}
Err(e) => {
self.execution_panel.state = ExecutionState::Error(e.to_string());
@@ -127,7 +135,7 @@ impl App {
}
}
/// Inicia geração do MKV em background thread.
/// Inicia geração do MKV em background thread com progresso em tempo real.
fn start_generation(&mut self, ctx: egui::Context) {
let project = match &self.project {
Some(p) => p.clone(),
@@ -141,8 +149,27 @@ impl App {
self.execution_panel.log_lines.clear();
std::thread::spawn(move || {
let gateway = FfmpegGateway;
match gateway.execute(args) {
// Canal std para receber linhas de log do run_ffmpeg_async
let (log_tx, log_rx) = std::sync::mpsc::channel::<String>();
// Thread auxiliar: encaminha cada linha de log para o canal da UI
let fwd_tx = tx.clone();
let fwd_ctx = ctx.clone();
let fwd_handle = std::thread::spawn(move || {
for line in log_rx {
let _ = fwd_tx.send(BackgroundMsg::LogLine(line));
fwd_ctx.request_repaint();
}
});
let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio");
let result = rt.block_on(run_ffmpeg_async(args, log_tx));
// Aguarda o encaminhador consumir todas as linhas pendentes
// antes de enviar Done/Error, garantindo ordem correta no log
let _ = fwd_handle.join();
match result {
Ok(_) => {
let _ = tx.send(BackgroundMsg::Done);
}
@@ -163,125 +190,201 @@ impl eframe::App for App {
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();
// ── Cabeçalho (fixo no topo) ──────────────────────────────────────────
egui::TopBottomPanel::top("header_panel")
.exact_height(if self.global_error.is_some() {
58.0
} else {
38.0
})
.show(ctx, |ui| {
ui.add_space(6.0);
ui.heading("Editor de Faixas de Mídia");
if let Some(ref err) = self.global_error {
ui.add_space(2.0);
ui.colored_label(
egui::Color32::RED,
format!("⚠ Dependência ausente: {}", err),
);
}
}
});
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();
// ── Painel de execução (fixo no rodapé) ───────────────────────────────
egui::TopBottomPanel::bottom("execution_panel")
.resizable(false)
.show(ctx, |ui| {
ui.add_space(4.0);
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);
}
}
ui.add_space(4.0);
});
ui.add_space(8.0);
// ── Barra lateral esquerda: seleção de arquivos ───────────────────────
egui::SidePanel::left("sidebar_panel")
.min_width(220.0)
.max_width(400.0)
.resizable(true)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
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;
use crate::domain::entities::TrackKind;
match event {
ExistingTrackEvent::OffsetChanged(id, offset) => {
let _ = EditExistingTrackSync::execute(project, id, offset);
// ── 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();
}
}
ExistingTrackEvent::ExportRequested(id) => {
// Localiza a faixa pelo id
if let Some(track) = project
.existing_tracks
.iter()
.find(|t| t.id == id)
.cloned()
{
let maybe_output = match track.kind {
TrackKind::Audio => FilePickerAdapter::save_audio(&track.codec),
TrackKind::Subtitle => FilePickerAdapter::save_subtitle(&track.codec),
_ => None,
};
if let Some(output_path) = maybe_output {
let gateway = FfmpegGateway;
match ExportTrack::execute(
&project.source.path,
&track,
&output_path,
&gateway,
) {
Ok(_) => {
self.execution_panel.state =
ExecutionState::Success;
self.execution_panel.add_log(format!(
"Faixa exportada para: {}",
output_path.to_string_lossy()
));
}
Err(e) => {
self.execution_panel.state =
ExecutionState::Error(e.to_string());
ui.add_space(8.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();
}
}
if self.project.is_none() {
ui.add_space(16.0);
ui.separator();
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()
.weak(),
);
}
ui.add_space(8.0);
});
});
// ── Painel central: faixas e formulários ─────────────────────────────
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
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::domain::entities::TrackKind;
use crate::ui::components::existing_track_list::ExistingTrackEvent;
match event {
ExistingTrackEvent::OffsetChanged(id, offset) => {
let _ = EditExistingTrackSync::execute(project, id, offset);
}
ExistingTrackEvent::ExportRequested(id) => {
if let Some(track) =
project.existing_tracks.iter().find(|t| t.id == id).cloned()
{
let maybe_output = match track.kind {
TrackKind::Audio => {
FilePickerAdapter::save_audio(&track.codec)
}
TrackKind::Subtitle => {
FilePickerAdapter::save_subtitle(&track.codec)
}
_ => None,
};
if let Some(output_path) = maybe_output {
let gateway = FfmpegGateway;
match ExportTrack::execute(
&project.source.path,
&track,
&output_path,
&gateway,
) {
Ok(_) => {
self.execution_panel.state =
ExecutionState::Success;
self.execution_panel.add_log(format!(
"Faixa exportada para: {}",
output_path.to_string_lossy()
));
}
Err(e) => {
self.execution_panel.state =
ExecutionState::Error(e.to_string());
}
}
}
}
}
}
}
ui.add_space(8.0);
// ── Formulários: lado a lado se largura >= 600 px ─────
// Executa o render fora do bloco de borrow de `project`
// e coleta as requisições para aplicar em seguida.
let wide = ui.available_width() >= 600.0;
let (audio_req, subtitle_req) = if wide {
// Renderiza dois painéis horizontais de largura igual
let available = ui.available_width();
let col_w = (available - ui.spacing().item_spacing.x) / 2.0;
let audio_req = ui
.allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| self.add_audio_form.ui(ui),
)
.inner;
let subtitle_req = ui
.allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| self.add_subtitle_form.ui(ui),
)
.inner;
(audio_req, subtitle_req)
} else {
let ar = self.add_audio_form.ui(ui);
ui.add_space(4.0);
let sr = self.add_subtitle_form.ui(ui);
(ar, sr)
};
if let Some(req) = audio_req {
let _ =
AddAudioTrack::execute(project, req.path, req.offset, req.language);
}
if let Some(req) = subtitle_req {
let _ =
AddSubtitle::execute(project, req.path, req.offset, req.language);
}
ui.add_space(8.0);
// ── Faixas externas adicionadas ───────────────────────
let remove_events = self.added_track_list.ui(ui, &project.tracks);
for event in remove_events {
use crate::ui::components::added_track_list::AddedTrackEvent;
match event {
AddedTrackEvent::RemoveRequested(id) => {
let _ = RemoveTrack::execute(project, id);
}
}
}
ui.add_space(8.0);
}
}
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);
}
});
});
}
}
+86
View File
@@ -0,0 +1,86 @@
use crate::domain::entities::Track;
use crate::domain::value_objects::TrackId;
use eframe::egui;
/// Evento emitido por interações com a lista de faixas externas adicionadas.
pub enum AddedTrackEvent {
RemoveRequested(TrackId),
}
/// Lista as faixas externas adicionadas pelo usuário e permite removê-las.
pub struct AddedTrackList;
impl AddedTrackList {
pub fn new() -> Self {
AddedTrackList
}
/// Renderiza a lista. Retorna eventos de remoção.
pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> {
let mut events = Vec::new();
ui.group(|ui| {
ui.heading("Faixas adicionadas");
if tracks.is_empty() {
ui.label("Nenhuma faixa adicionada.");
return;
}
egui::Grid::new("added_tracks_grid")
.num_columns(4)
.striped(true)
.show(ui, |ui| {
ui.strong("Tipo");
ui.strong("Arquivo");
ui.strong("Idioma");
ui.strong("");
ui.end_row();
for track in tracks {
let (kind_label, file_name, language) = match track {
Track::Audio(t) => (
"Áudio",
t.path
.as_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string(),
t.language.as_str().to_string(),
),
Track::Subtitle(t) => (
"Legenda",
t.path
.as_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string(),
t.language.as_str().to_string(),
),
};
ui.label(kind_label);
ui.label(&file_name).on_hover_text(track_full_path(track));
ui.label(language);
if ui.small_button("🗑 Remover").clicked() {
events.push(AddedTrackEvent::RemoveRequested(track.id()));
}
ui.end_row();
}
});
});
events
}
}
fn track_full_path(track: &Track) -> String {
match track {
Track::Audio(t) => t.path.to_string_lossy().into_owned(),
Track::Subtitle(t) => t.path.to_string_lossy().into_owned(),
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod add_audio_track_form;
pub mod add_subtitle_form;
pub mod added_track_list;
pub mod execution_panel;
pub mod existing_track_list;
pub mod language_field;