- 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.
353 lines
17 KiB
Markdown
353 lines
17 KiB
Markdown
# 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
|
||
|
||
- [x] Atualizar `Cargo.toml` com as dependências (`rfd = "0.14"` adicionado para diálogos nativos)
|
||
- [x] Criar a estrutura de pastas conforme especificado
|
||
- [x] Declarar os módulos em `src/main.rs`
|
||
- [x] 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`, nenhum `use 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`
|
||
|
||
```rust
|
||
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
|
||
|
||
- [x] `SyncOffset::from_seconds_str("1.2")` → `SyncOffset(1200)`
|
||
- [x] `SyncOffset::from_seconds_str("-0.5")` → `SyncOffset(-500)`
|
||
- [x] `Project` não aceita `output.path` igual a `source.path`
|
||
- [x] `TrackId` é 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/`)
|
||
|
||
```rust
|
||
// 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
|
||
|
||
- [x] Usar mocks dos ports (sem chamar nenhum processo externo)
|
||
- [x] `GenerateOutput` sempre produz comando com `-c copy` (verificado via mock)
|
||
- [x] `AdjustSync` com `TrackId` inexistente retorna erro
|
||
- [x] `AddAudioTrack` retorna erro se `source` e `output` conflitarem (coberto por `Project::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 copy` **sempre 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
|
||
```
|
||
|
||
- [x] Implementar `FfmpegCommandBuilder::build(project: &Project) -> Vec<String>`
|
||
- [x] Implementar `FfmpegCommandBuilder::build_export(source, track, output) -> Vec<String>` (exportação de faixas individuais)
|
||
- [x] 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>`.
|
||
|
||
- [x] Implementar parsing de JSON via `serde_json`
|
||
- [x] Mapear `codec_type` para `TrackKind`
|
||
- [x] Mapear `tags.language` para `Option<TrackLanguage>`
|
||
|
||
#### `FfmpegGateway`
|
||
|
||
Implementa `MediaProcessorPort`. Executa o processo real do FFmpeg e captura stderr.
|
||
|
||
- [x] Capturar stderr para exibição de erros (RF-07)
|
||
- [x] Retornar erro com mensagem legível em caso de código de saída não-zero
|
||
|
||
### Filesystem (`src/adapters/filesystem/`)
|
||
|
||
- [x] `FilePickerAdapter`: abstrai seleção de arquivo via diálogo nativo (crate `rfd`; inclui `save_audio` e `save_subtitle` por codec)
|
||
|
||
---
|
||
|
||
## Fase 5 — Infrastructure
|
||
|
||
**Objetivo:** Execução real e não-bloqueante de processos externos.
|
||
|
||
### Módulo `src/infrastructure/process/`
|
||
|
||
- [x] Implementar execução via `tokio::process::Command` (assíncrono, RNF-05)
|
||
- [x] Capturar stderr em stream via `run_ffmpeg_async` com `std::sync::mpsc::Sender<String>` (progresso em tempo real — RF-07)
|
||
- [x] Cancelamento de execução via `tokio::sync::oneshot` + `child.kill().await`
|
||
|
||
### Validação na inicialização
|
||
|
||
- [x] Verificar se `ffmpeg` está disponível no `PATH` (DT-06)
|
||
- [x] Verificar se `ffprobe` está disponível no `PATH`
|
||
- [x] 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`)
|
||
|
||
- [x] Implementar `eframe::App` para `App`
|
||
- [x] `App` contém `project: Option<Project>` como única fonte de verdade do estado
|
||
- [x] Despachar eventos de UI para os use cases correspondentes
|
||
- [x] Integrar execução assíncrona (tokio + `std::thread`) com o loop de UI do eframe
|
||
- [x] Botão "Cancelar" via `cancel_tx: Option<oneshot::Sender<()>>`
|
||
- [x] `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 no `Cargo.toml`)
|
||
- Local: diretório de configuração do usuário (`~/.config/simple-mkv-editor/session.json` no Linux; `AppData` no 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.rs` com `save_session` e `load_session`
|
||
- [ ] Resolver caminho do arquivo via `dirs` crate (ou `std::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`
|
||
|
||
```rust
|
||
struct BatchItem {
|
||
project: Project,
|
||
state: ExecutionState, // reutiliza o enum já existente
|
||
}
|
||
```
|
||
|
||
### Fluxo de execução do lote
|
||
|
||
1. Usuário adiciona itens ao carrinho (zero ou mais), configura cada um com diálogo nativo
|
||
2. Itens podem ser removidos do carrinho enquanto nenhum processamento estiver em curso
|
||
3. Ao clicar "Processar Tudo", o lote é bloqueado (sem mais adições/remoções)
|
||
4. Para cada item em ordem: `state → Running` → `run_ffmpeg_async` → aguarda `BackgroundMsg::Done | Error` → `state → Success | Error` → próximo item
|
||
5. "Cancelar" interrompe o item atual via `cancel_tx`; os demais permanecem no carrinho com estado `Idle`
|
||
|
||
### Tarefas
|
||
|
||
- [x] Criar `enum ActiveTab` e barra de abas no `update()` de `App`
|
||
- [x] Criar `BatchItem` e `batch_items: Vec<BatchItem>` em `App`
|
||
- [x] Criar componente `BatchPanel` com carrinho e formulário inline (Opção A)
|
||
- [x] Implementar loop sequencial de execução em `App::start_batch_item()` + avanço automático em `poll_background()`
|
||
- [x] Exibir estado individual por item (`Aguardando | Processando | Concluído | Erro`)
|
||
- [x] Bloquear adição/remoção de itens durante processamento
|
||
|
||
---
|
||
|
||
## Critérios de Conclusão (v1.0)
|
||
|
||
Alinhados com o PRD seção 12:
|
||
|
||
- [x] É possível selecionar um arquivo de vídeo base
|
||
- [x] Ao selecionar o vídeo, as faixas existentes são listadas automaticamente (via `ffprobe`)
|
||
- [x] É possível adicionar uma ou mais faixas de áudio externas
|
||
- [x] É possível adicionar uma ou mais faixas de legenda
|
||
- [x] É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
|
||
- [x] É possível editar o offset de sincronização de uma faixa já existente no arquivo
|
||
- [x] É possível atribuir idioma a cada faixa
|
||
- [x] O arquivo MKV é gerado corretamente ao confirmar
|
||
- [x] Erros do FFmpeg são exibidos de forma legível
|
||
- [x] A interface não trava durante o processamento
|
||
- [x] Nenhum reencoding ocorre (verificável via `ffprobe` no arquivo de saída)
|
||
- [x] O flag `-c copy` está sempre presente no comando gerado (verificável via testes unitários)
|