- 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.
32 KiB
Plano de Desenvolvimento — Simple Multimedia Track Audio Editor
Versão: 1.3
Data: 28/02/2026
Status: Fases 1–6 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.tomlcom 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, nenhumuse 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)Projectnão aceitaoutput.pathigual asource.pathTrackIdé 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)
GenerateOutputsempre produz comando com-c copy(verificado via mock)AdjustSynccomTrackIdinexistente retorna erroAddAudioTrackretorna erro sesourceeoutputconflitarem (coberto porProject::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 copysempre 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_typeparaTrackKind - Mapear
tags.languageparaOption<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 (craterfd; incluisave_audioesave_subtitlepor 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_asynccomstd::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
ffmpegestá disponível noPATH(DT-06) - Verificar se
ffprobeestá disponível noPATH - 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::AppparaApp Appcontémproject: 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 noCargo.toml) - Local: diretório de configuração do usuário (
~/.config/simple-mkv-editor/session.jsonno Linux;AppDatano 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.rscomsave_sessioneload_session - Resolver caminho do arquivo via
dirscrate (oustd::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
- Usuário adiciona itens ao carrinho (zero ou mais), configura cada um com diálogo nativo
- Itens podem ser removidos do carrinho enquanto nenhum processamento estiver em curso
- Ao clicar "Processar Tudo", o lote é bloqueado (sem mais adições/remoções)
- Para cada item em ordem:
state → Running→run_ffmpeg_async→ aguardaBackgroundMsg::Done | Error→state → Success | Error→ próximo item - "Cancelar" interrompe o item atual via
cancel_tx; os demais permanecem no carrinho com estadoIdle
Tarefas
- Criar
enum ActiveTabe barra de abas noupdate()deApp - Criar
BatchItemebatch_items: Vec<BatchItem>emApp - Criar componente
BatchPanelcom carrinho e formulário inline (Opção A) - Implementar loop sequencial de execução em
App::start_batch_item()+ avanço automático empoll_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.0em 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 reexportarSyncTransform.
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: f64com valor padrão1.0. - Atualizar
AudioTrack::new()para aceitar o parâmetrodrift_scale: f64.
Arquivo: src/domain/entities/subtitle_track.rs (modificar)
- Idem: adicionar
pub drift_scale: f64e atualizar construtor.
Arquivo: src/domain/entities/media_track_info.rs (modificar)
- Adicionar campo
pub drift_scale: f64com valor padrão1.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) -> f64que retorna o campo deAudioTrackouSubtitleTrack. - 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()retornafalsequando todas as faixas têmdrift_scale = 1.0needs_mkvmerge()retornatruequando qualquer faixa temdrift_scale ≠ 1.0SyncTransform::has_drift()comscale = 1.0retornafalseSyncTransform::has_drift()comscale = 0.99983retornatrueSyncTransform::default()é identidade
9.3 — Application: novos use cases e port
Arquivo: src/application/ports/mod.rs (modificar)
- Adicionar novo port:
(estruturalmente idêntico ao
/// Port para muxing com suporte a escala temporal (implementado pelo MkvmergeGateway). pub trait ContainerMuxPort { fn execute(&self, args: Vec<String>) -> Result<()>; }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.trackspeloid - Valida:
scale > 0.0escale < 10.0(protege contra valores absurdos) - Atualiza
track.set_drift_scale(scale) - Retorna
ErrseTrackIdnão encontrado
- Localiza faixa em
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_trackspeloid - Valida
scale > 0.0 - Atualiza
track.drift_scale = scale - Retorna
Errse não encontrado
- Localiza faixa em
Arquivo: src/application/use_cases/mod.rs (modificar)
- Expor
adjust_drifteadjust_existing_track_drift.
Arquivo: src/application/use_cases/add_audio_track.rs (modificar)
AddAudioTrack::execute(...)recebe um novo parâmetrodrift_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:
AdjustDriftcomTrackIdinexistente retornaErrAdjustDriftcomscale = 0.0retornaErrAdjustExistingTrackDriftatualiza corretamenteMediaTrackInfo.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:
-
Output (vem primeiro em mkvmerge):
-o <output.path> -
Tracks existentes com
--sync(aplica a cada faixa não-vídeo):--sync <stream_index>:<offset_ms>,<NUM>/<DEN>stream_indexdoMediaTrackInfoé o TID do mkvmerge para o arquivo fonte(NUM, DEN)=scale_to_rational(drift_scale)— ver abaixo- Faixas com
drift_scale = 1.0eoffset_ms = 0: sem--sync(omitidas)
-
Metadados e disposição de faixas existentes:
--language <stream_index>:<lang>- Somente se
language.is_some()
- Somente se
-
Arquivo fonte:
<source.path> -
Faixas externas adicionadas (cada arquivo externo é um input separado):
- TID do input externo i =
source_track_count + i(ondesource_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>
- TID do input externo i =
-
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
mkvmergecom os args viastd::process::Command - Captura stderr para
Result<()>(mesmo padrão doFfmpegGateway)
Arquivo: src/adapters/mod.rs (modificar)
- Adicionar
pub mod mkvmerge;
Testes obrigatórios (command_builder.rs):
- Presença de
-o output.mkvno início dos args --sync 1:500,1/1para faixa comoffset_ms=500,scale=1.0--sync 1:0,99983/100000para faixa comoffset_ms=0,scale=0.99983- Faixa
scale=1.0eoffset=0nã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")porCommand::new("mkvmerge") mkvmergeescreve progresso em stdout (não em stderr) — ajustar captura parastdout: Stdio::piped()estderr: Stdio::piped(); encaminhar ambos aoprogress_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
scaleoumkvmergeao usuário) - Valor padrão:
"100.00"(= scale 1.0) - Parsing:
scale = user_input_percent / 100.0 - Validação: valor entre
1.0e999.99(bloqueado fora dessa faixa) - Quando
scale ≠ 1.0: exibe ícone/texto discreto"⚠ requer mkvmerge"em laranja - Quando
mkvmergeausente: 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
SyncOffsetFieldcom o parâmetro de drift habilitado - Ao confirmar: chamar
AdjustExistingTrackDrift::execute()
Arquivo: src/ui/app.rs (modificar)
- Adicionar campo
mkvmerge_available: boolao structApp - Em
App::new():mkvmerge_available: crate::infrastructure::process::mkvmerge_available(), - Passar
mkvmerge_availablepara os componentes que precisam do campo de drift - 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) } - 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.rscomSyncTransform - Atualizar
src/domain/value_objects/mod.rspara exporSyncTransform - Adicionar
drift_scale: f64emAudioTrackeSubtitleTrack(preservando construtores atuais comdrift_scale = 1.0como default no callsite) - Adicionar
drift_scale: f64emMediaTrackInfo - Adicionar
drift_scale()eset_drift_scale()emTrack - Adicionar
Project::needs_mkvmerge()com testes unitários
Application:
- Criar
adjust_drift.rscom testes - Criar
adjust_existing_track_drift.rscom testes - Atualizar
add_audio_track.rspara aceitardrift_scale - Atualizar
add_subtitle.rspara aceitardrift_scale - Adicionar
ContainerMuxPortemports/mod.rs - Atualizar
mod.rsdos use cases
Adapters:
- Criar
src/adapters/mkvmerge/command_builder.rscomMkvmergeCommandBuilder::build()escale_to_rational() - Criar testes unitários para
MkvmergeCommandBuilder(ver seção 9.4) - Criar
src/adapters/mkvmerge/mkvmerge_gateway.rsimplementandoContainerMuxPort - Criar
src/adapters/mkvmerge/mod.rs - Atualizar
src/adapters/mod.rs
Infrastructure:
- Adicionar
run_mkvmerge_asyncemsrc/infrastructure/process/mod.rs - Adicionar
mkvmerge_available()emsrc/infrastructure/process/mod.rs
UI:
- Atualizar
SyncOffsetFieldcom campo de drift (condicionado amkvmerge_available) - Atualizar
ExistingTrackListpara expor drift e chamarAdjustExistingTrackDrift - Atualizar
AddAudioTrackFormeAddSubtitleFormpara passardrift_scaleao use case - Adicionar
mkvmerge_available: boolemApp - 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
ffprobeno arquivo de saída) - O flag
-c copyestá sempre presente no comando gerado (verificável via testes unitários)