feat: adiciona suporte para correção de drift em faixas de áudio e legenda utilizando mkvmerge

This commit is contained in:
2026-03-01 10:57:57 -03:00
parent 61b71f03a7
commit 285f22e2d1
+380
View File
@@ -334,6 +334,386 @@ struct BatchItem {
--- ---
## 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) ## Critérios de Conclusão (v1.0)
Alinhados com o PRD seção 12: Alinhados com o PRD seção 12: