From 4dec1ef8ed1624a312e0ab202e6167c381a6706d Mon Sep 17 00:00:00 2001 From: Felipe Canin Novaes Date: Sat, 28 Feb 2026 14:57:11 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20adiciona=20funcionalidade=20de=20cancel?= =?UTF-8?q?amento=20de=20gera=C3=A7=C3=A3o=20de=20faixas=20de=20=C3=A1udio?= =?UTF-8?q?=20e=20atualiza=20o=20painel=20de=20execu=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 10 +++--- src/infrastructure/process/mod.rs | 31 +++++++++++------ src/ui/app.rs | 24 +++++++++++-- src/ui/components/execution_panel.rs | 51 ++++++++++++++++++---------- 4 files changed, 83 insertions(+), 33 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 63c4535..6f3f629 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,7 @@ # Progresso de Implementação **Data:** 28/02/2026 -**Status:** Fases 1–6 concluídas + exportação de faixa + exibição/remoção de faixas adicionadas + layout responsivo + progresso em tempo real — compilando, testes passando, aplicação executável +**Status:** Fases 1–6 concluídas + exportação de faixa + exibição/remoção de faixas adicionadas + layout responsivo + progresso em tempo real + cancelamento de geração — compilando, testes passando, aplicação executável **Referência:** DEVELOPMENT_PLAN.md v1.0 --- @@ -16,6 +16,7 @@ Após a conclusão das fases, foram implementadas funcionalidades adicionais: - Exibição das faixas externas adicionadas com opção de remoção individual - Layout responsivo com painéis fixos (cabeçalho, rodapé, barra lateral) e área central com scroll - Progresso em tempo real via `run_ffmpeg_async` (Opção A: `std::sync::mpsc` em toda a cadeia) +- Cancelamento de geração em andamento via botão "Cancelar" — encerra o processo FFmpeg filho imediatamente --- @@ -86,11 +87,11 @@ src/ ├── infrastructure/ │ ├── mod.rs │ └── process/ -│ └── mod.rs — run_ffmpeg_async (tokio), validate_dependencies() + └── mod.rs — run_ffmpeg_async (tokio, cancel via oneshot), validate_dependencies() │ └── ui/ ├── mod.rs - ├── app.rs — eframe::App; Project como única fonte de verdade + ├── app.rs — eframe::App; Project como única fonte de verdade; cancel_tx para interromper FFmpeg └── components/ ├── mod.rs ├── video_selector.rs — seleção do vídeo base @@ -99,7 +100,7 @@ src/ ├── add_subtitle_form.rs — formulário: legenda externa ├── sync_offset_field.rs — campo de atraso em segundos ├── language_field.rs — campo de idioma ISO 639-2 - └── execution_panel.rs — botão gerar, progresso, erros + └── execution_panel.rs — botão gerar, botão cancelar, progresso, estados: Idle/Running/Success/Cancelled/Error ``` --- @@ -195,6 +196,7 @@ rfd = "0.14" - [x] Exportar faixa de áudio ou legenda existente para arquivo separado - [x] Exibição de faixas externas adicionadas com opção de remover - [x] Progresso em tempo real via `run_ffmpeg_async` — `run_ffmpeg_async` usa `std::sync::mpsc::Sender`; `start_generation` cria tokio Runtime + thread encaminhadora; cada linha de stderr do FFmpeg aparece no log antes do término +- [x] Cancelamento de geração — botão "Cancelar" visível durante `Running`; sinal via `tokio::sync::oneshot`; `child.kill().await` no `tokio::select!`; estado `ExecutionState::Cancelled` exibido em amarelo - [ ] Exibição do comando gerado em modo debug ### Qualidade diff --git a/src/infrastructure/process/mod.rs b/src/infrastructure/process/mod.rs index 266e388..d0ef543 100644 --- a/src/infrastructure/process/mod.rs +++ b/src/infrastructure/process/mod.rs @@ -5,7 +5,12 @@ use tokio::process::Command; /// Executa o FFmpeg de forma assíncrona, capturando stderr em tempo real. /// As linhas de saída são enviadas pelo canal `progress_tx` (std::sync::mpsc). -pub async fn run_ffmpeg_async(args: Vec, progress_tx: Sender) -> Result<()> { +/// Um sinal enviado em `cancel_rx` encerra o processo imediatamente. +pub async fn run_ffmpeg_async( + args: Vec, + progress_tx: Sender, + cancel_rx: tokio::sync::oneshot::Receiver<()>, +) -> Result<()> { let mut child = Command::new("ffmpeg") .args(&args) .stderr(std::process::Stdio::piped()) @@ -25,16 +30,22 @@ pub async fn run_ffmpeg_async(args: Vec, progress_tx: Sender) -> }); } - 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() - )); + tokio::select! { + result = child.wait() => { + let status = result.context("Erro ao aguardar ffmpeg")?; + if !status.success() { + return Err(anyhow!( + "FFmpeg encerrou com código de saída: {:?}", + status.code() + )); + } + Ok(()) + } + _ = cancel_rx => { + let _ = child.kill().await; + Err(anyhow!("Cancelado pelo usuário")) + } } - - Ok(()) } /// Verifica se um binário está disponível no PATH. diff --git a/src/ui/app.rs b/src/ui/app.rs index dd4cad4..5b1f5bb 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -45,6 +45,9 @@ pub struct App { // Canal de comunicação do background thread bg_rx: Option>, + // Sender para cancelar a geração em andamento + cancel_tx: Option>, + // Mensagem de erro global (ex: dependências ausentes) global_error: Option, } @@ -68,6 +71,7 @@ impl App { add_subtitle_form: AddSubtitleForm::new(), execution_panel: ExecutionPanel::new(), bg_rx: None, + cancel_tx: None, global_error, } } @@ -148,6 +152,9 @@ impl App { self.execution_panel.state = ExecutionState::Running; self.execution_panel.log_lines.clear(); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); + self.cancel_tx = Some(cancel_tx); + std::thread::spawn(move || { // Canal std para receber linhas de log do run_ffmpeg_async let (log_tx, log_rx) = std::sync::mpsc::channel::(); @@ -163,7 +170,7 @@ impl App { }); let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio"); - let result = rt.block_on(run_ffmpeg_async(args, log_tx)); + let result = rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx)); // Aguarda o encaminhador consumir todas as linhas pendentes // antes de enviar Done/Error, garantindo ordem correta no log @@ -180,6 +187,15 @@ impl App { ctx.request_repaint(); }); } + + /// Cancela a geração em andamento, encerrando o processo FFmpeg. + fn cancel_generation(&mut self) { + if let Some(tx) = self.cancel_tx.take() { + let _ = tx.send(()); + } + self.bg_rx = None; + self.execution_panel.state = ExecutionState::Cancelled; + } } impl eframe::App for App { @@ -215,10 +231,14 @@ impl eframe::App for App { .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 (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); }); diff --git a/src/ui/components/execution_panel.rs b/src/ui/components/execution_panel.rs index 38def7d..40f8d3f 100644 --- a/src/ui/components/execution_panel.rs +++ b/src/ui/components/execution_panel.rs @@ -7,6 +7,7 @@ pub enum ExecutionState { Idle, Running, Success, + Cancelled, Error(String), } @@ -32,28 +33,41 @@ impl ExecutionPanel { } } - /// 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; + /// Renderiza o painel. + /// Retorna `(generate_clicked, cancel_clicked)`. + pub fn ui(&mut self, ui: &mut egui::Ui, can_generate: bool) -> (bool, bool) { + let mut generate_clicked = false; + let mut cancel_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; - } + ui.horizontal(|ui| { + if ui + .add_enabled( + can_generate && !is_running, + egui::Button::new(if is_running { + "Processando…" + } else { + "Gerar MKV" + }), + ) + .clicked() + { + generate_clicked = true; + } + + if is_running + && ui + .add(egui::Button::new("Cancelar").fill(egui::Color32::DARK_RED)) + .on_hover_text("Interromper o processamento") + .clicked() + { + cancel_clicked = true; + } + }); match &self.state { ExecutionState::Idle => {} @@ -64,6 +78,9 @@ impl ExecutionPanel { ExecutionState::Success => { ui.colored_label(egui::Color32::GREEN, "✓ Arquivo gerado com sucesso!"); } + ExecutionState::Cancelled => { + ui.colored_label(egui::Color32::YELLOW, "⊘ Geração cancelada pelo usuário."); + } ExecutionState::Error(msg) => { ui.colored_label(egui::Color32::RED, "✗ Erro durante o processamento:"); ui.add( @@ -88,6 +105,6 @@ impl ExecutionPanel { } }); - clicked + (generate_clicked, cancel_clicked) } }