246 lines
9.7 KiB
Markdown
246 lines
9.7 KiB
Markdown
# Plano de Desenvolvimento — Simple Multimedia Track Audio Editor
|
|
|
|
**Versão:** 1.1
|
|
**Data:** 28/02/2026
|
|
**Status:** Concluído — todas as fases implementadas, 33 testes passando
|
|
**Referência:** PRD v1.1
|
|
|
|
---
|
|
|
|
## 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`
|
|
|
|
---
|
|
|
|
## 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)
|