Files
simple-multimidia-track-aud…/DEVELOPMENT_PLAN.md
T
Felipe 121e919bbf 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.
2026-02-28 20:28:24 -03:00

353 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plano de Desenvolvimento — Simple Multimedia Track Audio Editor
**Versão:** 1.3
**Data:** 28/02/2026
**Status:** Fases 16 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)