- 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.
17 KiB
Plano de Desenvolvimento — Simple Multimedia Track Audio Editor
Versão: 1.3
Data: 28/02/2026
Status: Fases 1–6 concluídas; Fase 7 (Persistência) e Fase 8 (Modo Lote) em planejamento
Referência: PRD v1.2
Estratégia Geral
O desenvolvimento segue a Clean Architecture de dentro para fora: as camadas mais internas (domínio) são implementadas e testadas primeiro, sem qualquer dependência de I/O, frameworks ou processos externos. A UI é sempre a última camada a ser construída.
Fase 1: Setup
└── Fase 2: Domain (testável, sem I/O)
└── Fase 3: Application (testável com mocks)
└── Fase 4: Adapters (integração com FFmpeg)
└── Fase 5: Infrastructure (processo real)
└── Fase 6: UI (montagem final)
Cada fase deve estar compilando e com testes passando antes de avançar para a próxima.
Fase 1 — Setup do Projeto
Objetivo: Preparar o ambiente antes de escrever qualquer lógica de negócio.
Tarefas
- Atualizar
Cargo.tomlcom as dependências (rfd = "0.14"adicionado para diálogos nativos) - Criar a estrutura de pastas conforme especificado
- Declarar os módulos em
src/main.rs - Verificar que o projeto compila (
cargo check)
Fase 2 — Domain (núcleo puro)
Objetivo: Modelar o problema de negócio sem nenhum acoplamento a frameworks, I/O ou processos externos.
Regra: nenhum
use std::process, nenhumuse eframe, nenhuma chamada de rede ou filesystem nessa camada.
Value Objects (src/domain/value_objects/)
| Tipo | Implementação |
|---|---|
FilePath |
Newtype sobre PathBuf; derivar Clone, Debug, Serialize, Deserialize |
TrackId |
Newtype opaco sobre u32; derivar Clone, Copy, Debug, PartialEq, Eq, Hash |
SyncOffset |
Newtype sobre i64 (milissegundos, nunca f64); implementar método from_seconds_str e as_ms |
TrackLanguage |
Newtype sobre String (ex: "por", "eng"); validar formato ISO 639-2 |
Entities (src/domain/entities/)
| Tipo | Campos principais |
|---|---|
VideoFile |
path: FilePath |
AudioTrack |
id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage |
SubtitleTrack |
id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage |
MediaTrackInfo |
id: TrackId, kind: TrackKind (enum: Video/Audio/Subtitle), codec: String, language: Option<TrackLanguage> |
MkvOutput |
path: FilePath |
Project |
Entidade raiz — ver abaixo |
Estrutura de Project
pub struct Project {
pub source: VideoFile,
pub tracks: Vec<Track>, // faixas externas adicionadas pelo usuário
pub existing_tracks: Vec<MediaTrackInfo>, // faixas lidas do arquivo via ffprobe
pub output: MkvOutput,
}
Testes obrigatórios
SyncOffset::from_seconds_str("1.2")→SyncOffset(1200)SyncOffset::from_seconds_str("-0.5")→SyncOffset(-500)Projectnão aceitaoutput.pathigual asource.pathTrackIdé opaco (não expõe indexação interna)
Fase 3 — Application (casos de uso e ports)
Objetivo: Definir o que o sistema faz sem saber como. Ports são traits; use cases orquestram entidades.
Ports (src/application/ports/)
// MediaInfoPort: inspeciona faixas de um arquivo de mídia
pub trait MediaInfoPort {
fn probe(&self, path: &FilePath) -> Result<Vec<MediaTrackInfo>>;
}
// MediaProcessorPort: executa o processamento final
pub trait MediaProcessorPort {
fn execute(&self, args: Vec<String>) -> Result<()>;
}
// FileSystemPort: abstrai acesso ao sistema de arquivos
pub trait FileSystemPort {
fn exists(&self, path: &FilePath) -> bool;
}
Use Cases (src/application/use_cases/)
Implementar nesta ordem (dependência crescente):
| # | Use Case | Descrição |
|---|---|---|
| 1 | LoadMediaInfo |
Usa MediaInfoPort para popular Project::existing_tracks |
| 2 | AddAudioTrack |
Adiciona AudioTrack externo ao Project::tracks |
| 3 | AddSubtitle |
Adiciona SubtitleTrack externo ao Project::tracks |
| 4 | AdjustSync |
Altera SyncOffset de uma faixa existente pelo TrackId |
| 5 | EditExistingTrackSync |
Ajusta offset de faixa já presente no arquivo original |
| 6 | SetTrackLanguage |
Altera idioma de uma faixa pelo TrackId |
| 7 | GenerateOutput |
Constrói o comando final e delega ao MediaProcessorPort |
Testes obrigatórios
- Usar mocks dos ports (sem chamar nenhum processo externo)
GenerateOutputsempre produz comando com-c copy(verificado via mock)AdjustSynccomTrackIdinexistente retorna erroAddAudioTrackretorna erro sesourceeoutputconflitarem (coberto porProject::new)
Fase 4 — Adapters (implementações concretas)
Objetivo: Conectar o domínio ao FFmpeg e ao filesystem real.
FFmpeg (src/adapters/ffmpeg/)
FfmpegCommandBuilder
Responsabilidade única: converter Project em Vec<String> de argumentos para o FFmpeg.
Regras invariantes:
-c copysempre presente (RNF-01 — nunca opcional, nunca configurável)SyncOffset(i64 ms)→-itsoffset 1.200(conversão feita somente aqui)TrackId→-map 0:a:N(mapeamento de índice feito somente aqui)
Exemplo de saída esperada:
ffmpeg -i input.mkv -itsoffset 1.200 -i audio_pt.aac -map 0:v -map 0:a -map 1:a -c copy -metadata:s:a:1 language=por output.mkv
- Implementar
FfmpegCommandBuilder::build(project: &Project) -> Vec<String> - Implementar
FfmpegCommandBuilder::build_export(source, track, output) -> Vec<String>(exportação de faixas individuais) - Testes: verificar presença de
-c copy, ordem dos-map, formato de-itsoffset
FfprobeGateway
Implementa MediaInfoPort. Executa:
ffprobe -v quiet -print_format json -show_streams <path>
e mapeia a saída JSON para Vec<MediaTrackInfo>.
- Implementar parsing de JSON via
serde_json - Mapear
codec_typeparaTrackKind - Mapear
tags.languageparaOption<TrackLanguage>
FfmpegGateway
Implementa MediaProcessorPort. Executa o processo real do FFmpeg e captura stderr.
- Capturar stderr para exibição de erros (RF-07)
- Retornar erro com mensagem legível em caso de código de saída não-zero
Filesystem (src/adapters/filesystem/)
FilePickerAdapter: abstrai seleção de arquivo via diálogo nativo (craterfd; incluisave_audioesave_subtitlepor codec)
Fase 5 — Infrastructure
Objetivo: Execução real e não-bloqueante de processos externos.
Módulo src/infrastructure/process/
- Implementar execução via
tokio::process::Command(assíncrono, RNF-05) - Capturar stderr em stream via
run_ffmpeg_asynccomstd::sync::mpsc::Sender<String>(progresso em tempo real — RF-07) - Cancelamento de execução via
tokio::sync::oneshot+child.kill().await
Validação na inicialização
- Verificar se
ffmpegestá disponível noPATH(DT-06) - Verificar se
ffprobeestá disponível noPATH - Exibir mensagem de erro global (
global_error) na UI se algum dos dois estiver ausente
Fase 6 — UI (camada mais externa)
Objetivo: Interface gráfica que conecta o usuário ao Project via use cases.
Todo estado da aplicação vive no
Project. A UI apenas lê e dispara use cases.
Termos técnicos do FFmpeg nunca aparecem na interface (ver PRD seção 13).
Componentes (src/ui/components/), em ordem de construção
| # | Componente | Descrição |
|---|---|---|
| 1 | VideoSelector |
Seletor de arquivo de vídeo; dispara LoadMediaInfo ao confirmar |
| 2 | ExistingTrackList |
Lista faixas detectadas (áudio/legenda); permite editar offset de cada uma |
| 3 | AddAudioTrackForm |
Formulário para adicionar faixa de áudio externa (arquivo, idioma, offset) |
| 4 | AddSubtitleForm |
Formulário para adicionar legenda externa (arquivo, idioma, offset) |
| 5 | SyncOffsetField |
Campo de offset em segundos (ex: -1.2s); converte para SyncOffset(ms) internamente |
| 6 | LanguageField |
Seletor/input de idioma (ex: por, eng) |
| 7 | OutputSelector |
Campo de caminho de saída + extensão .mkv forçada |
| 8 | ExecutionPanel |
Botão "Gerar MKV", exibição de progresso e erros legíveis |
App (src/ui/app.rs)
- Implementar
eframe::AppparaApp Appcontémproject: Option<Project>como única fonte de verdade do estado- Despachar eventos de UI para os use cases correspondentes
- Integrar execução assíncrona (tokio +
std::thread) com o loop de UI do eframe - Botão "Cancelar" via
cancel_tx: Option<oneshot::Sender<()>> ExecutionState:Idle | Running | Success | Cancelled | Error
Fase 7 — Persistência do Estado do Projeto
Objetivo: Salvar e carregar o estado do Project em disco, permitindo que o usuário retome uma sessão anterior sem precisar reconfigurar tudo.
Pré-requisito para a Fase 8: o modo lote se beneficia diretamente da persistência — um carrinho salvo pode ser retomado após fechar a aplicação.
Restrição: a serialização deve viver exclusivamente na camada de infraestrutura/UI. O domain/ não deve depender de serde diretamente, mas as entidades já derivam Serialize/Deserialize — nenhuma mudança no domínio é necessária.
Formato e local do arquivo
- Formato: JSON via
serde_json(já disponível noCargo.toml) - Local: diretório de configuração do usuário (
~/.config/simple-mkv-editor/session.jsonno Linux;AppDatano Windows) - Um único arquivo de sessão por vez (sobrescreve ao salvar)
Quando salvar / carregar
| Evento | Ação |
|---|---|
| Usuário clica "Salvar sessão" | Serializa Project para disco |
| Inicialização da aplicação | Verifica se existe arquivo de sessão; oferece opção de restaurar |
| Usuário clica "Carregar sessão" | Desserializa e substitui App::project |
Salvamento automático fica fora do escopo desta fase para evitar escritas freqüentes em disco.
Mudanças necessárias
| Arquivo | O que muda |
|---|---|
src/infrastructure/persistence/mod.rs |
Novo módulo: save_session(project) e load_session() -> Result<Project> |
src/infrastructure/mod.rs |
Expor persistence |
src/ui/app.rs |
Botões "Salvar sessão" / "Carregar sessão" no header; chamar o módulo de persistência |
Tarefas
- Criar
src/infrastructure/persistence/mod.rscomsave_sessioneload_session - Resolver caminho do arquivo via
dirscrate (oustd::env) - Adicionar botões no header da UI
- Tratar erros de desserialização (arquivo corrompido ou versão incompatível) com mensagem amigável
Fase 8 — Modo Lote via Abas
Objetivo: Permitir que o usuário configure e processe múltiplos projetos sequencialmente, um por vez, sem sobrecarga de I/O.
Modelo mental — carrinho: o usuário adiciona itens livremente, na ordem que quiser, e só inicia o processamento quando clicar "Processar Tudo". Itens podem ser removidos do carrinho a qualquer momento antes de processar.
Restrição: Nenhuma camada abaixo da UI (domain, application, adapters, infrastructure) precisa ser alterada — Project já é a unidade de trabalho reutilizável.
Seleção de arquivos
Igual ao modo Projeto Único — diálogos nativos via FilePickerAdapter / rfd. Nenhum path é digitado manualmente pelo usuário. O formulário de cada item do lote expõe os mesmos botões "Escolher..." já existentes.
Formulário — Opção A (inline na aba Lote)
O formulário de configuração de um novo item expande inline na própria aba Lote, abaixo da lista. Não altera nem reutiliza a aba Projeto Único. Ao confirmar, o item é adicionado ao carrinho e o formulário é limpo.
[ + Adicionar item ] ← clique expande o formulário abaixo
┌─────────────────────────────────────────────────┐
│ Vídeo: [ep03.mkv ] [Escolher...] │
│ Saída: [ep03_pt.mkv ] [Escolher...] │
│ Faixas: [+ Áudio] [+ Legenda] │
│ [Cancelar] [Adicionar ✓] │
└─────────────────────────────────────────────────┘
Mudanças na UI
| Arquivo | O que muda |
|---|---|
src/ui/app.rs |
Adicionar enum ActiveTab { Single, Batch } e active_tab: ActiveTab; adicionar batch_items: Vec<BatchItem> |
src/ui/components/batch_panel.rs |
Novo componente: carrinho de itens, formulário inline de adição, botão "Processar Tudo" |
src/ui/components/mod.rs |
Expor batch_panel |
Estrutura de BatchItem
struct BatchItem {
project: Project,
state: ExecutionState, // reutiliza o enum já existente
}
Fluxo de execução do lote
- Usuário adiciona itens ao carrinho (zero ou mais), configura cada um com diálogo nativo
- Itens podem ser removidos do carrinho enquanto nenhum processamento estiver em curso
- Ao clicar "Processar Tudo", o lote é bloqueado (sem mais adições/remoções)
- Para cada item em ordem:
state → Running→run_ffmpeg_async→ aguardaBackgroundMsg::Done | Error→state → Success | Error→ próximo item - "Cancelar" interrompe o item atual via
cancel_tx; os demais permanecem no carrinho com estadoIdle
Tarefas
- Criar
enum ActiveTabe barra de abas noupdate()deApp - Criar
BatchItemebatch_items: Vec<BatchItem>emApp - Criar componente
BatchPanelcom carrinho e formulário inline (Opção A) - Implementar loop sequencial de execução em
App::start_batch_item()+ avanço automático empoll_background() - Exibir estado individual por item (
Aguardando | Processando | Concluído | Erro) - Bloquear adição/remoção de itens durante processamento
Critérios de Conclusão (v1.0)
Alinhados com o PRD seção 12:
- É possível selecionar um arquivo de vídeo base
- Ao selecionar o vídeo, as faixas existentes são listadas automaticamente (via
ffprobe) - É possível adicionar uma ou mais faixas de áudio externas
- É possível adicionar uma ou mais faixas de legenda
- É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
- É possível editar o offset de sincronização de uma faixa já existente no arquivo
- É possível atribuir idioma a cada faixa
- O arquivo MKV é gerado corretamente ao confirmar
- Erros do FFmpeg são exibidos de forma legível
- A interface não trava durante o processamento
- Nenhum reencoding ocorre (verificável via
ffprobeno arquivo de saída) - O flag
-c copyestá sempre presente no comando gerado (verificável via testes unitários)