Files
simple-multimidia-track-aud…/DEVELOPMENT_PLAN.md
T
Felipe 1ef47b3a07 feat: add AdjustExistingTrackDrift use case to adjust drift scale of existing tracks
- Implemented AdjustExistingTrackDrift struct with execute method to modify drift scale of existing tracks in a project.
- Added tests for adjusting drift scale, handling non-existent track IDs, and invalid scale values.
- Updated MediaTrackInfo and AudioTrack entities to include drift_scale field.
- Enhanced Project entity with needs_mkvmerge method to determine if mkvmerge is required based on drift scale.
- Integrated drift scale adjustments into existing track list UI, allowing users to modify drift values.
- Updated various use cases and UI components to support drift scale functionality, including add audio/subtitle forms and batch processing.
- Implemented mkvmerge availability check and integrated it into the application workflow for conditional processing.
2026-03-01 12:01:39 -03:00

32 KiB
Raw Blame History

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

  • Atualizar Cargo.toml com as dependências (rfd = "0.14" adicionado para diálogos nativos)
  • Criar a estrutura de pastas conforme especificado
  • Declarar os módulos em src/main.rs
  • 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

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

  • SyncOffset::from_seconds_str("1.2")SyncOffset(1200)
  • SyncOffset::from_seconds_str("-0.5")SyncOffset(-500)
  • Project não aceita output.path igual a source.path
  • 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/)

// 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

  • Usar mocks dos ports (sem chamar nenhum processo externo)
  • GenerateOutput sempre produz comando com -c copy (verificado via mock)
  • AdjustSync com TrackId inexistente retorna erro
  • 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
  • Implementar FfmpegCommandBuilder::build(project: &Project) -> Vec<String>
  • Implementar FfmpegCommandBuilder::build_export(source, track, output) -> Vec<String> (exportação de faixas individuais)
  • 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>.

  • Implementar parsing de JSON via serde_json
  • Mapear codec_type para TrackKind
  • Mapear tags.language para Option<TrackLanguage>

FfmpegGateway

Implementa MediaProcessorPort. Executa o processo real do FFmpeg e captura stderr.

  • Capturar stderr para exibição de erros (RF-07)
  • Retornar erro com mensagem legível em caso de código de saída não-zero

Filesystem (src/adapters/filesystem/)

  • 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/

  • Implementar execução via tokio::process::Command (assíncrono, RNF-05)
  • Capturar stderr em stream via run_ffmpeg_async com std::sync::mpsc::Sender<String> (progresso em tempo real — RF-07)
  • Cancelamento de execução via tokio::sync::oneshot + child.kill().await

Validação na inicialização

  • Verificar se ffmpeg está disponível no PATH (DT-06)
  • Verificar se ffprobe está disponível no PATH
  • 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)

  • Implementar eframe::App para App
  • App contém project: Option<Project> como única fonte de verdade do estado
  • Despachar eventos de UI para os use cases correspondentes
  • Integrar execução assíncrona (tokio + std::thread) com o loop de UI do eframe
  • Botão "Cancelar" via cancel_tx: Option<oneshot::Sender<()>>
  • 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

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 → Runningrun_ffmpeg_async → aguarda BackgroundMsg::Done | Errorstate → Success | Error → próximo item
  5. "Cancelar" interrompe o item atual via cancel_tx; os demais permanecem no carrinho com estado Idle

Tarefas

  • Criar enum ActiveTab e barra de abas no update() de App
  • Criar BatchItem e batch_items: Vec<BatchItem> em App
  • Criar componente BatchPanel com carrinho e formulário inline (Opção A)
  • Implementar loop sequencial de execução em App::start_batch_item() + avanço automático em poll_background()
  • Exibir estado individual por item (Aguardando | Processando | Concluído | Erro)
  • 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.

#[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:
    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:
    /// 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:

    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:

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:

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():
    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):
    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:

  • É possível selecionar um arquivo de vídeo base
  • Ao selecionar o vídeo, as faixas existentes são listadas automaticamente (via ffprobe)
  • É possível adicionar uma ou mais faixas de áudio externas
  • É possível adicionar uma ou mais faixas de legenda
  • É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
  • É possível editar o offset de sincronização de uma faixa já existente no arquivo
  • É possível atribuir idioma a cada faixa
  • O arquivo MKV é gerado corretamente ao confirmar
  • Erros do FFmpeg são exibidos de forma legível
  • A interface não trava durante o processamento
  • Nenhum reencoding ocorre (verificável via ffprobe no arquivo de saída)
  • O flag -c copy está sempre presente no comando gerado (verificável via testes unitários)