feat: adiciona suporte para correção de drift em faixas de áudio e legenda utilizando mkvmerge
This commit is contained in:
@@ -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)
|
||||
|
||||
Alinhados com o PRD seção 12:
|
||||
|
||||
Reference in New Issue
Block a user