feat: implement batch processing feature with tabbed interface

- Added `ActiveTab` enum to manage active tab state in `App`.
- Created `BatchItem` struct and integrated `batch_items` vector in `App`.
- Developed `BatchPanel` component for managing batch projects with inline forms.
- Implemented sequential processing in `App::start_batch_item()` with automatic advancement in `poll_background()`.
- Updated UI to display individual item states (` Aguardando | ⟳ Processando | ✓ Concluído | ⊸ Cancelado | ✗ Erro`).
- Blocked item addition/removal during processing.
- Enhanced session persistence to include batch projects in `SessionData`.
- Updated session save/load functions to handle new session structure.
This commit is contained in:
2026-02-28 20:28:24 -03:00
parent 5d8e92f33f
commit 121e919bbf
6 changed files with 880 additions and 124 deletions
+314 -106
View File
@@ -12,6 +12,7 @@ use crate::ui::components::{
add_audio_track_form::AddAudioTrackForm,
add_subtitle_form::AddSubtitleForm,
added_track_list::AddedTrackList,
batch_panel::{BatchItem, BatchPanel, BatchPanelEvent},
execution_panel::{ExecutionPanel, ExecutionState},
existing_track_list::ExistingTrackList,
output_selector::OutputSelector,
@@ -20,6 +21,13 @@ use crate::ui::components::{
use eframe::egui;
use std::sync::mpsc;
/// Aba ativa da interface.
#[derive(PartialEq)]
enum ActiveTab {
Single,
Batch,
}
/// Mensagens enviadas do background thread para a UI.
enum BackgroundMsg {
LogLine(String),
@@ -53,6 +61,15 @@ pub struct App {
// Mensagem de feedback de persistência (sucesso ou erro)
session_msg: Option<(String, bool)>, // (texto, é_erro)
// Aba ativa (Projeto Único ou Lote)
active_tab: ActiveTab,
// Itens do carrinho de lote
batch_items: Vec<BatchItem>,
// Painel de modo lote com estado próprio
batch_panel: BatchPanel,
// Índice do item sendo processado no lote (None = nenhum)
batch_processing_index: Option<usize>,
}
impl App {
@@ -88,6 +105,10 @@ impl App {
cancel_tx: None,
global_error,
session_msg,
active_tab: ActiveTab::Single,
batch_items: Vec::new(),
batch_panel: BatchPanel::new(),
batch_processing_index: None,
}
}
@@ -123,34 +144,79 @@ impl App {
}
/// Processa mensagens pendentes do background thread.
fn poll_background(&mut self) {
let mut done = false;
/// Em modo lote, atualiza o item em execução e avança para o próximo automaticamente.
fn poll_background(&mut self, ctx: &egui::Context) {
// Resultado do término: Ok ou Err(mensagem)
enum DoneResult {
Ok,
Err(String),
}
let mut done_result: Option<DoneResult> = None;
if let Some(rx) = &self.bg_rx {
loop {
match rx.try_recv() {
Ok(BackgroundMsg::LogLine(line)) => {
self.execution_panel.add_log(line);
if let Some(idx) = self.batch_processing_index {
let item = &mut self.batch_items[idx];
item.log_lines.push(line);
if item.log_lines.len() > 200 {
item.log_lines.remove(0);
}
} else {
self.execution_panel.add_log(line);
}
}
Ok(BackgroundMsg::Done) => {
self.execution_panel.state = ExecutionState::Success;
done = true;
done_result = Some(DoneResult::Ok);
break;
}
Ok(BackgroundMsg::Error(e)) => {
self.execution_panel.state = ExecutionState::Error(e);
done = true;
done_result = Some(DoneResult::Err(e));
break;
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
done = true;
done_result = Some(DoneResult::Ok);
break;
}
}
}
}
if done {
if let Some(result) = done_result {
self.bg_rx = None;
if let Some(idx) = self.batch_processing_index.take() {
// Modo lote: atualiza estado do item concluído
match result {
DoneResult::Ok => {
self.batch_items[idx].state = ExecutionState::Success;
}
DoneResult::Err(e) => {
self.batch_items[idx].state = ExecutionState::Error(e);
}
}
// Avança automaticamente para o próximo item aguardando
let next = self
.batch_items
.iter()
.position(|item| matches!(item.state, ExecutionState::Idle));
if let Some(next_idx) = next {
self.start_batch_item(next_idx, ctx.clone());
}
} else {
// Modo único
match result {
DoneResult::Ok => {
self.execution_panel.state = ExecutionState::Success;
}
DoneResult::Err(e) => {
self.execution_panel.state = ExecutionState::Error(e);
}
}
}
}
}
@@ -203,7 +269,7 @@ impl App {
});
}
/// Cancela a geração em andamento, encerrando o processo FFmpeg.
/// Cancela a geração em andamento (modo projeto único), encerrando o processo FFmpeg.
fn cancel_generation(&mut self) {
if let Some(tx) = self.cancel_tx.take() {
let _ = tx.send(());
@@ -211,20 +277,80 @@ impl App {
self.bg_rx = None;
self.execution_panel.state = ExecutionState::Cancelled;
}
/// Inicia o processamento de um item específico do carrinho de lote.
fn start_batch_item(&mut self, index: usize, ctx: egui::Context) {
let project = self.batch_items[index].project.clone();
self.batch_items[index].state = ExecutionState::Running;
self.batch_items[index].log_lines.clear();
self.batch_processing_index = Some(index);
let args = FfmpegCommandBuilder::build(&project);
let (tx, rx) = mpsc::channel::<BackgroundMsg>();
self.bg_rx = Some(rx);
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
self.cancel_tx = Some(cancel_tx);
std::thread::spawn(move || {
let (log_tx, log_rx) = std::sync::mpsc::channel::<String>();
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, cancel_rx));
let _ = fwd_handle.join();
match result {
Ok(_) => {
let _ = tx.send(BackgroundMsg::Done);
}
Err(e) => {
let _ = tx.send(BackgroundMsg::Error(e.to_string()));
}
}
ctx.request_repaint();
});
}
/// Cancela o item atual do lote, mantendo os demais como Aguardando.
fn cancel_batch_current(&mut self) {
if let Some(tx) = self.cancel_tx.take() {
let _ = tx.send(());
}
self.bg_rx = None;
if let Some(idx) = self.batch_processing_index.take() {
self.batch_items[idx].state = ExecutionState::Cancelled;
}
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Consome mensagens assíncronas
self.poll_background();
self.poll_background(ctx);
if self.bg_rx.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// ── Cabeçalho (fixo no topo) ──────────────────────────────────────────
let header_height = 42.0
+ if self.global_error.is_some() { 22.0 } else { 0.0 }
+ if self.session_msg.is_some() { 22.0 } else { 0.0 };
let header_height =
42.0 + if self.global_error.is_some() {
22.0
} else {
0.0
} + if self.session_msg.is_some() {
22.0
} else {
0.0
};
egui::TopBottomPanel::top("header_panel")
.exact_height(header_height)
.show(ctx, |ui| {
@@ -236,31 +362,37 @@ impl eframe::App for App {
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
// Botão "Salvar sessão"
let can_save = self.project.is_some();
let can_save =
self.project.is_some() || !self.batch_items.is_empty();
if ui
.add_enabled(
can_save,
egui::Button::new("💾 Salvar sessão"),
)
.on_disabled_hover_text(
"Abra um projeto para poder salvar a sessão.",
"Abra um projeto ou adicione itens ao lote para salvar a sessão.",
)
.clicked()
{
if let Some(ref project) = self.project {
match crate::infrastructure::persistence::save_session(
project,
) {
Ok(_) => {
self.session_msg = Some((
"Sessão salva com sucesso.".to_string(),
false,
));
}
Err(e) => {
self.session_msg =
Some((format!("Erro ao salvar: {}", e), true));
}
use crate::infrastructure::persistence::SessionData;
let data = SessionData {
single_project: self.project.clone(),
batch_projects: self
.batch_items
.iter()
.map(|item| item.project.clone())
.collect(),
};
match crate::infrastructure::persistence::save_session(&data) {
Ok(_) => {
self.session_msg = Some((
"Sessão salva com sucesso.".to_string(),
false,
));
}
Err(e) => {
self.session_msg =
Some((format!("Erro ao salvar: {}", e), true));
}
}
}
@@ -277,16 +409,34 @@ impl eframe::App for App {
.clicked()
{
match crate::infrastructure::persistence::load_session() {
Ok(project) => {
self.pending_source =
Some(project.source.path.clone());
self.pending_output =
Some(project.output.path.clone());
self.existing_track_list
.sync_tracks(&project.existing_tracks);
self.project = Some(project);
Ok(data) => {
// Restaura projeto único
if let Some(project) = data.single_project {
self.pending_source =
Some(project.source.path.clone());
self.pending_output =
Some(project.output.path.clone());
self.existing_track_list
.sync_tracks(&project.existing_tracks);
self.project = Some(project);
}
// Restaura itens do lote (estado resetado para Idle)
if !data.batch_projects.is_empty() {
use crate::ui::components::batch_panel::BatchItem;
self.batch_items = data
.batch_projects
.into_iter()
.map(BatchItem::new)
.collect();
}
let single_ok = self.project.is_some();
let batch_count = self.batch_items.len();
self.session_msg = Some((
"Sessão carregada com sucesso.".to_string(),
match (single_ok, batch_count) {
(true, 0) => "Sessão carregada com sucesso (projeto único).".to_string(),
(false, n) => format!("Sessão carregada com sucesso ({} ite{} no lote).", n, if n == 1 { "m" } else { "ns" }),
(true, n) => format!("Sessão carregada com sucesso (projeto único + {} ite{} no lote).", n, if n == 1 { "m" } else { "ns" }),
},
false,
));
}
@@ -324,78 +474,132 @@ impl eframe::App for App {
}
});
// ── 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();
let (generate, cancel) = self.execution_panel.ui(ui, can_generate);
if generate {
let ctx_clone = ctx.clone();
self.start_generation(ctx_clone);
}
if cancel {
self.cancel_generation();
}
ui.add_space(4.0);
// ── Barra de abas ─────────────────────────────────────────────────────
egui::TopBottomPanel::top("tab_bar").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.selectable_value(&mut self.active_tab, ActiveTab::Single, "Projeto Único");
ui.selectable_value(&mut self.active_tab, ActiveTab::Batch, "🗂 Lote");
});
});
// ── 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| {
// ── Painel de execução (fixo no rodapé, apenas modo Projeto Único) ─────
if self.active_tab == ActiveTab::Single {
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();
let (generate, cancel) = self.execution_panel.ui(ui, can_generate);
if generate {
let ctx_clone = ctx.clone();
self.start_generation(ctx_clone);
}
if cancel {
self.cancel_generation();
}
ui.add_space(4.0);
});
}
// ── Barra lateral esquerda (apenas modo Projeto Único) ─────────────────
if self.active_tab == ActiveTab::Single {
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);
// ── 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(8.0);
// ── Seleção de saída ──────────────────────────────────
// Passa o stem do vídeo para pré-preencher o nome sugerido
let source_stem: Option<&str> = self
.pending_source
.as_ref()
.and_then(|p| p.as_path().file_stem())
.and_then(|s| s.to_str());
if let Some(path) = self.output_selector.ui(ui, source_stem) {
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);
});
});
} // fim da barra lateral (modo Projeto Único)
// ── Painel central: faixas / formulários ou painel de lote ───────────
egui::CentralPanel::default().show(ctx, |ui| {
// Modo Lote
if self.active_tab == ActiveTab::Batch {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
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();
let is_processing = self.batch_processing_index.is_some();
let events = self.batch_panel.ui(ui, &self.batch_items, is_processing);
for event in events {
match event {
BatchPanelEvent::AddItem(project) => {
self.batch_items.push(BatchItem::new(project));
}
BatchPanelEvent::EditItem(idx, project) => {
if let Some(item) = self.batch_items.get_mut(idx) {
item.project = project;
item.state = ExecutionState::Idle;
item.log_lines.clear();
}
}
BatchPanelEvent::RemoveItem(idx) => {
self.batch_items.remove(idx);
}
BatchPanelEvent::ProcessAll => {
if let Some(idx) = self
.batch_items
.iter()
.position(|item| matches!(item.state, ExecutionState::Idle))
{
self.start_batch_item(idx, ctx.clone());
}
}
BatchPanelEvent::CancelCurrent => {
self.cancel_batch_current();
}
}
}
ui.add_space(8.0);
// ── Seleção de saída ──────────────────────────────────
// Passa o stem do vídeo para pré-preencher o nome sugerido
let source_stem: Option<&str> = self
.pending_source
.as_ref()
.and_then(|p| p.as_path().file_stem())
.and_then(|s| s.to_str());
if let Some(path) = self.output_selector.ui(ui, source_stem) {
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);
});
});
return;
}
// ── Painel central: faixas e formulários ─────────────────────────────
egui::CentralPanel::default().show(ctx, |ui| {
// Modo Projeto Único
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
@@ -422,11 +626,15 @@ impl eframe::App for App {
.and_then(|s| s.to_str())
.unwrap_or("track");
let maybe_output = match track.kind {
TrackKind::Audio => {
FilePickerAdapter::save_audio(base_stem, &track.codec)
}
TrackKind::Audio => FilePickerAdapter::save_audio(
base_stem,
&track.codec,
),
TrackKind::Subtitle => {
FilePickerAdapter::save_subtitle(base_stem, &track.codec)
FilePickerAdapter::save_subtitle(
base_stem,
&track.codec,
)
}
_ => None,
};