From 285f22e2d1684de87103bdf20f0597a9f966c1a2 Mon Sep 17 00:00:00 2001 From: Felipe Canin Novaes Date: Sun, 1 Mar 2026 10:57:57 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20adiciona=20suporte=20para=20corre=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20drift=20em=20faixas=20de=20=C3=A1udio=20e=20lege?= =?UTF-8?q?nda=20utilizando=20mkvmerge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT_PLAN.md | 380 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index 7fdce39..911244a 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -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) -> 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` + +Lógica detalhada: + +1. **Output** (vem primeiro em mkvmerge): + + ``` + -o + ``` + +2. **Tracks existentes com `--sync`** (aplica a cada faixa não-vídeo): + + ``` + --sync :,/ + ``` + + - `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 : + ``` + + - Somente se `language.is_some()` + +4. **Arquivo fonte:** + + ``` + + ``` + +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 :,/ (se offset≠0 ou scale≠1.0) + --language : + --track-name : + --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: