Files
simple-multimidia-track-aud…/DEVELOPMENT_PLAN.md
T

733 lines
32 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
---
## Fase 9 — Correção de Drift via mkvmerge
**Objetivo:** Suportar correção de drift progressivo de sincronização (escala temporal) em faixas de áudio e legenda, sem reencoding e sem violar RNF-01, utilizando `mkvmerge` (MKVToolNix) como segundo motor de processamento.
**Referência:** `proposta_sync_drift.md`
**Decisão arquitetural central:** O FFmpeg não é capaz de escalar timestamps com `-c copy` — qualquer escala via `-af atempo` reencoda o áudio, violando RNF-01 diretamente. O `mkvmerge` suporta a opção `--sync TID:DELAY,NUM/DEN` que aplica deslocamento e escala de timestamps **no nível do container**, sem tocar nos bytes de mídia. Esta é a única rota viável que respeita a arquitetura atual.
**Regra de dispatch:**
- `scale = 1.0` em todas as faixas → pipeline FFmpeg (comportamento atual, **sem modificação**)
- Qualquer faixa com `scale ≠ 1.0` → pipeline mkvmerge inteiro para o projeto
**Dependência nova:** `mkvmerge` (binário do pacote MKVToolNix). Verificação **soft** (aviso, não erro bloqueador) — a funcionalidade de drift fica visualmente desabilitada se ausente; o restante do produto continua funcional.
---
### 9.1 — Domain: novo Value Object `SyncTransform`
**Arquivo:** `src/domain/value_objects/sync_transform.rs` _(novo)_
Novo value object que representa a transformação temporal completa de uma faixa: deslocamento constante + fator de escala.
```rust
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SyncTransform {
pub offset_ms: i64, // deslocamento em ms (mesmo semântico de SyncOffset)
pub scale: f64, // fator de escala: 1.0 = identidade; 0.99983 = ~25fps→24fps
}
impl SyncTransform {
pub fn new(offset_ms: i64, scale: f64) -> Self
pub fn from_offset(offset: SyncOffset) -> Self // scale = 1.0
pub fn has_drift(&self) -> bool // (scale - 1.0).abs() > 1e-9
pub fn is_identity(&self) -> bool // offset_ms == 0 && !has_drift()
pub fn to_sync_offset(&self) -> SyncOffset // para compatibilidade com FFmpeg path
}
impl Default for SyncTransform {
// { offset_ms: 0, scale: 1.0 }
}
```
**Arquivo:** `src/domain/value_objects/mod.rs` _(modificar)_
- Adicionar `pub mod sync_transform;` e reexportar `SyncTransform`.
---
### 9.2 — Domain: campo `drift_scale` nas entidades de faixa
Estratégia: **não substituir** `offset: SyncOffset` existente (preserva compilação de todo código atual). Adicionar `drift_scale: f64` com default `1.0` em paralelo.
**Arquivo:** `src/domain/entities/audio_track.rs` _(modificar)_
- Adicionar campo `pub drift_scale: f64` com valor padrão `1.0`.
- Atualizar `AudioTrack::new()` para aceitar o parâmetro `drift_scale: f64`.
**Arquivo:** `src/domain/entities/subtitle_track.rs` _(modificar)_
- Idem: adicionar `pub drift_scale: f64` e atualizar construtor.
**Arquivo:** `src/domain/entities/media_track_info.rs` _(modificar)_
- Adicionar campo `pub drift_scale: f64` com valor padrão `1.0`.
- `MediaTrackInfo::new()` continua sem o parâmetro (usa default); campo mutável diretamente.
**Arquivo:** `src/domain/entities/track.rs` _(modificar)_
- Adicionar método `pub fn drift_scale(&self) -> f64` que retorna o campo de `AudioTrack` ou `SubtitleTrack`.
- Adicionar método `pub fn set_drift_scale(&mut self, scale: f64)`.
**Arquivo:** `src/domain/entities/project.rs` _(modificar)_
- Adicionar método:
```rust
pub fn needs_mkvmerge(&self) -> bool {
let has_drift = |scale: f64| (scale - 1.0).abs() > 1e-9;
self.tracks.iter().any(|t| has_drift(t.drift_scale()))
|| self.existing_tracks.iter().any(|t| has_drift(t.drift_scale))
}
```
**Testes obrigatórios (`src/domain/entities/project.rs`):**
- `needs_mkvmerge()` retorna `false` quando todas as faixas têm `drift_scale = 1.0`
- `needs_mkvmerge()` retorna `true` quando qualquer faixa tem `drift_scale ≠ 1.0`
- `SyncTransform::has_drift()` com `scale = 1.0` retorna `false`
- `SyncTransform::has_drift()` com `scale = 0.99983` retorna `true`
- `SyncTransform::default()` é identidade
---
### 9.3 — Application: novos use cases e port
**Arquivo:** `src/application/ports/mod.rs` _(modificar)_
- Adicionar novo port:
```rust
/// Port para muxing com suporte a escala temporal (implementado pelo MkvmergeGateway).
pub trait ContainerMuxPort {
fn execute(&self, args: Vec<String>) -> Result<()>;
}
```
_(estruturalmente idêntico ao `MediaProcessorPort` — separado por semântica, não por interface)_
**Arquivo:** `src/application/use_cases/adjust_drift.rs` _(novo)_
- `AdjustDrift::execute(project: &mut Project, id: TrackId, scale: f64) -> Result<()>`
- Localiza faixa em `project.tracks` pelo `id`
- Valida: `scale > 0.0` e `scale < 10.0` (protege contra valores absurdos)
- Atualiza `track.set_drift_scale(scale)`
- Retorna `Err` se `TrackId` não encontrado
**Arquivo:** `src/application/use_cases/adjust_existing_track_drift.rs` _(novo)_
- `AdjustExistingTrackDrift::execute(project: &mut Project, id: TrackId, scale: f64) -> Result<()>`
- Localiza faixa em `project.existing_tracks` pelo `id`
- Valida `scale > 0.0`
- Atualiza `track.drift_scale = scale`
- Retorna `Err` se não encontrado
**Arquivo:** `src/application/use_cases/mod.rs` _(modificar)_
- Expor `adjust_drift` e `adjust_existing_track_drift`.
**Arquivo:** `src/application/use_cases/add_audio_track.rs` _(modificar)_
- `AddAudioTrack::execute(...)` recebe um novo parâmetro `drift_scale: f64` (default 1.0 no callsite da UI)
- Passa o valor para `AudioTrack::new()`
**Arquivo:** `src/application/use_cases/add_subtitle.rs` _(modificar)_
- Idem para `SubtitleTrack`.
**Testes obrigatórios:**
- `AdjustDrift` com `TrackId` inexistente retorna `Err`
- `AdjustDrift` com `scale = 0.0` retorna `Err`
- `AdjustExistingTrackDrift` atualiza corretamente `MediaTrackInfo.drift_scale`
---
### 9.4 — Adapters: novo módulo `mkvmerge`
**Arquivo:** `src/adapters/mkvmerge/mod.rs` _(novo)_
```
src/adapters/mkvmerge/
├── mod.rs
├── command_builder.rs
└── mkvmerge_gateway.rs
```
**Arquivo:** `src/adapters/mkvmerge/command_builder.rs` _(novo)_
`MkvmergeCommandBuilder::build(project: &Project) -> Vec<String>`
Lógica detalhada:
1. **Output** (vem primeiro em mkvmerge):
```
-o <output.path>
```
2. **Tracks existentes com `--sync`** (aplica a cada faixa não-vídeo):
```
--sync <stream_index>:<offset_ms>,<NUM>/<DEN>
```
- `stream_index` do `MediaTrackInfo` é o TID do mkvmerge para o arquivo fonte
- `(NUM, DEN)` = `scale_to_rational(drift_scale)` — ver abaixo
- Faixas com `drift_scale = 1.0` e `offset_ms = 0`: sem `--sync` (omitidas)
3. **Metadados e disposição de faixas existentes:**
```
--language <stream_index>:<lang>
```
- Somente se `language.is_some()`
4. **Arquivo fonte:**
```
<source.path>
```
5. **Faixas externas adicionadas** (cada arquivo externo é um input separado):
- TID do input externo i = `source_track_count + i` (onde `source_track_count = project.existing_tracks.len()`)
- Para cada faixa externa:
```
--sync <tid>:<offset_ms>,<NUM>/<DEN> (se offset≠0 ou scale≠1.0)
--language <tid>:<lang>
--track-name <tid>:<title>
--default-track <tid>:yes|no
<path_do_arquivo_externo>
```
6. **Utilitário interno:**
```rust
fn scale_to_rational(scale: f64) -> (u64, u64) {
let denom = 1_000_000u64;
let numer = (scale * denom as f64).round() as u64;
let g = gcd(numer, denom);
(numer / g, denom / g)
}
```
Precisão de 0.0001% — suficiente para todos os casos práticos.
**Arquivo:** `src/adapters/mkvmerge/mkvmerge_gateway.rs` _(novo)_
- `struct MkvmergeGateway;`
- Implementa `ContainerMuxPort`
- Executa `mkvmerge` com os args via `std::process::Command`
- Captura stderr para `Result<()>` (mesmo padrão do `FfmpegGateway`)
**Arquivo:** `src/adapters/mod.rs` _(modificar)_
- Adicionar `pub mod mkvmerge;`
**Testes obrigatórios (`command_builder.rs`):**
- Presença de `-o output.mkv` no início dos args
- `--sync 1:500,1/1` para faixa com `offset_ms=500`, `scale=1.0`
- `--sync 1:0,99983/100000` para faixa com `offset_ms=0`, `scale=0.99983`
- Faixa `scale=1.0` e `offset=0` não produz `--sync`
- `scale_to_rational(1.0)` === `(1, 1)`
- `scale_to_rational(0.99983)` === `(99983, 100000)`
- `scale_to_rational(24.0/23.976)` resulta em fração reduzida válida
- Output path é **primeiro** argumento (antes dos inputs)
---
### 9.5 — Infrastructure: `run_mkvmerge_async`
**Arquivo:** `src/infrastructure/process/mod.rs` _(modificar)_
Adicionar função:
```rust
pub async fn run_mkvmerge_async(
args: Vec<String>,
progress_tx: Sender<String>,
cancel_rx: tokio::sync::oneshot::Receiver<()>,
) -> Result<()>
```
- Estrutura idêntica a `run_ffmpeg_async`
- Substitui `Command::new("ffmpeg")` por `Command::new("mkvmerge")`
- `mkvmerge` escreve progresso em **stdout** (não em stderr) — ajustar captura para `stdout: Stdio::piped()` e `stderr: Stdio::piped()`; encaminhar ambos ao `progress_tx`
Adicionar função:
```rust
pub fn mkvmerge_available() -> bool {
check_binary_available("mkvmerge").is_ok()
}
```
- Não bloqueia — apenas verifica disponibilidade para uso da UI
---
### 9.6 — UI: campo de drift e dispatch de execução
**Arquivo:** `src/ui/components/sync_offset_field.rs` _(modificar)_
Adicionar campo opcional de drift:
```
Offset: [___0.0s___]
Drift: [__100.00_%] ← novo; só visível quando mkvmerge disponível
```
- Label: **"Velocidade original (%)"** (nunca expõe `scale` ou `mkvmerge` ao usuário)
- Valor padrão: `"100.00"` (= scale 1.0)
- Parsing: `scale = user_input_percent / 100.0`
- Validação: valor entre `1.0` e `999.99` (bloqueado fora dessa faixa)
- Quando `scale ≠ 1.0`: exibe ícone/texto discreto `"⚠ requer mkvmerge"` em laranja
- Quando `mkvmerge` ausente: campo desabilitado com tooltip `"Instale MKVToolNix para usar correção de drift"`
**Arquivo:** `src/ui/components/existing_track_list.rs` _(modificar)_
- Expor campo de drift para faixas existentes de áudio e legenda
- Usar o mesmo `SyncOffsetField` com o parâmetro de drift habilitado
- Ao confirmar: chamar `AdjustExistingTrackDrift::execute()`
**Arquivo:** `src/ui/app.rs` _(modificar)_
1. Adicionar campo `mkvmerge_available: bool` ao struct `App`
2. Em `App::new()`:
```rust
mkvmerge_available: crate::infrastructure::process::mkvmerge_available(),
```
3. Passar `mkvmerge_available` para os componentes que precisam do campo de drift
4. Em `App::generate_output()` (método que inicia a geração):
```rust
if project.needs_mkvmerge() {
let args = MkvmergeCommandBuilder::build(&project);
// spawn run_mkvmerge_async
} else {
let args = FfmpegCommandBuilder::build(&project);
// spawn run_ffmpeg_async (comportamento atual)
}
```
5. Idem em `App::start_batch_item()` para o modo lote
**Arquivo:** `src/ui/components/execution_panel.rs` _(modificar — mínimo)_
- Nenhuma mudança estrutural necessária; o painel recebe mensagens de log do canal existente — funciona para ambos os motores.
---
### 9.7 — Compatibilidade e regressão
| Cenário | Motor | Alteração necessária |
| ------------------------------------ | -------- | ------------------------------------------ |
| Projeto sem drift (scale=1.0) | FFmpeg | Nenhuma — caminho atual inalterado |
| Projeto com drift em faixa existente | mkvmerge | Nova lógica de dispatch |
| Projeto com drift em faixa externa | mkvmerge | Nova lógica de dispatch |
| Exportação de faixa individual | FFmpeg | Nenhuma — `build_export` inalterado |
| Modo lote sem drift | FFmpeg | Dispatch verifica cada `BatchItem.project` |
| Modo lote com drift | mkvmerge | Dispatch verifica cada `BatchItem.project` |
**Invariante preservada:** `FfmpegCommandBuilder::build()` e `FfmpegCommandBuilder::build_export()` **nunca são modificados** nesta fase. Todos os testes existentes continuam passando sem alteração.
---
### 9.8 — Tarefas (em ordem)
**Domain:**
- [ ] Criar `src/domain/value_objects/sync_transform.rs` com `SyncTransform`
- [ ] Atualizar `src/domain/value_objects/mod.rs` para expor `SyncTransform`
- [ ] Adicionar `drift_scale: f64` em `AudioTrack` e `SubtitleTrack` (preservando construtores atuais com `drift_scale = 1.0` como default no callsite)
- [ ] Adicionar `drift_scale: f64` em `MediaTrackInfo`
- [ ] Adicionar `drift_scale()` e `set_drift_scale()` em `Track`
- [ ] Adicionar `Project::needs_mkvmerge()` com testes unitários
**Application:**
- [ ] Criar `adjust_drift.rs` com testes
- [ ] Criar `adjust_existing_track_drift.rs` com testes
- [ ] Atualizar `add_audio_track.rs` para aceitar `drift_scale`
- [ ] Atualizar `add_subtitle.rs` para aceitar `drift_scale`
- [ ] Adicionar `ContainerMuxPort` em `ports/mod.rs`
- [ ] Atualizar `mod.rs` dos use cases
**Adapters:**
- [ ] Criar `src/adapters/mkvmerge/command_builder.rs` com `MkvmergeCommandBuilder::build()` e `scale_to_rational()`
- [ ] Criar testes unitários para `MkvmergeCommandBuilder` (ver seção 9.4)
- [ ] Criar `src/adapters/mkvmerge/mkvmerge_gateway.rs` implementando `ContainerMuxPort`
- [ ] Criar `src/adapters/mkvmerge/mod.rs`
- [ ] Atualizar `src/adapters/mod.rs`
**Infrastructure:**
- [ ] Adicionar `run_mkvmerge_async` em `src/infrastructure/process/mod.rs`
- [ ] Adicionar `mkvmerge_available()` em `src/infrastructure/process/mod.rs`
**UI:**
- [ ] Atualizar `SyncOffsetField` com campo de drift (condicionado a `mkvmerge_available`)
- [ ] Atualizar `ExistingTrackList` para expor drift e chamar `AdjustExistingTrackDrift`
- [ ] Atualizar `AddAudioTrackForm` e `AddSubtitleForm` para passar `drift_scale` ao use case
- [ ] Adicionar `mkvmerge_available: bool` em `App`
- [ ] Atualizar `App::generate_output()` com dispatch FFmpeg/mkvmerge
- [ ] Atualizar `App::start_batch_item()` com o mesmo dispatch
**Validação final:**
- [ ] `cargo test` — todos os testes existentes passam sem modificação
- [ ] Verificar manualmente com arquivo de teste que apresenta drift progressivo
---
## 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)