Compare commits

...
6 Commits
Author SHA1 Message Date
Felipe e4e836e86b feat: adiciona funcionalidade para reordenar faixas externas com botões ↑↓ na interface 2026-03-02 00:04:47 -03:00
Felipe d86135ad6b feat: implementa painel de debug para exibir o comando gerado pelo FFmpeg/mkvmerge na ExecutionPanel, com opção de cópia 2026-03-01 22:16:28 -03:00
Felipe e347202e95 Atualiza PRD para versão 1.4, adiciona novos requisitos funcionais (RF-12 a RF-15) e remove o arquivo PROGRESS.md. Implementa painel de debug para exibir o comando gerado pelo FFmpeg/mkvmerge na ExecutionPanel, permitindo cópia e inspeção do comando. Melhora a interface e a persistência de estado do projeto. 2026-03-01 22:10:13 -03:00
Felipe 8b031f07ec feat: adiciona suporte para excluir faixas de áudio e legenda do arquivo de saída 2026-03-01 15:47:01 -03:00
Felipe e70612f5ea feat: adiciona suporte para sugestão de velocidade com base nas durações de vídeo e faixas de áudio/legenda 2026-03-01 14:54:49 -03:00
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
37 changed files with 2135 additions and 1122 deletions
-732
View File
@@ -1,732 +0,0 @@
# 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
- [x] Atualizar `Cargo.toml` com as dependências (`rfd = "0.14"` adicionado para diálogos nativos)
- [x] Criar a estrutura de pastas conforme especificado
- [x] Declarar os módulos em `src/main.rs`
- [x] 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`
```rust
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
- [x] `SyncOffset::from_seconds_str("1.2")``SyncOffset(1200)`
- [x] `SyncOffset::from_seconds_str("-0.5")``SyncOffset(-500)`
- [x] `Project` não aceita `output.path` igual a `source.path`
- [x] `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/`)
```rust
// 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
- [x] Usar mocks dos ports (sem chamar nenhum processo externo)
- [x] `GenerateOutput` sempre produz comando com `-c copy` (verificado via mock)
- [x] `AdjustSync` com `TrackId` inexistente retorna erro
- [x] `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
```
- [x] Implementar `FfmpegCommandBuilder::build(project: &Project) -> Vec<String>`
- [x] Implementar `FfmpegCommandBuilder::build_export(source, track, output) -> Vec<String>` (exportação de faixas individuais)
- [x] 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>`.
- [x] Implementar parsing de JSON via `serde_json`
- [x] Mapear `codec_type` para `TrackKind`
- [x] Mapear `tags.language` para `Option<TrackLanguage>`
#### `FfmpegGateway`
Implementa `MediaProcessorPort`. Executa o processo real do FFmpeg e captura stderr.
- [x] Capturar stderr para exibição de erros (RF-07)
- [x] Retornar erro com mensagem legível em caso de código de saída não-zero
### Filesystem (`src/adapters/filesystem/`)
- [x] `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/`
- [x] Implementar execução via `tokio::process::Command` (assíncrono, RNF-05)
- [x] Capturar stderr em stream via `run_ffmpeg_async` com `std::sync::mpsc::Sender<String>` (progresso em tempo real — RF-07)
- [x] Cancelamento de execução via `tokio::sync::oneshot` + `child.kill().await`
### Validação na inicialização
- [x] Verificar se `ffmpeg` está disponível no `PATH` (DT-06)
- [x] Verificar se `ffprobe` está disponível no `PATH`
- [x] 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`)
- [x] Implementar `eframe::App` para `App`
- [x] `App` contém `project: Option<Project>` como única fonte de verdade do estado
- [x] Despachar eventos de UI para os use cases correspondentes
- [x] Integrar execução assíncrona (tokio + `std::thread`) com o loop de UI do eframe
- [x] Botão "Cancelar" via `cancel_tx: Option<oneshot::Sender<()>>`
- [x] `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`
```rust
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 → Running``run_ffmpeg_async` → aguarda `BackgroundMsg::Done | Error``state → Success | Error` → próximo item
5. "Cancelar" interrompe o item atual via `cancel_tx`; os demais permanecem no carrinho com estado `Idle`
### Tarefas
- [x] Criar `enum ActiveTab` e barra de abas no `update()` de `App`
- [x] Criar `BatchItem` e `batch_items: Vec<BatchItem>` em `App`
- [x] Criar componente `BatchPanel` com carrinho e formulário inline (Opção A)
- [x] Implementar loop sequencial de execução em `App::start_batch_item()` + avanço automático em `poll_background()`
- [x] Exibir estado individual por item (`Aguardando | Processando | Concluído | Erro`)
- [x] 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.
```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:
- [x] É possível selecionar um arquivo de vídeo base
- [x] Ao selecionar o vídeo, as faixas existentes são listadas automaticamente (via `ffprobe`)
- [x] É possível adicionar uma ou mais faixas de áudio externas
- [x] É possível adicionar uma ou mais faixas de legenda
- [x] É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
- [x] É possível editar o offset de sincronização de uma faixa já existente no arquivo
- [x] É possível atribuir idioma a cada faixa
- [x] O arquivo MKV é gerado corretamente ao confirmar
- [x] Erros do FFmpeg são exibidos de forma legível
- [x] A interface não trava durante o processamento
- [x] Nenhum reencoding ocorre (verificável via `ffprobe` no arquivo de saída)
- [x] O flag `-c copy` está sempre presente no comando gerado (verificável via testes unitários)
+133 -53
View File
@@ -1,9 +1,9 @@
# PRD — Simple Multimedia Track Audio Editor
**Versão:** 1.2
**Data:** 28/02/2026
**Status:** Em desenvolvimento
**Revisão:** v1.2 — Adicionado RF-09 (Modo Lote); batch promovido de backlog para requisito planejado
**Versão:** 1.5
**Data:** 02/03/2026
**Status:** v1.0 funcional — Fases 19 concluídas; 72 testes passando; aplicação executável
**Revisão:** v1.5 — Adicionado RF-16 (Reordenar faixas externas)
---
@@ -42,9 +42,8 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
- Reencoding / transcodificação de vídeo ou áudio
- Preview de vídeo embutido
- Detecção automática de sincronização
- Remoção de faixas existentes do arquivo original
- Seleção de faixa padrão no container
- Edição de corte ou splice de vídeo
- Salvamento automático em disco (sem intervenção do usuário)
---
@@ -127,8 +126,55 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
- A seleção de arquivos usa **diálogos nativos** (igual ao modo Projeto Único) — nenhum path é digitado manualmente
- O processamento é **sequencial** — um projeto por vez, sem paralelismo, para evitar sobrecarga de I/O
- Durante o processamento, o carrinho é bloqueado: não é possível adicionar nem remover itens
- Cada item exibe seu estado individual: `Aguardando | Processando | Concluído | Erro`
- Cada item exibe seu estado individual: `Aguardando | Processando | Concluído | ⊸ Cancelado | ✗ Erro`
- É possível cancelar o item em execução; os demais permanecem no carrinho com estado `Aguardando`
- O dispatch FFmpeg/mkvmerge por item é automático: se qualquer faixa do item tiver `drift_scale ≠ 1.0`, o motor mkvmerge é usado
### RF-12 — Correção de drift progressivo de sincronização
- O usuário pode especificar um fator de velocidade original da faixa em porcentagem (ex: `99.983%`)
- Internamente representado como `drift_scale: f64` em cada faixa (`AudioTrack`, `SubtitleTrack`, `MediaTrackInfo`)
- Implementado via `mkvmerge --sync TID:DELAY,NUM/DEN` — aplica escala de timestamps no nível do container, sem reencoding
- **Dispatch automático:** se qualquer faixa tiver `scale ≠ 1.0`, o projeto inteiro usa o pipeline `mkvmerge` em vez do FFmpeg
- Campo exibido na interface como **"Velocidade original (%)"** — jamais expõe termos técnicos como `scale`, `drift` ou `mkvmerge`
- O campo é desabilitado com tooltip explicativo quando `mkvmerge` não está disponível no `PATH`
- Funciona tanto no modo Projeto Único quanto no modo Lote
- Preserva a invariante RNF-01: nenhum byte de mídia é reprocessado
### RF-13 — Exportar faixa existente
- A partir da lista de faixas detectadas pelo `ffprobe`, o usuário pode exportar qualquer faixa de áudio ou legenda para um arquivo separado
- O formato do arquivo de saída é inferido a partir do codec da faixa (ex: AAC → `.aac`, SRT → `.srt`)
- Implementado via `FfmpegCommandBuilder::build_export()` com `-c:a copy` para áudio; legendas omitem `-c` (conversão de container de texto, sem processamento de mídia)
- O diálogo de salvamento usa filtro por extensão correspondente ao codec detectado
### RF-14 — Persistência de sessão
- O usuário pode salvar o estado completo do projeto em disco com o botão **"💾 Salvar sessão"**
- O estado é restaurado com o botão **"📂 Carregar sessão"**
- Formato: JSON via `serde_json`; local: `~/.config/simple-mkv-editor/session.json` (Linux) / `AppData` (Windows)
- Feedback visual no header: indicador verde (sucesso) ou vermelho (erro) com botão ✕ para dispensar
- Erros de desserialização (arquivo corrompido ou versão incompatível) são tratados com mensagem amigável
- Salvamento automático não é realizado — apenas sob demanda do usuário
### RF-15 — Excluir faixa existente do arquivo de saída
- O usuário pode marcar qualquer faixa de áudio ou legenda **já presente no arquivo de entrada** para ser omitida do arquivo de saída
- A operação é reversível: a faixa pode ser restaurada antes da geração do MKV
- Faixas marcadas como excluídas são exibidas com texto tachado e cor esmaecida na lista; seus campos de offset e drift ficam desabilitados
- Faixas de vídeo e dados **não** podem ser excluídas — somente áudio e legenda
- Implementado via campo `excluded: bool` em `MediaTrackInfo`; método `toggle_existing_track_excluded()` em `Project`
- Ambos os pipelines (FFmpeg e mkvmerge) respeitam o flag: faixas excluídas são filtradas antes da geração dos argumentos de `-map` / `--audio-tracks` / `--subtitle-tracks`
- O estado `excluded` é persistido junto com a sessão (RF-14)
### RF-16 — Reordenar faixas externas
- O usuário pode alterar a ordem das faixas externas adicionadas (áudio e legenda) usando os botões **↑** e **↓** na lista de faixas
- A ordem determina o índice de stream no container MKV final — relevante para players que selecionam faixas por posição (ex: VLC, Jellyfin)
- O botão ↑ fica desabilitado na primeira faixa da lista; o botão ↓, na última
- Implementado via método `move_track(id, delta: i8)` em `Project`, usando `Vec::swap` — ambos os pipelines (FFmpeg e mkvmerge) iteram `project.tracks` em ordem e não precisam de alteração
- Somente faixas **externas** (`project.tracks`) são reordenáveis; faixas existentes (`project.existing_tracks`, lidas via ffprobe) permanecem na ordem detectada
- A nova ordem é persistida automaticamente junto com a sessão (RF-14)
---
@@ -159,9 +205,11 @@ A operação de mux deve ser concluída em tempo proporcional ao tamanho do arqu
O arquivo de saída deve ser compatível com players modernos que suportam MKV (ex: VLC, mpv, Jellyfin).
### RNF-04 — Dependência externa
### RNF-04 — Dependências externas
FFmpeg deve estar instalado no sistema. A aplicação não o embute por padrão, porém pode ser distribuída junto.
FFmpeg e ffprobe devem estar instalados no sistema. A aplicação verifica a presença de ambos na inicialização e exibe erro global se ausentes.
mkvmerge (MKVToolNix) é uma dependência opcional. Quando ausente, a funcionalidade de correção de drift (RF-12) fica desabilitada visualmente; o restante do produto continua funcional. A verificação é **soft** — não bloqueia a inicialização.
### RNF-05 — Interface responsiva
@@ -183,15 +231,17 @@ Arquivo de saída (.mkv)
### Stack
| Camada | Tecnologia | Motivo |
| ------------ | ------------------------------ | ------------------------------------ |
| Interface | `egui` / `eframe` | Nativo, simples, multiplataforma |
| Backend | Rust (`std::process::Command`) | Estável, sem dependências externas |
| Erros | `anyhow` | Propagação de erros simplificada |
| Configuração | `serde` | Serialização de perfis e histórico |
| Async | `tokio` (opcional) | Execução não bloqueante da interface |
| Mídia | FFmpeg (externo) | Maduro, estável, amplamente testado |
| Container | MKV | Melhor suporte a múltiplas faixas |
| Camada | Tecnologia | Motivo |
| ------------- | ------------------------------- | ------------------------------------------- |
| Interface | `egui` / `eframe` | Nativo, simples, multiplataforma |
| Backend | Rust (`std::process::Command`) | Estável, sem dependências externas |
| Erros | `anyhow` | Propagação de erros simplificada |
| Configuração | `serde` + `serde_json` | Serialização de sessão em JSON |
| Async | `tokio` | Execução não bloqueante da interface |
| Diálogos | `rfd` | Diálogos nativos de arquivo multiplataforma |
| Mídia (mux) | FFmpeg (externo) | Mux sem reencoding; caminho padrão |
| Mídia (drift) | mkvmerge / MKVToolNix (externo) | Correção de drift sem reencoding (opcional) |
| Container | MKV | Melhor suporte a múltiplas faixas |
### Dependências Cargo
@@ -199,7 +249,9 @@ Arquivo de saída (.mkv)
eframe = "0.27"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["process"] } # opcional
serde_json = "1"
tokio = { version = "1", features = ["process", "rt-multi-thread", "macros", "io-util", "sync"] }
rfd = "0.14"
```
---
@@ -236,31 +288,35 @@ Contém as entidades e objetos de valor do negócio. Não depende de nada extern
| Tipo | Exemplos |
| ------------- | ------------------------------------------------------------------------------------ |
| Entities | `Project`, `VideoFile`, `AudioTrack`, `SubtitleTrack`, `MkvOutput`, `MediaTrackInfo` |
| Value Objects | `TrackId`, `SyncOffset`, `TrackLanguage`, `FilePath` |
| Value Objects | `TrackId`, `SyncOffset`, `SyncTransform`, `TrackLanguage`, `FilePath` |
> **`Project`** é a entidade central do domínio. Representa a sessão de edição completa do usuário e deve ser a única fonte de verdade do estado em memória.
>
> ```
> Project
> ├── source: VideoFile
> ├── tracks: Vec<Track> // faixas externas adicionadas
> ├── tracks: Vec<Track> // faixas externas adicionadas
> ├── existing_tracks: Vec<MediaTrackInfo> // faixas lidas do arquivo via ffprobe
> └── output: MkvOutput
> ```
>
> **`TrackId`** abstrai os índices de stream do FFmpeg. Internamente é um identificador opaco (`u32` ou `String`). Nenhuma lógica de negócio deve depender de índices FFmpeg diretamente — isso é responsabilidade do adapter.
> Método relevante: `needs_mkvmerge() -> bool` — retorna `true` se qualquer faixa tiver `drift_scale ≠ 1.0`, sinalizando que o projeto deve usar o pipeline `mkvmerge` em vez do FFmpeg.
>
> **`TrackId`** abstrai os índices de stream do FFmpeg. Internamente é um identificador opaco (`u32`). Nenhuma lógica de negócio deve depender de índices FFmpeg diretamente — isso é responsabilidade do adapter.
>
> **`SyncOffset`** armazena o offset de sincronização em **milissegundos como inteiro** (`i64`). Nunca como `f64`. O uso de ponto flutuante acumula erro de precisão; o adapter é responsável por converter para o formato exigido pelo FFmpeg (`-itsoffset`).
>
> **`SyncTransform`** é o value object que combina deslocamento e escala temporal: `{ offset_ms: i64, scale: f64 }`. Usado internamente para representar a transformação completa de uma faixa no pipeline mkvmerge (RF-12). `scale = 1.0` representa identidade; `scale ≠ 1.0` ativa o correto motor de dispatch.
#### Application (casos de uso)
Contém as regras de negócio da aplicação. Orquestra as entidades do domínio.
Define traits (ports) que as camadas externas devem implementar.
| Tipo | Exemplos |
| --------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Use Cases | `LoadMediaInfo`, `AddAudioTrack`, `AddSubtitle`, `AdjustSync`, `EditExistingTrackSync`, `SetTrackLanguage`, `GenerateOutput` |
| Ports | `MediaProcessorPort`, `MediaInfoPort`, `FileSystemPort` |
| Tipo | Exemplos |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Use Cases | `LoadMediaInfo`, `AddAudioTrack`, `AddSubtitle`, `AdjustSync`, `AdjustDrift`, `AdjustExistingTrackDrift`, `EditExistingTrackSync`, `ExportTrack`, `RemoveTrack`, `SetTrackLanguage`, `GenerateOutput` |
| Ports | `MediaProcessorPort`, `MediaInfoPort`, `FileSystemPort`, `ContainerMuxPort` |
> **`MediaInfoPort`** é o port responsável por inspecionar arquivos de mídia existentes. Deve ser definido na camada Application e implementado na camada Adapters via `FfprobeGateway`.
>
@@ -278,15 +334,20 @@ Define traits (ports) que as camadas externas devem implementar.
Implementam os ports definidos na camada de Application. Traduzem dados entre o domínio e o mundo externo.
| Tipo | Exemplos |
| ------------ | --------------------------------------------------------- |
| Gateway | `FfmpegCommandBuilder`, `FfmpegGateway`, `FfprobeGateway` |
| Presenter | `ErrorPresenter` (formata stderr do FFmpeg) |
| File Adapter | `FilePickerAdapter` |
| Tipo | Exemplos |
| ------------ | ------------------------------------------------------------------------- |
| Gateway | `FfmpegCommandBuilder`, `FfmpegGateway`, `FfprobeGateway` |
| Gateway | `MkvmergeCommandBuilder`, `MkvmergeGateway` (motor de drift — RF-12) |
| Presenter | `ErrorPresenter` (formata stderr do FFmpeg / stdout do mkvmerge) |
| File Adapter | `FilePickerAdapter` (inclui `save_audio(codec)` e `save_subtitle(codec)`) |
> **`FfprobeGateway`** implementa `MediaInfoPort`. Executa `ffprobe -v quiet -print_format json -show_streams` e mapeia a saída para `Vec<MediaTrackInfo>`. Não conhece o domínio além das structs que está populando.
>
> **`FfmpegCommandBuilder`** é responsável por converter `TrackId` para índices `-map 0:a:N` do FFmpeg e `SyncOffset` (ms inteiro) para o formato `-itsoffset 1.200` aceito pelo binário. **Toda conversão de tipos internos para argumentos FFmpeg fica aqui e somente aqui.**
>
> Além do `-c copy`, sempre emite `-max_interleave_delta 0` (evita descarte silencioso de pacotes em streams com grande delta de timestamps) e `-avoid_negative_ts make_zero` (normaliza timestamps negativos comuns em M4A/AAC de serviços de streaming).
>
> **`MkvmergeCommandBuilder`** converte `Project` em argumentos para o `mkvmerge`. Usa `--sync TID:DELAY,NUM/DEN` para aplicar deslocamento e escala de timestamps sem reencoding. A escala `f64` é convertida para fração racionl `(NUM, DEN)` com precisão de 6 casas decimais.
#### Infrastructure / UI
@@ -306,18 +367,23 @@ Camada mais externa. Contém o framework de UI e a execução real de processos.
src/
├── domain/
│ ├── entities/ # Project, VideoFile, AudioTrack, SubtitleTrack, MkvOutput, MediaTrackInfo
│ └── value_objects/ # TrackId, SyncOffset (ms/i64), TrackLanguage, FilePath
│ └── value_objects/ # TrackId, SyncOffset (ms/i64), SyncTransform (offset+scale), TrackLanguage, FilePath
├── application/
│ ├── use_cases/ # LoadMediaInfo, AddAudioTrack, AddSubtitle, AdjustSync, GenerateOutput
└── ports/ # Traits: MediaProcessorPort, MediaInfoPort, FileSystemPort
│ ├── use_cases/ # LoadMediaInfo, AddAudioTrack, AddSubtitle, AdjustSync, AdjustDrift,
# AdjustExistingTrackDrift, EditExistingTrackSync, ExportTrack,
│ │ # RemoveTrack, SetTrackLanguage, GenerateOutput
│ └── ports/ # Traits: MediaProcessorPort, MediaInfoPort, FileSystemPort, ContainerMuxPort
├── adapters/
│ ├── ffmpeg/ # FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway
│ ├── mkvmerge/ # MkvmergeCommandBuilder, MkvmergeGateway (RF-12 — drift)
│ └── filesystem/ # FilePickerAdapter
├── infrastructure/
── process/ # Execução real do FFmpeg / ffprobe
── process/ # run_ffmpeg_async, run_mkvmerge_async, validate_dependencies, mkvmerge_available
│ └── persistence/ # save_session, load_session (RF-14)
├── ui/
│ ├── app.rs # eframe App (ponto de entrada da interface)
│ └── components/ # Componentes egui reutilizáveis
│ ├── app.rs # eframe App; ActiveTab (Single/Batch); dispatch FFmpeg/mkvmerge
│ └── components/ # VideoSelector, ExistingTrackList, AddAudioTrackForm, AddSubtitleForm,
│ # SyncOffsetField (+ campo drift), OutputSelector, ExecutionPanel, BatchPanel
└── main.rs
```
@@ -325,15 +391,18 @@ src/
## 10. Débitos Técnicos
| ID | Descrição | Impacto | Mitigação |
| ----- | ----------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------ |
| DT-01 | FFmpeg não embutido | Usuário precisa instalar | Distribuir junto com a aplicação |
| DT-02 | Dependência de processo externo | Menor controle interno | Encapsular via módulo de serviço |
| DT-03 | Parsing de erros do FFmpeg (stderr) | Necessário tratamento manual | Parsear saída e exibir mensagem amigável |
| DT-04 | Compatibilidade de codecs | Alguns codecs podem não ser aceitos | Usar MKV como container padrão |
| DT-05 | Performance de processo externo | Pequeno overhead | Aceitável — mux é rápido |
| DT-06 | ffprobe como dependência adicional | Parsing de JSON da saída do ffprobe | Validar presença de ffprobe na inicialização; exibir mensagem clara se ausente |
| DT-07 | Compatibilidade com Jellyfin mobile | Faixas não selecionadas automaticamente | Investigando; as RFC RF-10 e RF-11 mitigam parcialmente`title` e `disposition` são emitidos corretamente, porém o Jellyfin mobile pode ter comportamento diferente do web; requer testes com arquivo gerado |
| ID | Descrição | Impacto | Mitigação / Status |
| ----- | ------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| DT-01 | FFmpeg não embutido | Usuário precisa instalar | Distribuir junto com a aplicação |
| DT-02 | Dependência de processo externo | Menor controle interno | Encapsulado via `infrastructure/process`; `validate_dependencies()` na inicialização |
| DT-03 | Parsing de erros do FFmpeg (stderr) | Mensagens brutas exibidas ao usuário | Stderr capturado linha a linha via `run_ffmpeg_async`; exibido no `ExecutionPanel`; parse amigável futuro |
| DT-04 | Compatibilidade de codecs | Alguns codecs podem não ser aceitos | MKV como container padrão; `-c copy` garante que não há transcodificação |
| DT-05 | Performance de processo externo | Pequeno overhead | Aceitável — mux é rápido; sem impacto perceptível |
| DT-06 | ffprobe como dependência adicional | Parsing de JSON da saída do ffprobe | ✅ Resolvido — `validate_dependencies()` verifica ffprobe na inicialização; erro global exibido se ausente |
| DT-07 | Compatibilidade com Jellyfin mobile | Faixas não selecionadas automaticamente | Investigando — RF-10 (`title`) e RF-11 (`disposition`) implementados; comportamento no Jellyfin mobile requer testes com arquivo gerado |
| DT-08 | mkvmerge não embutido | Usuário precisa instalar MKVToolNix | Dependência opcional; feat. de drift desabilitada visualmente se ausente; verificação soft na inicialização |
| DT-09 | Testes de integração com FFmpeg real | Regressões em comandos gerados | Pendente — requer `ffmpeg` no CI; coberto indiretamente pelos testes unitários do `FfmpegCommandBuilder` |
| DT-10 | Comando FFmpeg não visível ao usuário | Difícil de debugar manualmente | ✅ Resolvido — painel colapsável "🛠 Comando gerado" na `ExecutionPanel` e em cada item do modo Lote; botão de cópia para clipboard |
---
@@ -341,9 +410,10 @@ src/
- Preview de vídeo embutido na interface
- Detecção automática de sincronização entre faixas
- Seleção de faixa padrão no container MKV
- Remoção de faixas existentes do arquivo original
- Suporte a perfis de configuração salvos
- Suporte a perfis de configuração reutilizáveis (presets de idioma, offset, drift)
- Mensagens de erro mais amigáveis a partir do stderr do FFmpeg
- Testes de integração com FFmpeg real no CI (DT-09)
- Testes de snapshot para o `FfmpegCommandBuilder` e `MkvmergeCommandBuilder` com projetos complexos
---
@@ -356,11 +426,21 @@ src/
- [x] É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
- [x] É possível editar o offset de sincronização de uma faixa de áudio ou legenda já existente no arquivo
- [x] É possível atribuir idioma a cada faixa
- [x] É possível definir um título/nome para cada faixa externa (RF-10)
- [x] É possível marcar uma faixa externa como faixa padrão do container (RF-11)
- [x] É possível exportar uma faixa existente para arquivo separado (RF-13)
- [x] É possível salvar e restaurar a sessão em disco (RF-14)
- [x] É possível excluir faixas de áudio ou legenda existentes do arquivo de saída, com possibilidade de restauração antes da geração (RF-15)
- [x] É possível reordenar as faixas externas adicionadas com botões ↑↓; a ordem reflete o índice no container final (RF-16)
- [x] O modo lote permite configurar e processar múltiplos projetos sequencialmente (RF-09)
- [x] É possível definir fator de correção de drift por faixa (RF-12; requer mkvmerge)
- [x] O arquivo MKV é gerado corretamente ao confirmar
- [x] Erros do FFmpeg são exibidos de forma legível
- [x] A interface não trava durante o processamento
- [x] Nenhum reencoding ocorre (verificável via `ffprobe`)
- [x] O flag `-c copy` está sempre presente no comando gerado (verificável via testes unitários e modo debug)
- [x] Erros do FFmpeg/mkvmerge são exibidos de forma legível na `ExecutionPanel`
- [x] A interface não trava durante o processamento (execução assíncrona via `tokio`)
- [x] É possível cancelar a geração em andamento
- [x] Nenhum reencoding ocorre (verificável via `ffprobe` no arquivo de saída)
- [x] O flag `-c copy` está sempre presente no comando FFmpeg gerado (verificável via testes unitários)
- [x] O comando FFmpeg/mkvmerge gerado é exibido em painel colapsável na ExecutionPanel (DT-10)
---
-281
View File
@@ -1,281 +0,0 @@
# Progresso de Implementação
**Data:** 28/02/2026
**Status:** Fases 18 concluídas — compilando, 33 testes passando, zero warnings do projeto, aplicação executável
**Referência:** DEVELOPMENT_PLAN.md v1.3
---
## Resumo Executivo
Todas as 6 fases do plano de desenvolvimento foram implementadas. O projeto compila sem erros,
33 testes unitários passam e a aplicação pode ser executada com `cargo run`.
Todos os 14 warnings de `unused`/`dead_code` foram resolvidos: assignment morto em `command_builder.rs` removido, lifetime explícito em `FilePath::to_string_lossy` corrigido, e APIs arquiteturais não chamadas pela UI suprimidas com `#[allow(dead_code)]`.
Após a conclusão das fases, foram implementadas funcionalidades adicionais:
- Exportação de faixa de áudio ou legenda diretamente da lista de faixas existentes
- Exibição das faixas externas adicionadas com opção de remoção individual
- Layout responsivo com painéis fixos (cabeçalho, rodapé, barra lateral) e área central com scroll
- Progresso em tempo real via `run_ffmpeg_async` (Opção A: `std::sync::mpsc` em toda a cadeia)
- Cancelamento de geração em andamento via botão "Cancelar" — encerra o processo FFmpeg filho imediatamente
- **Fase 8 — Modo Lote via Abas** — barra de abas ("Projeto Único" / "🗂 Lote"); `BatchPanel` com carrinho de projetos e formulário inline de adição; processamento sequencial automático via `start_batch_item` + avanço em `poll_background`; estado individual por item (`⏳ Aguardando | ⟳ Processando | ✓ Concluído | ⊸ Cancelado | ✗ Erro`); cancelamento do item atual com botão "⏹ Cancelar item atual"; carrinho bloqueado durante processamento
---
## Status por Fase
| Fase | Descrição | Status |
| ---- | ------------------------------- | ------------ |
| 1 | Setup do projeto | ✅ Concluída |
| 2 | Domain (núcleo puro) | ✅ Concluída |
| 3 | Application (use cases + ports) | ✅ Concluída |
| 4 | Adapters (FFmpeg + filesystem) | ✅ Concluída |
| 5 | Infrastructure (processo async) | ✅ Concluída |
| 6 | UI (eframe/egui) | ✅ Concluída |
| 7 | Persistência do estado | ✅ Concluída |
| 8 | Modo Lote via Abas | ✅ Concluída |
---
## Estrutura de Arquivos Criados
```
src/
├── main.rs — entrada, configuração da janela eframe
├── domain/
│ ├── mod.rs
│ ├── value_objects/
│ │ ├── mod.rs
│ │ ├── file_path.rs — FilePath (newtype sobre PathBuf)
│ │ ├── track_id.rs — TrackId (newtype opaco sobre u32)
│ │ ├── sync_offset.rs — SyncOffset (i64 ms, nunca f64)
│ │ └── track_language.rs — TrackLanguage (ISO 639-2, validado)
│ └── entities/
│ ├── mod.rs
│ ├── project.rs — Project (entidade raiz / fonte de verdade)
│ ├── video_file.rs — VideoFile
│ ├── audio_track.rs — AudioTrack
│ ├── subtitle_track.rs — SubtitleTrack
│ ├── media_track_info.rs — MediaTrackInfo + TrackKind
│ ├── mkv_output.rs — MkvOutput
│ └── track.rs — Track (enum: Audio | Subtitle)
├── application/
│ ├── mod.rs
│ ├── ports/
│ │ └── mod.rs — MediaInfoPort, MediaProcessorPort, FileSystemPort
│ └── use_cases/
│ ├── mod.rs
│ ├── load_media_info.rs — popula Project::existing_tracks via ffprobe
│ ├── add_audio_track.rs — adiciona AudioTrack ao Project
│ ├── add_subtitle.rs — adiciona SubtitleTrack ao Project
│ ├── adjust_sync.rs — altera SyncOffset de faixa externa
│ ├── edit_existing_track_sync.rs — altera SyncOffset de faixa existente
│ ├── export_track.rs — exporta faixa de áudio ou legenda para arquivo
│ ├── remove_track.rs — remove faixa externa do projeto pelo TrackId
│ ├── set_track_language.rs — altera idioma de faixa externa
│ └── generate_output.rs — constrói comando e delega ao MediaProcessorPort
├── adapters/
│ ├── mod.rs
│ ├── ffmpeg/
│ │ ├── mod.rs
│ │ ├── command_builder.rs — FfmpegCommandBuilder: Project → Vec<String>; build_export()
│ │ ├── ffprobe_gateway.rs — FfprobeGateway impl MediaInfoPort
│ │ └── ffmpeg_gateway.rs — FfmpegGateway impl MediaProcessorPort
│ └── filesystem/
│ ├── mod.rs — RealFileSystem impl FileSystemPort
│ └── file_picker.rs — FilePickerAdapter; save_audio(codec), save_subtitle(codec)
├── infrastructure/
│ ├── mod.rs
│ └── process/
└── mod.rs — run_ffmpeg_async (tokio, cancel via oneshot), validate_dependencies()
└── ui/
├── mod.rs
├── app.rs — eframe::App; Project como única fonte de verdade; cancel_tx para interromper FFmpeg
└── components/
├── mod.rs
├── video_selector.rs — seleção do vídeo base
├── output_selector.rs — seleção do arquivo de saída (.mkv)
├── existing_track_list.rs — lista faixas detectadas + edição de offset│ ├── added_track_list.rs — lista faixas externas adicionadas + botão remover ├── add_audio_track_form.rs — formulário: áudio externo
├── add_subtitle_form.rs — formulário: legenda externa
├── sync_offset_field.rs — campo de atraso em segundos
├── language_field.rs — campo de idioma ISO 639-2
├── execution_panel.rs — botão gerar, botão cancelar, progresso, estados: Idle/Running/Success/Cancelled/Error
└── batch_panel.rs — BatchItem, BatchPanel; carrinho de lote + formulário inline; estado por item
```
---
## Inventário de Mudanças — Compatibilidade com Jellyfin (01/03/2026)
### Contexto
Arquivos M4A produzidos pelo Google (ex: downloads do YouTube Music ou Google Drive) carregam
um metadado `title` na stream de áudio com o valor `"ISO Media file produced by Google Inc."`. Quando
o FFmpeg faz o mux sem sobrescrever esse metadado, ele é copiado literalmente para a faixa do MKV.
O Jellyfin **web** é tolerante e usa a tag `language` para selecionar a faixa preferida. Já o
Jellyfin **mobile** usa o campo `title` da stream para exibir e selecionar faixas — quando não
reconhece o título como idioma, não realiza seleção automática e mantém a faixa original.
Algo semelhante ocorre com `disposition:default`: a faixa original do fonte fica com `default=1`;
a nova faixa fica com `default=0`; o Jellyfin mobile respeitam esse flag e tocam a original.
> **Status:** mudanças implementadas e compilando; investigação em andamento — o problema no
> Jellyfin mobile ainda não foi completamente resolvido.
### Mudanças por camada
| Arquivo | Mudança |
| --- | --- |
| `domain/entities/audio_track.rs` | Adicionado `is_default: bool` e `title: String` a `AudioTrack` |
| `domain/entities/subtitle_track.rs` | Adicionado `is_default: bool` e `title: String` a `SubtitleTrack` |
| `application/use_cases/add_audio_track.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
| `application/use_cases/add_subtitle.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
| `adapters/ffmpeg/command_builder.rs` | Emite `-disposition:a/s:{idx} 0` nas faixas existentes quando uma externa é marcada como padrão; emite `-disposition:a/s:{idx} default` na faixa marcada; emite `-metadata:s:a/s:{idx} title={valor}` para toda faixa externa (string vazia apaga o título herdado) |
| `ui/components/add_audio_track_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
| `ui/components/add_subtitle_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
| `ui/components/batch_panel.rs` | `PendingAudio` e `PendingSubtitle` propagam `is_default` e `title` |
---
## Testes Unitários (33/33 passando)
### Domain — Value Objects
| Teste | Resultado |
| ------------------------------------------------------------------------ | --------- |
| `sync_offset::from_seconds_str_positivo``"1.2"``SyncOffset(1200)` | ✅ |
| `sync_offset::from_seconds_str_negativo``"-0.5"``SyncOffset(-500)` | ✅ |
| `sync_offset::from_seconds_str_com_sufixo_s` | ✅ |
| `sync_offset::from_seconds_str_invalido` | ✅ |
| `sync_offset::from_seconds_str_zero` | ✅ |
| `track_language::idioma_valido``"por"`, `"eng"` | ✅ |
| `track_language::idioma_invalido_curto``"pt"` | ✅ |
| `track_language::idioma_invalido_maiusculo``"POR"` | ✅ |
| `track_language::idioma_invalido_longo``"port"` | ✅ |
### Domain — Entities
| Teste | Resultado |
| ------------------------------------------------------------------------- | --------- |
| `project::projeto_valido` | ✅ |
| `project::projeto_invalido_mesmo_caminho` — output == source retorna erro | ✅ |
| `project::track_id_opaco` — TrackId não expõe indexação interna | ✅ |
### Application — Use Cases (todos com mocks, sem I/O real)
| Teste | Resultado |
| ------------------------------------------------------------------ | --------- |
| `load_media_info::carrega_faixas_no_projeto` | ✅ |
| `add_audio_track::adiciona_audio_no_projeto` | ✅ |
| `add_subtitle::adiciona_legenda_no_projeto` | ✅ |
| `adjust_sync::ajusta_offset_existente` | ✅ |
| `adjust_sync::erro_se_id_inexistente` | ✅ |
| `edit_existing_track_sync::edita_offset_de_faixa_existente` | ✅ |
| `edit_existing_track_sync::erro_se_faixa_existente_nao_encontrada` | ✅ |
| `set_track_language::altera_idioma_da_faixa` | ✅ |
| `generate_output::sempre_inclui_c_copy` | ✅ |
### Adapters — FfmpegCommandBuilder
| Teste | Resultado |
| -------------------------------------------------------- | --------- |
| `sempre_contem_c_copy` — invariante RNF-01 | ✅ |
| `output_e_o_ultimo_argumento` | ✅ |
| `itsoffset_formato_correto``1200ms``"1.200"` | ✅ |
| `mapa_faixa_externa_de_audio``map 1:a` | ✅ |
| `metadata_idioma_audio``-metadata:s:a:0 language=por` | ✅ |
### Application — ExportTrack
| Teste | Resultado |
| ----------------------------------------------------------------------------------------- | --------- |
| `export_track::exporta_audio_com_c_a_copy``-c:a copy` presente, sem `-c copy` genérico | ✅ |
| `export_track::exporta_audio_mapeia_stream_correto``0:stream_index` correto | ✅ |
| `export_track::exporta_legenda_sem_c_copy` — nenhuma forma de `-c` para legendas | ✅ |
| `export_track::exporta_legenda_output_e_ultimo_argumento` | ✅ |
| `export_track::rejeita_faixa_de_video` — retorna erro para `TrackKind::Video` | ✅ |
### Application — RemoveTrack
| Teste | Resultado |
| ---------------------------------------------------------------------- | --------- |
| `remove_track::remove_faixa_existente` — faixa removida do Vec | ✅ |
| `remove_track::erro_se_id_inexistente` — retorna erro se id não existe | ✅ |
---
## Dependências (Cargo.toml)
```toml
eframe = "0.27"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["process", "rt-multi-thread", "macros", "io-util", "sync"] }
rfd = "0.14"
```
---
## Invariantes Garantidas
- **`-c copy` sempre presente** — `FfmpegCommandBuilder::build()` emite incondicionalmente (RNF-01)
- **`SyncOffset` nunca usa `f64`** — armazenado como `i64` ms; conversão para string FFmpeg feita exclusivamente no `FfmpegCommandBuilder`
- **`TrackId` é opaco** — não expõe índice interno; mapeamento para `-map N:tipo` feito apenas no adapter
- **`Project` rejeita `output == source`** — validado no construtor
- **Termos FFmpeg nunca aparecem na UI** — a interface usa linguagem do usuário final
- **Exceção documentada ao RNF-01** — `build_export()` omite `-c copy` exclusivamente para legendas (conversão de container de texto, sem processamento de mídia); comentário inline explica a exceção
- **`title=` sempre emitido** — `FfmpegCommandBuilder` emite `-metadata:s:a/s:{idx} title=` para toda faixa externa, garantindo que títulos herdados do arquivo fonte sejam sobrescritos; string vazia limpa o campo no MKV
---
## O Que Falta (Backlog de Refinamento)
### Funcional
- [x] Exportar faixa de áudio ou legenda existente para arquivo separado
- [x] Exibição de faixas externas adicionadas com opção de remover
- [x] Progresso em tempo real via `run_ffmpeg_async``run_ffmpeg_async` usa `std::sync::mpsc::Sender<String>`; `start_generation` cria tokio Runtime + thread encaminhadora; cada linha de stderr do FFmpeg aparece no log antes do término
- [x] Cancelamento de geração — botão "Cancelar" visível durante `Running`; sinal via `tokio::sync::oneshot`; `child.kill().await` no `tokio::select!`; estado `ExecutionState::Cancelled` exibido em amarelo
- [x] **Fase 7 — Persistência do Estado** — novo módulo `infrastructure/persistence` com `save_session`/`load_session`/`session_exists`; JSON em `~/.config/simple-mkv-editor/session.json`; botões "💾 Salvar sessão" / "📂 Carregar sessão" no header com feedback visual (verde/vermelho + botão ✕)
- [x] **Fase 8 — Modo Lote via Abas** — barra de abas "Projeto Único" / "🗂 Lote"; `BatchPanel` com carrinho + formulário inline; `start_batch_item` + avanço automático em `poll_background`; estado individual por item; cancelamento de item atual; carrinho bloqueado durante processamento
### Qualidade
- [x] Corrigir 14 warnings de `unused`/`dead_code` identificados por `cargo build`:
- `next_input_idx += 1` removido do loop externo em `command_builder.rs` (assignment nunca lido)
- Lifetime explícito `Cow<'_, str>` adicionado em `FilePath::to_string_lossy`
- `RealFileSystem`, `FileSystemPort`, `AdjustSync`, `GenerateOutput`, `SetTrackLanguage` suprimidos com `#[allow(dead_code)]` — APIs arquiteturais sem chamador na UI atual
- Métodos `find_track_mut`, `language`, `set_offset`, `set_language`, `from_ms` suprimidos com `#[allow(dead_code)]`
- [ ] Testes de integração com FFmpeg real (requer ffmpeg instalado no CI)
- [ ] Testes de snapshot para o `FfmpegCommandBuilder` com projetos mais complexos
### UI
- [x] Layout responsivo mais refinado
- [ ] Exibição do comando FFmpeg gerado em modo debug (ex: painel colapsável na `ExecutionPanel`)
- [ ] Persistência do estado do projeto em disco (`serde` já disponível)
- [ ] Mensagens de erro mais amigáveis na UI
---
## Como Executar
```bash
# Verificar compilação
cargo check
# Rodar testes
cargo test
# Executar a aplicação (requer ffmpeg e ffprobe no PATH)
cargo run
```
+162
View File
@@ -0,0 +1,162 @@
# Implementação — DT-10: Painel de Debug do Comando Gerado
**Data:** 01/03/2026
**Status:** ✅ Implementado
**Prioridade:** Baixa — melhoria de DX para usuário técnico
**Arquivos afetados:**
- `src/ui/components/execution_panel.rs`
- `src/ui/app.rs`
- `PRD.md` (documentação ao concluir)
---
## Objetivo
Exibir, de forma colapsável na `ExecutionPanel`, o comando exato (`ffmpeg …` ou `mkvmerge …`) que foi enviado ao processo externo na última geração. Permite que o usuário técnico inspecione, copie e reproduza o comando manualmente para diagnóstico.
---
## Viabilidade
| Ponto | Situação |
| ------------------------------------------------ | ------------------------------------------------ | --- | ---------------------------------- |
| `args: Vec<String>` já construído antes do spawn | `start_generation()` — linha ~244 de `app.rs` |
| `ExecutionPanel` já usa `ui.collapsing()` | Padrão reutilizado do "Log detalhado" |
| Nenhuma camada nova necessária | Mudanças em 2 arquivos apenas |
| Sem impacto em testes existentes | Campo additive — nenhuma assinatura pública muda |
| Botão "Copiar" sem dependência adicional | `ui.output_mut( | o | o.copied_text = …)` nativo do egui |
**Estimativa:** ~30 linhas adicionadas. Risco zero de regressão.
---
## Passo 1 — Campo `last_command` em `ExecutionPanel`
**Arquivo:** `src/ui/components/execution_panel.rs`
Adicionar o campo à struct:
```rust
pub struct ExecutionPanel {
pub state: ExecutionState,
pub log_lines: Vec<String>,
/// Comando completo enviado ao FFmpeg/mkvmerge na última geração.
/// Exemplo: "ffmpeg -i input.mkv -c copy ... output.mkv"
pub last_command: Option<String>,
}
```
Inicializar em `ExecutionPanel::new()`:
```rust
pub fn new() -> Self {
ExecutionPanel {
state: ExecutionState::Idle,
log_lines: Vec::new(),
last_command: None,
}
}
```
---
## Passo 2 — Formatar e armazenar o comando em `app.rs`
**Arquivo:** `src/ui/app.rs`
**Localização:** método `start_generation()`, logo após construir `args` e antes do `thread::spawn`
```rust
// Formatar comando legível para o painel de debug
let binary = if uses_mkvmerge { "mkvmerge" } else { "ffmpeg" };
let cmd_str = format!(
"{} {}",
binary,
args.iter()
.map(|a| if a.contains(' ') { format!("\"{}\"", a) } else { a.clone() })
.collect::<Vec<_>>()
.join(" ")
);
self.execution_panel.last_command = Some(cmd_str);
```
> **Atenção:** replicar o mesmo bloco em `start_batch_generation()` para cobrir o modo Lote (RF-09).
---
## Passo 3 — Renderizar o painel colapsável em `ExecutionPanel::ui()`
**Arquivo:** `src/ui/components/execution_panel.rs`
**Localização:** após o bloco `"Log detalhado"`, antes do `});` de fechamento do `ui.group`
```rust
if let Some(cmd) = &self.last_command {
ui.collapsing("🛠 Comando gerado", |ui| {
let mut cmd_str = cmd.as_str();
ui.add(
egui::TextEdit::multiline(&mut cmd_str)
.desired_rows(3)
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace),
);
if ui.small_button("📋 Copiar").clicked() {
ui.output_mut(|o| o.copied_text = cmd.clone());
}
});
}
```
**Comportamento esperado:**
- Colapsável fechado por padrão — não ocupa espaço visual desnecessário
- `TextEdit` somente leitura (variável local imutável no contexto do egui)
- Botão "📋 Copiar" coloca o comando no clipboard do sistema
- Argumento com espaço interno é envolvido em aspas para reprodutibilidade no terminal
---
## Passo 4 — Comportamento de limpeza
`last_command` **não** é limpo ao iniciar nova geração — ele é sobrescrito. O usuário vê sempre o comando da execução atual.
`last_command` **não** é limpo em `cancel_generation()` — o usuário pode querer inspecionar o comando de uma geração cancelada.
Não há necessidade de resetar em nenhum outro ponto.
---
## Passo 5 — Atualizar PRD e débitos técnicos
Ao concluir a implementação:
1. Marcar DT-10 como `✅ Resolvido` na tabela da seção 10 do `PRD.md`
2. Remover o item do backlog da seção 11
3. Adicionar critério de aceitação na seção 12:
- `[x] O comando FFmpeg/mkvmerge gerado é exibido em painel colapsável na ExecutionPanel (DT-10)`
---
## Ordem de execução recomendada
| # | Passo | Testável após |
| --- | --------------------------------------------------------------------- | ---------------------------------- |
| 1 | Adicionar campo + init em `ExecutionPanel` | `cargo check` |
| 2 | Renderizar painel colapsável (Passo 3) com valor hardcoded temporário | `cargo run` — inspeção visual |
| 3 | Wiring em `start_generation()` (Passo 2) | `cargo run` — geração real |
| 4 | Replicar wiring em `start_batch_generation()` | `cargo run` — modo lote |
| 5 | Remover hardcode temporário se usado; `cargo test` | 33 testes devem continuar passando |
| 6 | Atualizar PRD (Passo 5) | — |
---
## Critério de aceitação
- [x] O painel "🛠 Comando gerado" aparece na `ExecutionPanel` após a primeira geração
- [x] O painel está colapsado por padrão
- [x] O conteúdo exibe o binário (`ffmpeg` ou `mkvmerge`) seguido de todos os argumentos
- [x] Argumentos com espaços internos são envolvidos em aspas duplas
- [x] O botão "📋 Copiar" coloca o texto no clipboard
- [x] O comando é exibido também para gerações canceladas
- [x] O painel **não** aparece antes da primeira geração (estado `Idle` inicial)
- [x] Modo Lote exibe o comando do último item processado
- [x] `cargo test` — 68 testes passando sem regressão
+260
View File
@@ -0,0 +1,260 @@
# Implementação — Reordenar Faixas Externas (botões ↑↓)
**Data:** 01/03/2026
**Status:** ✅ Implementado
**Prioridade:** Baixa — melhoria de usabilidade
**Arquivos afetados:**
- `src/domain/entities/project.rs`
- `src/ui/components/added_track_list.rs`
- `src/ui/app.rs`
---
## Objetivo
Permitir que o usuário reordene as faixas externas adicionadas (áudio e legenda) usando botões ↑↓ na lista. A ordem do `Vec<Track>` em `project.tracks` determina diretamente o índice de stream no container MKV final — já que ambos os command builders (`FfmpegCommandBuilder` e `MkvmergeCommandBuilder`) iteram esse Vec posicionalmente a cada `build()`.
---
## Por que botões ↑↓ e não drag-and-drop
| Critério | Botões ↑↓ | Drag-and-drop (egui) |
| ------------------------ | --------------------------------------- | -------------------------------------------- |
| Esforço de implementação | ~1h | ~56h |
| Risco de regressão | Nulo — mudanças aditivas | Baixo, mas requer estado extra no componente |
| Clareza de UX | Explícita, sem ambiguidade | Mais fluido, porém menos óbvio em grids |
| Testabilidade | Método de domínio testável isoladamente | Lógica de UI difícil de testar |
**Conclusão:** botões ↑↓ entregam 100% do valor com ~15% do esforço.
---
## Análise da arquitetura atual
A ordem das faixas externas já é a fonte de verdade do índice no output:
```rust
// FfmpegCommandBuilder::build() — sem cache de índice
let mut ext_idx = external_input_start;
for track in &project.tracks { // ← itera em ordem
args.push("-map".to_string());
// ...
ext_idx += 1;
}
```
```rust
// MkvmergeCommandBuilder::build() — idem
for track in &project.tracks { // ← itera em ordem
// cada arquivo externo vira um input separado
}
```
`TrackId` é opaco (`TrackId(u32)`) — não representa posição, apenas identidade. Uma troca de posição no Vec não invalida nenhum `TrackId` existente.
---
## Passo 1 — Método `move_track` em `Project`
**Arquivo:** `src/domain/entities/project.rs`
Adicionar logo após `remove_track`:
```rust
/// Move uma faixa externa para cima (`delta = -1`) ou para baixo (`delta = 1`).
/// Retorna `true` se a faixa foi encontrada e o movimento era possível.
pub fn move_track(&mut self, id: TrackId, delta: i8) -> bool {
let Some(pos) = self.tracks.iter().position(|t| t.id() == id) else {
return false;
};
let new_pos = pos as i64 + delta as i64;
if new_pos < 0 || new_pos >= self.tracks.len() as i64 {
return false;
}
self.tracks.swap(pos, new_pos as usize);
true
}
```
---
## Passo 2 — Novos eventos em `AddedTrackEvent`
**Arquivo:** `src/ui/components/added_track_list.rs`
```rust
pub enum AddedTrackEvent {
RemoveRequested(TrackId),
MoveUp(TrackId), // ← novo
MoveDown(TrackId), // ← novo
}
```
---
## Passo 3 — Botões ↑↓ na grid
**Arquivo:** `src/ui/components/added_track_list.rs`
Alterar a assinatura do método para receber o índice atual e o total, e adicionar os botões na última coluna.
A grid passa de 4 para 5 colunas (`num_columns(5)`). O cabeçalho ganha uma coluna `""` extra. Cada linha recebe os botões ↑ e ↓, desabilitados na primeira e última posição respectivamente:
```rust
pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> {
let mut events = Vec::new();
let total = tracks.len();
ui.group(|ui| {
ui.heading("Faixas adicionadas");
if tracks.is_empty() {
ui.label("Nenhuma faixa adicionada.");
return;
}
egui::Grid::new("added_tracks_grid")
.num_columns(5) // ← era 4
.max_col_width(200.0)
.striped(true)
.show(ui, |ui| {
ui.strong("Tipo");
ui.strong("Arquivo");
ui.strong("Idioma");
ui.strong(""); // coluna de ordem
ui.strong(""); // coluna de remover
ui.end_row();
for (idx, track) in tracks.iter().enumerate() {
// ... células existentes (tipo, arquivo, idioma) ...
// ── Botões de ordenação ──
ui.horizontal(|ui| {
ui.add_enabled_ui(idx > 0, |ui| {
if ui.small_button("").clicked() {
events.push(AddedTrackEvent::MoveUp(track.id()));
}
});
ui.add_enabled_ui(idx + 1 < total, |ui| {
if ui.small_button("").clicked() {
events.push(AddedTrackEvent::MoveDown(track.id()));
}
});
});
if ui.small_button("🗑 Remover").clicked() {
events.push(AddedTrackEvent::RemoveRequested(track.id()));
}
ui.end_row();
}
});
});
events
}
```
---
## Passo 4 — Tratar eventos em `app.rs`
**Arquivo:** `src/ui/app.rs`
O trecho que já trata `RemoveRequested` precisa ser expandido:
```rust
let track_events = self.added_track_list.ui(ui, &project.tracks);
for event in track_events {
use crate::ui::components::added_track_list::AddedTrackEvent;
match event {
AddedTrackEvent::RemoveRequested(id) => {
let _ = RemoveTrack::execute(project, id);
}
AddedTrackEvent::MoveUp(id) => {
project.move_track(id, -1);
}
AddedTrackEvent::MoveDown(id) => {
project.move_track(id, 1);
}
}
}
```
---
## Passo 5 — Testes unitários para `move_track`
**Arquivo:** `src/domain/entities/project.rs` — bloco `#[cfg(test)]` existente.
```rust
#[test]
fn move_track_para_cima() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let t2 = audio_track_with_drift(2, 1.0);
let id1 = t1.id();
let id2 = t2.id();
p.tracks.push(t1);
p.tracks.push(t2);
assert!(p.move_track(id2, -1));
assert_eq!(p.tracks[0].id(), id2);
assert_eq!(p.tracks[1].id(), id1);
}
#[test]
fn move_track_para_baixo() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let t2 = audio_track_with_drift(2, 1.0);
let id1 = t1.id();
let id2 = t2.id();
p.tracks.push(t1);
p.tracks.push(t2);
assert!(p.move_track(id1, 1));
assert_eq!(p.tracks[0].id(), id2);
assert_eq!(p.tracks[1].id(), id1);
}
#[test]
fn move_track_limite_superior_ignorado() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let id1 = t1.id();
p.tracks.push(t1);
assert!(!p.move_track(id1, -1)); // já é o primeiro — não move
}
#[test]
fn move_track_limite_inferior_ignorado() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let id1 = t1.id();
p.tracks.push(t1);
assert!(!p.move_track(id1, 1)); // já é o último — não move
}
```
---
## Escopo e limitações
- Somente faixas **externas** (`project.tracks`) são reordenáveis. Faixas existentes (`project.existing_tracks`, lidas via ffprobe) permanecem na ordem detectada — essa restrição deve ser comunicada visualmente (ex: tooltip ou nota abaixo da lista de faixas adicionadas).
- A ordenação é persistida automaticamente via RF-14 (sessão salva em JSON inclui o Vec na nova ordem).
- Sem impacto no modo Lote: cada `BatchItem` tem seu próprio `Project` independente; a mesma lógica se aplica.
---
## Checklist de implementação
- [x] `Project::move_track()` adicionado e testado (4 testes novos)
- [x] `AddedTrackEvent` com variantes `MoveUp` e `MoveDown`
- [x] Grid de 5 colunas com botões ↑↓ desabilitados nas bordas
- [x] Handler em `app.rs` tratando os dois novos eventos
- [x] `cargo test` — 72 testes passando, 0 falhas
- [x] `cargo check` sem warnings (3 warnings pré-existentes não relacionados)
+54 -4
View File
@@ -61,8 +61,8 @@ impl FfmpegCommandBuilder {
}
// ── Maps ────────────────────────────────────────────────────────────────
// Faixas existentes do source
for track in &project.existing_tracks {
// Faixas existentes do source (excluídas pelo usuário são omitidas)
for track in project.existing_tracks.iter().filter(|t| !t.excluded) {
args.push("-map".to_string());
if track.offset.is_zero() {
args.push(format!("0:{}", track.stream_index));
@@ -74,7 +74,9 @@ impl FfmpegCommandBuilder {
}
}
// Se não há faixas existentes mapeadas, mapeia tudo do source
// Se não há faixas existentes conhecidas (ffprobe não foi executado), mapeia tudo do source.
// Quando existing_tracks está populado mas todas foram excluídas, o usuário optou
// conscientemente por não incluir nenhuma faixa original — não emitimos -map 0.
if project.existing_tracks.is_empty() {
args.push("-map".to_string());
args.push("0".to_string());
@@ -310,6 +312,7 @@ mod tests {
TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
)
.unwrap();
@@ -329,6 +332,7 @@ mod tests {
TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
)
.unwrap();
@@ -350,6 +354,7 @@ mod tests {
TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
)
.unwrap();
@@ -361,4 +366,49 @@ mod tests {
);
assert!(args.contains(&"language=por".to_string()));
}
}
#[test]
fn faixa_existente_excluida_nao_aparece_em_map() {
use crate::domain::entities::{MediaTrackInfo, TrackKind};
use crate::domain::value_objects::TrackId;
let mut project = base_project();
let mut audio = MediaTrackInfo::new(
TrackId::new(1),
TrackKind::Audio,
"aac",
None,
1,
);
audio.excluded = true;
project.existing_tracks.push(audio);
let args = FfmpegCommandBuilder::build(&project);
// -map 0:1 não deve aparecer pois a faixa está excluída
assert!(
!args.contains(&"0:1".to_string()),
"faixa excluída não deve aparecer em -map — args: {:?}",
args
);
}
#[test]
fn faixa_existente_nao_excluida_aparece_em_map() {
use crate::domain::entities::{MediaTrackInfo, TrackKind};
use crate::domain::value_objects::TrackId;
let mut project = base_project();
project.existing_tracks.push(MediaTrackInfo::new(
TrackId::new(1),
TrackKind::Audio,
"aac",
None,
1,
));
let args = FfmpegCommandBuilder::build(&project);
assert!(
args.contains(&"0:1".to_string()),
"faixa não excluída deve aparecer em -map — args: {:?}",
args
);
}
}
+73 -5
View File
@@ -20,12 +20,17 @@ struct FfprobeStream {
index: u32,
codec_type: Option<String>,
codec_name: Option<String>,
duration: Option<String>,
tags: Option<FfprobeTags>,
}
#[derive(Deserialize)]
struct FfprobeTags {
language: Option<String>,
/// Presente em containers MKV/MP4 quando `duration` não está no nível do stream.
/// Formato: "HH:MM:SS.NNNNNNNNN" ou "HH:MM:SS.mmm".
#[serde(rename = "DURATION")]
duration_tag: Option<String>,
}
impl MediaInfoPort for FfprobeGateway {
@@ -61,15 +66,78 @@ impl MediaInfoPort for FfprobeGateway {
_ => return None,
};
let codec = s.codec_name.unwrap_or_else(|| "unknown".to_string());
let language = s
.tags
.and_then(|t| t.language)
.and_then(|l| TrackLanguage::new(l).ok());
let (language, duration_from_tag) = match s.tags {
Some(t) => {
let lang = t.language.and_then(|l| TrackLanguage::new(l).ok());
let dur = t.duration_tag.and_then(|d| parse_duration_tag(&d));
(lang, dur)
}
None => (None, None),
};
let id = TrackId::new(s.index);
Some(MediaTrackInfo::new(id, kind, codec, language, s.index))
let duration_ms = s
.duration
.and_then(|d| d.trim().parse::<f64>().ok())
.map(|secs| (secs * 1000.0).round() as u64)
.or(duration_from_tag);
let mut info = MediaTrackInfo::new(id, kind, codec, language, s.index);
info.duration_ms = duration_ms;
Some(info)
})
.collect();
Ok(tracks)
}
}
/// Converte string no formato "HH:MM:SS.NNNNNNNNN" (tag DURATION do ffprobe) para milissegundos.
fn parse_duration_tag(s: &str) -> Option<u64> {
// Suporta "1:23:45.678901234" e "23:45.678"
let s = s.trim();
let (time_part, frac_part) = match s.split_once('.') {
Some((t, f)) => (t, Some(f)),
None => (s, None),
};
let parts: Vec<&str> = time_part.split(':').collect();
let (h, m, sec) = match parts.as_slice() {
[h, m, sec] => (h.parse::<u64>().ok()?, m.parse::<u64>().ok()?, sec.parse::<u64>().ok()?),
[m, sec] => (0, m.parse::<u64>().ok()?, sec.parse::<u64>().ok()?),
_ => return None,
};
let total_ms = (h * 3600 + m * 60 + sec) * 1000;
let frac_ms = frac_part
.and_then(|f| {
// Normaliza para 3 dígitos (ms), seja nano ou ms
let padded = format!("{:0<9}", f); // pad à direita para 9 dígitos
padded[..9].parse::<u64>().ok().map(|ns| ns / 1_000_000)
})
.unwrap_or(0);
Some(total_ms + frac_ms)
}
#[cfg(test)]
mod tests {
use super::parse_duration_tag;
#[test]
fn parse_hh_mm_ss_nanoseconds() {
// Formato típico de container MKV: "01:23:45.678901234"
let ms = parse_duration_tag("01:23:45.678901234").unwrap();
assert_eq!(ms, (1 * 3600 + 23 * 60 + 45) * 1000 + 678);
}
#[test]
fn parse_mm_ss_milliseconds() {
assert_eq!(parse_duration_tag("23:40.000000000").unwrap(), (23 * 60 + 40) * 1000);
}
#[test]
fn parse_sem_fracao() {
assert_eq!(parse_duration_tag("1:00:00").unwrap(), 3_600_000);
}
#[test]
fn parse_invalido_retorna_none() {
assert!(parse_duration_tag("abc").is_none());
}
}
+336
View File
@@ -0,0 +1,336 @@
use crate::domain::entities::{Project, Track, TrackKind};
use crate::domain::value_objects::SyncOffset;
/// Constrói os argumentos de linha de comando para o `mkvmerge`.
///
/// Responsabilidade única: converter `Project` → `Vec<String>` para mkvmerge.
/// Usado quando `project.needs_mkvmerge() == true`.
pub struct MkvmergeCommandBuilder;
impl MkvmergeCommandBuilder {
/// Converte um `Project` em argumentos para `mkvmerge`.
///
/// Ordem de saída:
/// 1. `-o <output>`
/// 2. Opções de faixas existentes (--sync, --language) antes do arquivo fonte
/// 3. `<source.path>`
/// 4. Para cada faixa externa: opções (--sync, --language, --track-name, --default-track) + arquivo
pub fn build(project: &Project) -> Vec<String> {
let mut args = Vec::new();
// 1. Output (vem primeiro no mkvmerge)
args.push("-o".to_string());
args.push(project.output.path.to_string());
// 2. Seleção de faixas existentes (exclusão pelo usuário)
// Coleta TIDs de áudio e legenda não excluídos para --audio-tracks / --subtitle-tracks.
// Se todos de um tipo foram excluídos, emite --no-audio ou --no-subtitles.
let audio_included: Vec<u32> = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Audio) && !t.excluded)
.map(|t| t.stream_index)
.collect();
let audio_total = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Audio))
.count();
if audio_total > 0 {
if audio_included.is_empty() {
args.push("--no-audio".to_string());
} else if audio_included.len() < audio_total {
let tids = audio_included
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
args.push("--audio-tracks".to_string());
args.push(tids);
}
}
let sub_included: Vec<u32> = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Subtitle) && !t.excluded)
.map(|t| t.stream_index)
.collect();
let sub_total = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Subtitle))
.count();
if sub_total > 0 {
if sub_included.is_empty() {
args.push("--no-subtitles".to_string());
} else if sub_included.len() < sub_total {
let tids = sub_included
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
args.push("--subtitle-tracks".to_string());
args.push(tids);
}
}
// Opções de faixas existentes não excluídas (--sync, --language)
for track in project.existing_tracks.iter().filter(|t| !t.excluded) {
let needs_sync = track.offset.as_ms() != 0 || (track.drift_scale - 1.0).abs() > 1e-9;
if needs_sync {
let (num, den) = scale_to_rational(track.drift_scale);
args.push("--sync".to_string());
args.push(format!("{}:{},{}/{}", track.stream_index, track.offset.as_ms(), num, den));
}
if let Some(lang) = &track.language {
args.push("--language".to_string());
args.push(format!("{}:{}", track.stream_index, lang.as_str()));
}
}
// 3. Arquivo fonte
args.push(project.source.path.to_string());
// 4. Faixas externas adicionadas pelo usuário
// Cada arquivo externo é um input separado; usa-se TID 0 (faixa única por arquivo).
for track in &project.tracks {
let (path, offset, drift_scale, language, title, is_default) = match track {
Track::Audio(t) => (
&t.path,
t.offset,
t.drift_scale,
t.language.as_str(),
t.title.as_str(),
t.is_default,
),
Track::Subtitle(t) => (
&t.path,
t.offset,
t.drift_scale,
t.language.as_str(),
t.title.as_str(),
t.is_default,
),
};
// TID 0: arquivos externos têm apenas uma faixa
let needs_sync = offset.as_ms() != 0 || (drift_scale - 1.0).abs() > 1e-9;
if needs_sync {
let (num, den) = scale_to_rational(drift_scale);
args.push("--sync".to_string());
args.push(format!("0:{},{}/{}", offset.as_ms(), num, den));
}
args.push("--language".to_string());
args.push(format!("0:{}", language));
if !title.is_empty() {
args.push("--track-name".to_string());
args.push(format!("0:{}", title));
}
args.push("--default-track".to_string());
args.push(format!("0:{}", if is_default { "yes" } else { "no" }));
args.push(path.to_string());
}
args
}
}
/// Converte um fator de escala f64 em fração racional irredutível (numerador, denominador).
///
/// Precisão de 1 em 1_000_000 — suficiente para todos os casos práticos de drift.
/// Exemplos:
/// - `1.0` → `(1, 1)`
/// - `0.99983` → `(99983, 100000)`
/// - `24/23.976` → fração reduzida válida
pub 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)
}
fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
let t = b;
b = a % b;
a = t;
}
a
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::use_cases::add_audio_track::AddAudioTrack;
use crate::domain::entities::{MediaTrackInfo, MkvOutput, TrackKind, VideoFile};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
fn base_project() -> Project {
Project::new(
VideoFile::new(FilePath::from("source.mkv")),
MkvOutput::new(FilePath::from("output.mkv")),
)
.unwrap()
}
// ── scale_to_rational ──────────────────────────────────────────────────────
#[test]
fn scale_to_rational_identidade() {
assert_eq!(scale_to_rational(1.0), (1, 1));
}
#[test]
fn scale_to_rational_0_99983() {
assert_eq!(scale_to_rational(0.99983), (99983, 100000));
}
#[test]
fn scale_to_rational_24_sobre_23976() {
// 24 / 23.976 ≈ 1.001001...
let scale = 24.0 / 23.976;
let (n, d) = scale_to_rational(scale);
// Verifica que a fração representa a escala com precisão ≤ 1 em 10^5
let recovered = n as f64 / d as f64;
assert!((recovered - scale).abs() < 1e-5, "n={}, d={}, scale={}", n, d, scale);
// Verifica que é irredutível (GCD = 1)
assert_eq!(gcd(n, d), 1, "Fração não reduzida: {}/{}", n, d);
}
// ── output primeiro ───────────────────────────────────────────────────────
#[test]
fn output_e_o_primeiro_argumento() {
let project = base_project();
let args = MkvmergeCommandBuilder::build(&project);
assert_eq!(args[0], "-o");
assert_eq!(args[1], "output.mkv");
}
// ── faixas existentes ─────────────────────────────────────────────────────
#[test]
fn sync_scale_1_offset_zero_nao_emite_sync() {
let mut project = base_project();
let mut info = MediaTrackInfo::new(
crate::domain::value_objects::TrackId::new(1),
TrackKind::Audio,
"aac",
None,
1,
);
info.drift_scale = 1.0; // identidade
project.existing_tracks.push(info);
let args = MkvmergeCommandBuilder::build(&project);
assert!(!args.contains(&"--sync".to_string()), "não deve emitir --sync para identidade");
}
#[test]
fn sync_offset_500_scale_1_emite_sync_correto() {
let mut project = base_project();
let mut info = MediaTrackInfo::new(
crate::domain::value_objects::TrackId::new(1),
TrackKind::Audio,
"aac",
None,
1,
);
info.offset = SyncOffset::from_ms(500);
info.drift_scale = 1.0;
project.existing_tracks.push(info);
let args = MkvmergeCommandBuilder::build(&project);
let pos = args.iter().position(|a| a == "--sync").expect("missing --sync");
assert_eq!(args[pos + 1], "1:500,1/1");
}
#[test]
fn sync_offset_zero_scale_0_99983_emite_sync_correto() {
let mut project = base_project();
let mut info = MediaTrackInfo::new(
crate::domain::value_objects::TrackId::new(1),
TrackKind::Audio,
"aac",
None,
1,
);
info.offset = SyncOffset::from_ms(0);
info.drift_scale = 0.99983;
project.existing_tracks.push(info);
let args = MkvmergeCommandBuilder::build(&project);
let pos = args.iter().position(|a| a == "--sync").expect("missing --sync");
assert_eq!(args[pos + 1], "1:0,99983/100000");
}
// ── arquivo fonte ─────────────────────────────────────────────────────────
#[test]
fn fonte_aparece_apos_opcoes_das_faixas_existentes() {
let mut project = base_project();
let mut info = MediaTrackInfo::new(
crate::domain::value_objects::TrackId::new(1),
TrackKind::Audio,
"aac",
Some(TrackLanguage::new("por").unwrap()),
1,
);
info.offset = SyncOffset::from_ms(500);
project.existing_tracks.push(info);
let args = MkvmergeCommandBuilder::build(&project);
let sync_pos = args.iter().position(|a| a == "--sync").unwrap();
let src_pos = args.iter().position(|a| a == "source.mkv").unwrap();
assert!(src_pos > sync_pos, "source.mkv deve vir depois das opções de sync");
}
// ── faixas externas ───────────────────────────────────────────────────────
#[test]
fn faixa_externa_sem_drift_emite_language_e_default() {
let mut project = base_project();
let lang = TrackLanguage::new("por").unwrap();
AddAudioTrack::execute(
&mut project,
FilePath::from("audio.aac"),
SyncOffset::default(),
lang,
false,
String::new(),
1.0,
)
.unwrap();
let args = MkvmergeCommandBuilder::build(&project);
assert!(args.contains(&"--language".to_string()));
assert!(args.contains(&"0:por".to_string()));
assert!(args.contains(&"audio.aac".to_string()));
}
#[test]
fn faixa_externa_com_drift_emite_sync() {
let mut project = base_project();
let lang = TrackLanguage::new("por").unwrap();
AddAudioTrack::execute(
&mut project,
FilePath::from("audio.aac"),
SyncOffset::from_ms(1200),
lang,
false,
String::new(),
0.99983,
)
.unwrap();
let args = MkvmergeCommandBuilder::build(&project);
let pos = args.iter().position(|a| a == "--sync").expect("missing --sync");
assert_eq!(args[pos + 1], "0:1200,99983/100000");
}
}
+34
View File
@@ -0,0 +1,34 @@
use anyhow::{anyhow, Context, Result};
use std::process::Command;
use crate::application::ports::ContainerMuxPort;
/// Implementa `ContainerMuxPort` executando o binário `mkvmerge` (MKVToolNix).
///
/// Usado quando o projeto possui correção de drift (`drift_scale ≠ 1.0`).
pub struct MkvmergeGateway;
impl ContainerMuxPort for MkvmergeGateway {
fn execute(&self, args: Vec<String>) -> Result<()> {
let output = Command::new("mkvmerge")
.args(&args)
.output()
.context("Falha ao executar mkvmerge. Verifique se o MKVToolNix está instalado e no PATH.")?;
// mkvmerge retorna exit code 0 (sucesso) ou 1 (warnings) — ambos aceitáveis.
// Apenas exit code 2 (erros) é tratado como falha.
let exit_code = output.status.code().unwrap_or(2);
if exit_code >= 2 {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(anyhow!(
"mkvmerge encerrou com erro (código {}):\n{}\n{}",
exit_code,
stdout,
stderr
));
}
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod command_builder;
pub mod mkvmerge_gateway;
pub use command_builder::MkvmergeCommandBuilder;
pub use mkvmerge_gateway::MkvmergeGateway;
+1
View File
@@ -1,2 +1,3 @@
pub mod ffmpeg;
pub mod filesystem;
pub mod mkvmerge;
+7
View File
@@ -12,6 +12,13 @@ pub trait MediaProcessorPort {
fn execute(&self, args: Vec<String>) -> Result<()>;
}
/// Port para muxing com suporte a escala temporal (implementado pelo MkvmergeGateway).
/// Estruturalmente idêntico ao `MediaProcessorPort` — separado por semântica, não por interface.
#[allow(dead_code)]
pub trait ContainerMuxPort {
fn execute(&self, args: Vec<String>) -> Result<()>;
}
/// Port para abstrair acesso ao sistema de arquivos.
#[allow(dead_code)]
pub trait FileSystemPort {
+3 -1
View File
@@ -13,9 +13,10 @@ impl AddAudioTrack {
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
) -> Result<()> {
let id = project.next_track_id();
let track = AudioTrack::new(id, path, offset, language, is_default, title);
let track = AudioTrack::new(id, path, offset, language, is_default, title, drift_scale);
project.tracks.push(Track::Audio(track));
Ok(())
}
@@ -43,6 +44,7 @@ mod tests {
lang,
false,
String::new(),
1.0,
)
.unwrap();
+3 -1
View File
@@ -13,9 +13,10 @@ impl AddSubtitle {
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
) -> Result<()> {
let id = project.next_track_id();
let track = SubtitleTrack::new(id, path, offset, language, is_default, title);
let track = SubtitleTrack::new(id, path, offset, language, is_default, title, drift_scale);
project.tracks.push(Track::Subtitle(track));
Ok(())
}
@@ -42,6 +43,7 @@ mod tests {
lang,
false,
String::new(),
1.0,
)
.unwrap();
+85
View File
@@ -0,0 +1,85 @@
use crate::domain::entities::Project;
use crate::domain::value_objects::TrackId;
use anyhow::{Result, anyhow};
/// Ajusta o fator de escala temporal (drift) de uma faixa externa pelo TrackId.
///
/// Um `scale != 1.0` indica que o projeto precisará usar o pipeline mkvmerge.
#[allow(dead_code)]
pub struct AdjustDrift;
impl AdjustDrift {
#[allow(dead_code)]
pub fn execute(project: &mut Project, id: TrackId, scale: f64) -> Result<()> {
if scale <= 0.0 {
return Err(anyhow!("O fator de escala deve ser maior que zero: {}", scale));
}
let track = project
.find_track_mut(id)
.ok_or_else(|| anyhow!("Faixa não encontrada: {:?}", id))?;
track.set_drift_scale(scale);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::use_cases::add_audio_track::AddAudioTrack;
use crate::domain::entities::{MkvOutput, VideoFile};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
fn make_project() -> Project {
Project::new(
VideoFile::new(FilePath::from("input.mkv")),
MkvOutput::new(FilePath::from("output.mkv")),
)
.unwrap()
}
#[test]
fn ajusta_drift_existente() {
let mut p = make_project();
let lang = TrackLanguage::new("por").unwrap();
AddAudioTrack::execute(
&mut p,
FilePath::from("audio.aac"),
SyncOffset::default(),
lang,
false,
String::new(),
1.0,
)
.unwrap();
let id = p.tracks[0].id();
AdjustDrift::execute(&mut p, id, 0.99983).unwrap();
assert!((p.tracks[0].drift_scale() - 0.99983).abs() < 1e-9);
}
#[test]
fn erro_se_id_inexistente() {
let mut p = make_project();
let result = AdjustDrift::execute(&mut p, TrackId::new(99), 0.99983);
assert!(result.is_err());
}
#[test]
fn erro_se_scale_zero() {
let mut p = make_project();
let lang = TrackLanguage::new("por").unwrap();
AddAudioTrack::execute(
&mut p,
FilePath::from("audio.aac"),
SyncOffset::default(),
lang,
false,
String::new(),
1.0,
)
.unwrap();
let id = p.tracks[0].id();
let result = AdjustDrift::execute(&mut p, id, 0.0);
assert!(result.is_err());
}
}
@@ -0,0 +1,77 @@
use crate::domain::entities::Project;
use crate::domain::value_objects::TrackId;
use anyhow::{Result, anyhow};
/// Ajusta o fator de escala temporal (drift) de uma faixa já presente no arquivo original.
///
/// Opera sobre `project.existing_tracks` (faixas lidas via ffprobe).
/// Um `scale != 1.0` indica que o projeto precisará usar o pipeline mkvmerge.
#[allow(dead_code)]
pub struct AdjustExistingTrackDrift;
impl AdjustExistingTrackDrift {
#[allow(dead_code)]
pub fn execute(project: &mut Project, id: TrackId, scale: f64) -> Result<()> {
if scale <= 0.0 {
return Err(anyhow!("O fator de escala deve ser maior que zero: {}", scale));
}
let track = project
.find_existing_track_mut(id)
.ok_or_else(|| anyhow!("Faixa existente não encontrada: {:?}", id))?;
track.drift_scale = scale;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entities::{MediaTrackInfo, MkvOutput, TrackKind, VideoFile};
use crate::domain::value_objects::{FilePath, TrackId};
fn make_project() -> Project {
Project::new(
VideoFile::new(FilePath::from("input.mkv")),
MkvOutput::new(FilePath::from("output.mkv")),
)
.unwrap()
}
#[test]
fn ajusta_drift_em_faixa_existente() {
let mut p = make_project();
let id = TrackId::new(1);
p.existing_tracks.push(MediaTrackInfo::new(
id,
TrackKind::Audio,
"aac",
None,
0,
));
AdjustExistingTrackDrift::execute(&mut p, id, 0.99983).unwrap();
assert!((p.existing_tracks[0].drift_scale - 0.99983).abs() < 1e-9);
}
#[test]
fn erro_se_id_inexistente() {
let mut p = make_project();
let result = AdjustExistingTrackDrift::execute(&mut p, TrackId::new(99), 0.99983);
assert!(result.is_err());
}
#[test]
fn erro_se_scale_zero() {
let mut p = make_project();
let id = TrackId::new(1);
p.existing_tracks.push(MediaTrackInfo::new(
id,
TrackKind::Audio,
"aac",
None,
0,
));
let result = AdjustExistingTrackDrift::execute(&mut p, id, 0.0);
assert!(result.is_err());
}
}
+1
View File
@@ -39,6 +39,7 @@ mod tests {
lang,
false,
String::new(),
1.0,
)
.unwrap();
let id = project.tracks[0].id();
@@ -51,6 +51,7 @@ mod tests {
TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
)
.unwrap();
+2
View File
@@ -1,5 +1,7 @@
pub mod add_audio_track;
pub mod add_subtitle;
pub mod adjust_drift;
pub mod adjust_existing_track_drift;
pub mod adjust_sync;
pub mod edit_existing_track_sync;
pub mod export_track;
@@ -40,6 +40,7 @@ mod tests {
TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
)));
assert_eq!(project.tracks.len(), 1);
@@ -38,6 +38,7 @@ mod tests {
TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
)
.unwrap();
let id = project.tracks[0].id();
+5
View File
@@ -15,6 +15,9 @@ pub struct AudioTrack {
/// String vazia emite `title=` para apagar o título herdado do arquivo fonte
/// (ex: "ISO Media file produced by Google Inc.").
pub title: String,
/// Fator de escala temporal para correção de drift. `1.0` = sem correção.
/// Valor diferente de 1.0 força o uso de mkvmerge em vez de FFmpeg.
pub drift_scale: f64,
}
impl AudioTrack {
@@ -25,6 +28,7 @@ impl AudioTrack {
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
) -> Self {
AudioTrack {
id,
@@ -33,6 +37,7 @@ impl AudioTrack {
language,
is_default,
title,
drift_scale,
}
}
}
+12
View File
@@ -32,6 +32,15 @@ pub struct MediaTrackInfo {
pub offset: SyncOffset,
/// Índice do stream no container original (usado internamente pelo adapter).
pub stream_index: u32,
/// Fator de escala temporal para correção de drift. `1.0` = sem correção.
/// Campo mutável diretamente; construtor sempre parte de 1.0.
pub drift_scale: f64,
/// Duração da faixa em milissegundos (lida via ffprobe). `None` se não disponível.
pub duration_ms: Option<u64>,
/// Quando `true`, a faixa é omitida do arquivo de saída (não é mapeada).
/// A faixa permanece visível na UI para que o usuário possa reverter a decisão.
#[serde(default)]
pub excluded: bool,
}
impl MediaTrackInfo {
@@ -49,6 +58,9 @@ impl MediaTrackInfo {
language,
offset: SyncOffset::default(),
stream_index,
drift_scale: 1.0,
duration_ms: None,
excluded: false,
}
}
}
+174 -1
View File
@@ -49,18 +49,57 @@ impl Project {
self.existing_tracks.iter_mut().find(|t| t.id == id)
}
/// Alterna o estado `excluded` de uma faixa existente pelo TrackId.
/// Retorna `true` se a faixa foi encontrada. Faixas de vídeo são ignoradas.
pub fn toggle_existing_track_excluded(&mut self, id: TrackId) -> bool {
use crate::domain::entities::TrackKind;
match self.find_existing_track_mut(id) {
Some(t) if !matches!(t.kind, TrackKind::Video) => {
t.excluded = !t.excluded;
true
}
_ => false,
}
}
/// Remove uma faixa externa pelo TrackId. Retorna true se a faixa foi encontrada e removida.
pub fn remove_track(&mut self, id: TrackId) -> bool {
let before = self.tracks.len();
self.tracks.retain(|t| t.id() != id);
self.tracks.len() < before
}
/// Move uma faixa externa para cima (`delta = -1`) ou para baixo (`delta = 1`).
/// Retorna `true` se a faixa foi encontrada e o movimento era possível.
pub fn move_track(&mut self, id: TrackId, delta: i8) -> bool {
let Some(pos) = self.tracks.iter().position(|t| t.id() == id) else {
return false;
};
let new_pos = pos as i64 + delta as i64;
if new_pos < 0 || new_pos >= self.tracks.len() as i64 {
return false;
}
self.tracks.swap(pos, new_pos as usize);
true
}
/// Retorna `true` se qualquer faixa (externa ou existente) possui `drift_scale != 1.0`,
/// indicando que o projeto deve usar o pipeline mkvmerge em vez do FFmpeg.
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))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::value_objects::FilePath;
use crate::domain::entities::{AudioTrack, Track};
use crate::domain::value_objects::{FilePath, SyncOffset, SyncTransform, TrackLanguage};
fn make_project(src: &str, out: &str) -> Result<Project> {
Project::new(
@@ -69,6 +108,18 @@ mod tests {
)
}
fn audio_track_with_drift(id: u32, drift: f64) -> Track {
Track::Audio(AudioTrack::new(
TrackId::new(id),
FilePath::from("audio.aac"),
SyncOffset::default(),
TrackLanguage::new("por").unwrap(),
false,
String::new(),
drift,
))
}
#[test]
fn projeto_valido() {
assert!(make_project("input.mkv", "output.mkv").is_ok());
@@ -86,4 +137,126 @@ mod tests {
let id2 = id;
assert_eq!(id, id2);
}
#[test]
fn needs_mkvmerge_false_sem_drift() {
let mut p = make_project("input.mkv", "output.mkv").unwrap();
p.tracks.push(audio_track_with_drift(1, 1.0));
assert!(!p.needs_mkvmerge());
}
#[test]
fn needs_mkvmerge_true_com_drift_na_faixa_externa() {
let mut p = make_project("input.mkv", "output.mkv").unwrap();
p.tracks.push(audio_track_with_drift(1, 0.99983));
assert!(p.needs_mkvmerge());
}
#[test]
fn needs_mkvmerge_true_com_drift_na_faixa_existente() {
let mut p = make_project("input.mkv", "output.mkv").unwrap();
let mut info = MediaTrackInfo::new(
TrackId::new(1),
crate::domain::entities::TrackKind::Audio,
"aac",
None,
0,
);
info.drift_scale = 0.99983;
p.existing_tracks.push(info);
assert!(p.needs_mkvmerge());
}
#[test]
fn sync_transform_default_e_identidade() {
let t = SyncTransform::default();
assert!(t.is_identity());
assert!(!t.has_drift());
}
#[test]
fn sync_transform_has_drift_false() {
assert!(!SyncTransform::new(0, 1.0).has_drift());
}
#[test]
fn sync_transform_has_drift_true() {
assert!(SyncTransform::new(0, 0.99983).has_drift());
}
#[test]
fn toggle_excluded_alterna_estado() {
use crate::domain::entities::TrackKind;
let mut p = make_project("input.mkv", "output.mkv").unwrap();
let id = TrackId::new(10);
p.existing_tracks.push(MediaTrackInfo::new(id, TrackKind::Audio, "aac", None, 1));
assert!(!p.existing_tracks[0].excluded);
assert!(p.toggle_existing_track_excluded(id));
assert!(p.existing_tracks[0].excluded);
assert!(p.toggle_existing_track_excluded(id));
assert!(!p.existing_tracks[0].excluded);
}
#[test]
fn toggle_excluded_ignora_faixa_de_video() {
use crate::domain::entities::TrackKind;
let mut p = make_project("input.mkv", "output.mkv").unwrap();
let id = TrackId::new(20);
p.existing_tracks.push(MediaTrackInfo::new(id, TrackKind::Video, "h264", None, 0));
// retorna false: faixa de vídeo não pode ser excluída
assert!(!p.toggle_existing_track_excluded(id));
assert!(!p.existing_tracks[0].excluded);
}
#[test]
fn move_track_para_cima() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let t2 = audio_track_with_drift(2, 1.0);
let id1 = t1.id();
let id2 = t2.id();
p.tracks.push(t1);
p.tracks.push(t2);
assert!(p.move_track(id2, -1));
assert_eq!(p.tracks[0].id(), id2);
assert_eq!(p.tracks[1].id(), id1);
}
#[test]
fn move_track_para_baixo() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let t2 = audio_track_with_drift(2, 1.0);
let id1 = t1.id();
let id2 = t2.id();
p.tracks.push(t1);
p.tracks.push(t2);
assert!(p.move_track(id1, 1));
assert_eq!(p.tracks[0].id(), id2);
assert_eq!(p.tracks[1].id(), id1);
}
#[test]
fn move_track_limite_superior_ignorado() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let id1 = t1.id();
p.tracks.push(t1);
assert!(!p.move_track(id1, -1)); // já é o primeiro — não move
}
#[test]
fn move_track_limite_inferior_ignorado() {
let mut p = make_project("i.mkv", "o.mkv").unwrap();
let t1 = audio_track_with_drift(1, 1.0);
let id1 = t1.id();
p.tracks.push(t1);
assert!(!p.move_track(id1, 1)); // já é o último — não move
}
}
+5
View File
@@ -13,6 +13,9 @@ pub struct SubtitleTrack {
/// Título exibido na lista de faixas do player (ex: "Português", "Forçada").
/// String vazia emite `title=` para apagar o título herdado do arquivo fonte.
pub title: String,
/// Fator de escala temporal para correção de drift. `1.0` = sem correção.
/// Valor diferente de 1.0 força o uso de mkvmerge em vez de FFmpeg.
pub drift_scale: f64,
}
impl SubtitleTrack {
@@ -23,6 +26,7 @@ impl SubtitleTrack {
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
) -> Self {
SubtitleTrack {
id,
@@ -31,6 +35,7 @@ impl SubtitleTrack {
language,
is_default,
title,
drift_scale,
}
}
}
+16
View File
@@ -47,4 +47,20 @@ impl Track {
Track::Subtitle(t) => t.language = language,
}
}
/// Retorna o fator de escala temporal da faixa.
pub fn drift_scale(&self) -> f64 {
match self {
Track::Audio(t) => t.drift_scale,
Track::Subtitle(t) => t.drift_scale,
}
}
/// Define o fator de escala temporal da faixa.
pub fn set_drift_scale(&mut self, scale: f64) {
match self {
Track::Audio(t) => t.drift_scale = scale,
Track::Subtitle(t) => t.drift_scale = scale,
}
}
}
+2
View File
@@ -1,9 +1,11 @@
pub mod file_path;
pub mod sync_offset;
pub mod sync_transform;
pub mod track_id;
pub mod track_language;
pub use file_path::FilePath;
pub use sync_offset::SyncOffset;
pub use sync_transform::SyncTransform;
pub use track_id::TrackId;
pub use track_language::TrackLanguage;
@@ -0,0 +1,96 @@
use serde::{Deserialize, Serialize};
use super::SyncOffset;
/// Transformação temporal completa de uma faixa: deslocamento constante + fator de escala.
///
/// - `offset_ms`: deslocamento em milissegundos (mesmo semântico de `SyncOffset`).
/// - `scale`: fator de escala temporal. `1.0` = identidade. `0.99983` ≈ correção 25 fps → 24 fps.
///
/// Quando `scale == 1.0`, o pipeline padrão FFmpeg é utilizado.
/// Quando `scale != 1.0`, o projeto precisa passar pelo pipeline mkvmerge.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SyncTransform {
pub offset_ms: i64,
pub scale: f64,
}
impl SyncTransform {
/// Cria uma transformação com deslocamento e escala explícitos.
pub fn new(offset_ms: i64, scale: f64) -> Self {
SyncTransform { offset_ms, scale }
}
/// Cria uma transformação a partir de um `SyncOffset` sem drift (escala = 1.0).
pub fn from_offset(offset: SyncOffset) -> Self {
SyncTransform {
offset_ms: offset.as_ms(),
scale: 1.0,
}
}
/// Retorna `true` se o fator de escala difere de 1.0 por mais de 1e-9.
pub fn has_drift(&self) -> bool {
(self.scale - 1.0).abs() > 1e-9
}
/// Retorna `true` se a transformação é identidade (offset == 0 e sem drift).
pub fn is_identity(&self) -> bool {
self.offset_ms == 0 && !self.has_drift()
}
/// Converte para `SyncOffset` descartando a escala (compatibilidade com FFmpeg path).
pub fn to_sync_offset(&self) -> SyncOffset {
SyncOffset::from_ms(self.offset_ms)
}
}
impl Default for SyncTransform {
/// Identidade: sem deslocamento, sem drift.
fn default() -> Self {
SyncTransform {
offset_ms: 0,
scale: 1.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_e_identidade() {
let t = SyncTransform::default();
assert!(t.is_identity());
assert!(!t.has_drift());
assert_eq!(t.offset_ms, 0);
assert_eq!(t.scale, 1.0);
}
#[test]
fn has_drift_false_scale_1() {
let t = SyncTransform::new(500, 1.0);
assert!(!t.has_drift());
}
#[test]
fn has_drift_true_scale_0_99983() {
let t = SyncTransform::new(0, 0.99983);
assert!(t.has_drift());
}
#[test]
fn from_offset_sem_drift() {
let offset = SyncOffset::from_ms(1200);
let t = SyncTransform::from_offset(offset);
assert_eq!(t.offset_ms, 1200);
assert!(!t.has_drift());
}
#[test]
fn to_sync_offset_descarta_escala() {
let t = SyncTransform::new(-500, 0.99983);
assert_eq!(t.to_sync_offset().as_ms(), -500);
}
}
+73
View File
@@ -71,3 +71,76 @@ pub fn validate_dependencies() -> Result<()> {
check_binary_available("ffprobe")?;
Ok(())
}
/// Verifica se `mkvmerge` está disponível no PATH (verificação soft — não bloqueia o app).
pub fn mkvmerge_available() -> bool {
std::process::Command::new("mkvmerge")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Executa o mkvmerge de forma assíncrona, capturando stdout + stderr em tempo real.
///
/// Diferença em relação ao FFmpeg: o mkvmerge escreve progresso em **stdout**,
/// por isso ambos são capturados e encaminhados ao `progress_tx`.
///
/// Códigos de saída: 0 = sucesso, 1 = avisos (aceitável), 2 = erro (falha).
pub async fn run_mkvmerge_async(
args: Vec<String>,
progress_tx: Sender<String>,
cancel_rx: tokio::sync::oneshot::Receiver<()>,
) -> Result<()> {
let mut child = Command::new("mkvmerge")
.args(&args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Falha ao iniciar mkvmerge. Verifique se o MKVToolNix está instalado.")?;
// Captura stdout (progresso)
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
let tx = progress_tx.clone();
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
let _ = tx.send(line);
}
});
}
// Captura stderr (erros e avisos)
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
let tx = progress_tx.clone();
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
let _ = tx.send(line);
}
});
}
tokio::select! {
result = child.wait() => {
let status = result.context("Erro ao aguardar mkvmerge")?;
let code = status.code().unwrap_or(2);
if code >= 2 {
return Err(anyhow!(
"mkvmerge encerrou com código de saída: {} (erro)",
code
));
}
Ok(())
}
_ = cancel_rx => {
let _ = child.kill().await;
Err(anyhow!("Cancelado pelo usuário"))
}
}
}
+87 -17
View File
@@ -1,13 +1,15 @@
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
use crate::adapters::mkvmerge::MkvmergeCommandBuilder;
use crate::adapters::filesystem::file_picker::FilePickerAdapter;
use crate::application::use_cases::{
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
adjust_existing_track_drift::AdjustExistingTrackDrift,
edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack,
load_media_info::LoadMediaInfo, remove_track::RemoveTrack,
};
use crate::domain::entities::{MkvOutput, Project, VideoFile};
use crate::domain::entities::{MkvOutput, Project, TrackKind, VideoFile};
use crate::domain::value_objects::FilePath;
use crate::infrastructure::process::run_ffmpeg_async;
use crate::infrastructure::process::{run_ffmpeg_async, run_mkvmerge_async};
use crate::ui::components::{
add_audio_track_form::AddAudioTrackForm,
add_subtitle_form::AddSubtitleForm,
@@ -70,6 +72,8 @@ pub struct App {
batch_panel: BatchPanel,
// Índice do item sendo processado no lote (None = nenhum)
batch_processing_index: Option<usize>,
// mkvmerge disponível no PATH (verificação soft na inicialização)
mkvmerge_available: bool,
}
impl App {
@@ -109,6 +113,7 @@ impl App {
batch_items: Vec::new(),
batch_panel: BatchPanel::new(),
batch_processing_index: None,
mkvmerge_available: crate::infrastructure::process::mkvmerge_available(),
}
}
@@ -130,6 +135,12 @@ impl App {
Ok(_) => {
self.existing_track_list
.sync_tracks(&project.existing_tracks);
// Propaga duração do vídeo para os formulários (sugestão de velocidade)
let video_dur = project.existing_tracks.iter()
.find(|t| matches!(t.kind, TrackKind::Video))
.and_then(|t| t.duration_ms);
self.add_audio_form.set_video_duration(video_dur);
self.add_subtitle_form.set_video_duration(video_dur);
}
Err(e) => {
self.execution_panel.state = ExecutionState::Error(e.to_string());
@@ -221,13 +232,32 @@ impl App {
}
/// Inicia geração do MKV em background thread com progresso em tempo real.
/// Despacha para FFmpeg ou mkvmerge dependendo de `project.needs_mkvmerge()`.
fn start_generation(&mut self, ctx: egui::Context) {
let project = match &self.project {
Some(p) => p.clone(),
None => return,
};
let args = FfmpegCommandBuilder::build(&project);
let uses_mkvmerge = project.needs_mkvmerge();
let args = if uses_mkvmerge {
MkvmergeCommandBuilder::build(&project)
} else {
FfmpegCommandBuilder::build(&project)
};
// Formatar comando legível para o painel de debug
let binary = if uses_mkvmerge { "mkvmerge" } else { "ffmpeg" };
let cmd_str = format!(
"{} {}",
binary,
args.iter()
.map(|a| if a.contains(' ') { format!("\"{}\"", a) } else { a.clone() })
.collect::<Vec<_>>()
.join(" ")
);
self.execution_panel.last_command = Some(cmd_str);
let (tx, rx) = mpsc::channel::<BackgroundMsg>();
self.bg_rx = Some(rx);
self.execution_panel.state = ExecutionState::Running;
@@ -237,7 +267,7 @@ impl App {
self.cancel_tx = Some(cancel_tx);
std::thread::spawn(move || {
// Canal std para receber linhas de log do run_ffmpeg_async
// Canal std para receber linhas de log
let (log_tx, log_rx) = std::sync::mpsc::channel::<String>();
// Thread auxiliar: encaminha cada linha de log para o canal da UI
@@ -251,10 +281,12 @@ impl App {
});
let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio");
let result = rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx));
let result = if uses_mkvmerge {
rt.block_on(run_mkvmerge_async(args, log_tx, cancel_rx))
} else {
rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx))
};
// Aguarda o encaminhador consumir todas as linhas pendentes
// antes de enviar Done/Error, garantindo ordem correta no log
let _ = fwd_handle.join();
match result {
@@ -279,13 +311,32 @@ impl App {
}
/// Inicia o processamento de um item específico do carrinho de lote.
/// Despacha para FFmpeg ou mkvmerge dependendo de `project.needs_mkvmerge()`.
fn start_batch_item(&mut self, index: usize, ctx: egui::Context) {
let project = self.batch_items[index].project.clone();
self.batch_items[index].state = ExecutionState::Running;
self.batch_items[index].log_lines.clear();
self.batch_processing_index = Some(index);
let args = FfmpegCommandBuilder::build(&project);
let uses_mkvmerge = project.needs_mkvmerge();
let args = if uses_mkvmerge {
MkvmergeCommandBuilder::build(&project)
} else {
FfmpegCommandBuilder::build(&project)
};
// Formatar comando legível para o painel de debug do item de lote
let binary = if uses_mkvmerge { "mkvmerge" } else { "ffmpeg" };
let cmd_str = format!(
"{} {}",
binary,
args.iter()
.map(|a| if a.contains(' ') { format!("\"{}\"", a) } else { a.clone() })
.collect::<Vec<_>>()
.join(" ")
);
self.batch_items[index].last_command = Some(cmd_str);
let (tx, rx) = mpsc::channel::<BackgroundMsg>();
self.bg_rx = Some(rx);
@@ -305,7 +356,11 @@ impl App {
});
let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio");
let result = rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx));
let result = if uses_mkvmerge {
rt.block_on(run_mkvmerge_async(args, log_tx, cancel_rx))
} else {
rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx))
};
let _ = fwd_handle.join();
match result {
@@ -577,7 +632,7 @@ impl eframe::App for App {
.auto_shrink([false, false])
.show(ui, |ui| {
let is_processing = self.batch_processing_index.is_some();
let events = self.batch_panel.ui(ui, &self.batch_items, is_processing);
let events = self.batch_panel.ui(ui, &self.batch_items, is_processing, self.mkvmerge_available);
for event in events {
match event {
BatchPanelEvent::AddItem(mut project) => {
@@ -621,7 +676,8 @@ impl eframe::App for App {
.show(ui, |ui| {
if let Some(project) = &mut self.project {
// ── Faixas existentes ─────────────────────────────────
let events = self.existing_track_list.ui(ui, &project.existing_tracks);
let mkvmerge_available = self.mkvmerge_available;
let events = self.existing_track_list.ui(ui, &project.existing_tracks, mkvmerge_available);
for event in events {
use crate::domain::entities::TrackKind;
use crate::ui::components::existing_track_list::ExistingTrackEvent;
@@ -629,6 +685,12 @@ impl eframe::App for App {
ExistingTrackEvent::OffsetChanged(id, offset) => {
let _ = EditExistingTrackSync::execute(project, id, offset);
}
ExistingTrackEvent::DriftChanged(id, scale) => {
let _ = AdjustExistingTrackDrift::execute(project, id, scale);
}
ExistingTrackEvent::ExcludeToggled(id) => {
project.toggle_existing_track_excluded(id);
}
ExistingTrackEvent::ExportRequested(id) => {
if let Some(track) =
project.existing_tracks.iter().find(|t| t.id == id).cloned()
@@ -697,7 +759,7 @@ impl eframe::App for App {
.allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| self.add_audio_form.ui(ui),
|ui| self.add_audio_form.ui(ui, mkvmerge_available),
)
.inner;
@@ -705,15 +767,15 @@ impl eframe::App for App {
.allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| self.add_subtitle_form.ui(ui),
|ui| self.add_subtitle_form.ui(ui, mkvmerge_available),
)
.inner;
(audio_req, subtitle_req)
} else {
let ar = self.add_audio_form.ui(ui);
let ar = self.add_audio_form.ui(ui, mkvmerge_available);
ui.add_space(4.0);
let sr = self.add_subtitle_form.ui(ui);
let sr = self.add_subtitle_form.ui(ui, mkvmerge_available);
(ar, sr)
};
@@ -725,6 +787,7 @@ impl eframe::App for App {
req.language,
req.is_default,
req.title,
req.drift_scale,
);
}
if let Some(req) = subtitle_req {
@@ -735,19 +798,26 @@ impl eframe::App for App {
req.language,
req.is_default,
req.title,
req.drift_scale,
);
}
ui.add_space(8.0);
// ── Faixas externas adicionadas ───────────────────────
let remove_events = self.added_track_list.ui(ui, &project.tracks);
for event in remove_events {
let track_events = self.added_track_list.ui(ui, &project.tracks);
for event in track_events {
use crate::ui::components::added_track_list::AddedTrackEvent;
match event {
AddedTrackEvent::RemoveRequested(id) => {
let _ = RemoveTrack::execute(project, id);
}
AddedTrackEvent::MoveUp(id) => {
project.move_track(id, -1);
}
AddedTrackEvent::MoveDown(id) => {
project.move_track(id, 1);
}
}
}
+40 -2
View File
@@ -1,4 +1,7 @@
use crate::adapters::ffmpeg::{FfprobeGateway};
use crate::adapters::filesystem::FilePickerAdapter;
use crate::application::ports::MediaInfoPort;
use crate::domain::entities::TrackKind;
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui;
@@ -10,6 +13,8 @@ pub struct AddAudioTrackRequest {
pub language: TrackLanguage,
pub is_default: bool,
pub title: String,
/// Fator de escala temporal (correção de drift). 1.0 = sem correção.
pub drift_scale: f64,
}
/// Formulário para adicionar uma faixa de áudio externa.
@@ -19,6 +24,10 @@ pub struct AddAudioTrackForm {
language_field: LanguageField,
is_default: bool,
title_input: String,
/// Duração do vídeo base (ms) — definida por App após carregar o projeto.
pub video_duration_ms: Option<u64>,
/// Duração da faixa de áudio selecionada (ms) — obtida via ffprobe ao escolher o arquivo.
selected_duration_ms: Option<u64>,
}
impl AddAudioTrackForm {
@@ -29,11 +38,27 @@ impl AddAudioTrackForm {
language_field: LanguageField::new(None),
is_default: false,
title_input: String::new(),
video_duration_ms: None,
selected_duration_ms: None,
}
}
/// Atualiza a duração do vídeo base e recalcula a sugestão de velocidade.
pub fn set_video_duration(&mut self, dur: Option<u64>) {
self.video_duration_ms = dur;
self.update_suggestion();
}
fn update_suggestion(&mut self) {
let suggestion = self.video_duration_ms
.zip(self.selected_duration_ms)
.filter(|(_, td)| *td > 0)
.map(|(vd, td)| vd as f64 / td as f64 * 100.0);
self.offset_field.set_suggestion(suggestion);
}
/// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<AddAudioTrackRequest> {
pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddAudioTrackRequest> {
let mut result = None;
ui.group(|ui| {
@@ -48,10 +73,19 @@ impl AddAudioTrackForm {
ui.label(&label);
if ui.button("Selecionar áudio…").clicked() {
self.selected_path = FilePickerAdapter::pick_audio();
// Proba a duração do arquivo selecionado para sugestão de velocidade
self.selected_duration_ms = self.selected_path.as_ref().and_then(|p| {
let tracks = FfprobeGateway.probe(p).ok()?;
tracks.iter()
.find(|t| matches!(t.kind, TrackKind::Audio) && t.duration_ms.is_some())
.or_else(|| tracks.iter().find(|t| t.duration_ms.is_some()))
.and_then(|t| t.duration_ms)
});
self.update_suggestion();
}
});
self.offset_field.ui(ui, "Atraso:");
self.offset_field.ui_with_drift(ui, "Atraso:", mkvmerge_available);
self.language_field.ui(ui);
ui.horizontal(|ui| {
@@ -82,16 +116,20 @@ impl AddAudioTrackForm {
self.offset_field.parse(),
self.language_field.parse(),
) {
let drift_scale = self.offset_field.parse_drift();
result = Some(AddAudioTrackRequest {
path,
offset,
language,
is_default: self.is_default,
title: self.title_input.trim().to_string(),
drift_scale,
});
// Reset form
self.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.selected_duration_ms = None;
self.update_suggestion();
self.language_field = LanguageField::new(None);
self.is_default = false;
self.title_input = String::new();
+40 -2
View File
@@ -1,4 +1,7 @@
use crate::adapters::ffmpeg::FfprobeGateway;
use crate::adapters::filesystem::FilePickerAdapter;
use crate::application::ports::MediaInfoPort;
use crate::domain::entities::TrackKind;
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui;
@@ -10,6 +13,8 @@ pub struct AddSubtitleRequest {
pub language: TrackLanguage,
pub is_default: bool,
pub title: String,
/// Fator de escala temporal (correção de drift). 1.0 = sem correção.
pub drift_scale: f64,
}
/// Formulário para adicionar uma faixa de legenda externa.
@@ -19,6 +24,10 @@ pub struct AddSubtitleForm {
language_field: LanguageField,
is_default: bool,
title_input: String,
/// Duração do vídeo base (ms) — definida por App após carregar o projeto.
pub video_duration_ms: Option<u64>,
/// Duração da legenda selecionada (ms) — obtida via ffprobe ao escolher o arquivo.
selected_duration_ms: Option<u64>,
}
impl AddSubtitleForm {
@@ -29,11 +38,27 @@ impl AddSubtitleForm {
language_field: LanguageField::new(None),
is_default: false,
title_input: String::new(),
video_duration_ms: None,
selected_duration_ms: None,
}
}
/// Atualiza a duração do vídeo base e recalcula a sugestão de velocidade.
pub fn set_video_duration(&mut self, dur: Option<u64>) {
self.video_duration_ms = dur;
self.update_suggestion();
}
fn update_suggestion(&mut self) {
let suggestion = self.video_duration_ms
.zip(self.selected_duration_ms)
.filter(|(_, td)| *td > 0)
.map(|(vd, td)| vd as f64 / td as f64 * 100.0);
self.offset_field.set_suggestion(suggestion);
}
/// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<AddSubtitleRequest> {
pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddSubtitleRequest> {
let mut result = None;
ui.group(|ui| {
@@ -48,10 +73,19 @@ impl AddSubtitleForm {
ui.label(&label);
if ui.button("Selecionar legenda…").clicked() {
self.selected_path = FilePickerAdapter::pick_subtitle();
// Proba a duração do arquivo selecionado para sugestão de velocidade
self.selected_duration_ms = self.selected_path.as_ref().and_then(|p| {
let tracks = FfprobeGateway.probe(p).ok()?;
tracks.iter()
.find(|t| matches!(t.kind, TrackKind::Subtitle) && t.duration_ms.is_some())
.or_else(|| tracks.iter().find(|t| t.duration_ms.is_some()))
.and_then(|t| t.duration_ms)
});
self.update_suggestion();
}
});
self.offset_field.ui(ui, "Atraso:");
self.offset_field.ui_with_drift(ui, "Atraso:", mkvmerge_available);
self.language_field.ui(ui);
ui.horizontal(|ui| {
@@ -81,15 +115,19 @@ impl AddSubtitleForm {
self.offset_field.parse(),
self.language_field.parse(),
) {
let drift_scale = self.offset_field.parse_drift();
result = Some(AddSubtitleRequest {
path,
offset,
language,
is_default: self.is_default,
title: self.title_input.trim().to_string(),
drift_scale,
});
self.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.selected_duration_ms = None;
self.update_suggestion();
self.language_field = LanguageField::new(None);
self.is_default = false;
self.title_input = String::new();
+29 -3
View File
@@ -5,6 +5,8 @@ use eframe::egui;
/// Evento emitido por interações com a lista de faixas externas adicionadas.
pub enum AddedTrackEvent {
RemoveRequested(TrackId),
MoveUp(TrackId),
MoveDown(TrackId),
}
/// Lista as faixas externas adicionadas pelo usuário e permite removê-las.
@@ -15,9 +17,10 @@ impl AddedTrackList {
AddedTrackList
}
/// Renderiza a lista. Retorna eventos de remoção.
/// Renderiza a lista. Retorna eventos de interação (remoção e reordenação).
pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> {
let mut events = Vec::new();
let total = tracks.len();
ui.group(|ui| {
ui.heading("Faixas adicionadas");
@@ -28,17 +31,18 @@ impl AddedTrackList {
}
egui::Grid::new("added_tracks_grid")
.num_columns(4)
.num_columns(5)
.max_col_width(200.0)
.striped(true)
.show(ui, |ui| {
ui.strong("Tipo");
ui.strong("Arquivo");
ui.strong("Idioma");
ui.strong("Ordem");
ui.strong("");
ui.end_row();
for track in tracks {
for (idx, track) in tracks.iter().enumerate() {
let (kind_label, file_name, language) = match track {
Track::Audio(t) => (
"Áudio",
@@ -67,6 +71,28 @@ impl AddedTrackList {
.on_hover_text(track_full_path(track));
ui.label(language);
// ── Botões de reordenação ──────────────────────────
ui.horizontal(|ui| {
ui.add_enabled_ui(idx > 0, |ui| {
if ui
.small_button("")
.on_hover_text("Mover para cima")
.clicked()
{
events.push(AddedTrackEvent::MoveUp(track.id()));
}
});
ui.add_enabled_ui(idx + 1 < total, |ui| {
if ui
.small_button("")
.on_hover_text("Mover para baixo")
.clicked()
{
events.push(AddedTrackEvent::MoveDown(track.id()));
}
});
});
if ui.small_button("🗑 Remover").clicked() {
events.push(AddedTrackEvent::RemoveRequested(track.id()));
}
+32 -7
View File
@@ -17,6 +17,8 @@ pub struct BatchItem {
pub project: Project,
pub state: ExecutionState,
pub log_lines: Vec<String>,
/// Comando completo enviado ao FFmpeg/mkvmerge na última geração deste item.
pub last_command: Option<String>,
}
impl BatchItem {
@@ -25,6 +27,7 @@ impl BatchItem {
project,
state: ExecutionState::Idle,
log_lines: Vec::new(),
last_command: None,
}
}
}
@@ -51,6 +54,7 @@ struct PendingAudio {
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
}
struct PendingSubtitle {
@@ -59,6 +63,7 @@ struct PendingSubtitle {
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
}
// ── BatchPanel ─────────────────────────────────────────────────────────────────
@@ -123,6 +128,7 @@ impl BatchPanel {
language: t.language.clone(),
is_default: t.is_default,
title: t.title.clone(),
drift_scale: t.drift_scale,
}),
Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle {
path: t.path.clone(),
@@ -130,6 +136,7 @@ impl BatchPanel {
language: t.language.clone(),
is_default: t.is_default,
title: t.title.clone(),
drift_scale: t.drift_scale,
}),
}
}
@@ -141,6 +148,7 @@ impl BatchPanel {
ui: &mut egui::Ui,
items: &[BatchItem],
is_processing: bool,
mkvmerge_available: bool,
) -> Vec<BatchPanelEvent> {
let mut events = Vec::new();
@@ -249,7 +257,20 @@ impl BatchPanel {
});
});
}
});
if let Some(cmd) = &item.last_command {
ui.collapsing("🛠 Comando gerado", |ui| {
let mut cmd_str = cmd.as_str();
ui.add(
egui::TextEdit::multiline(&mut cmd_str)
.desired_rows(3)
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace),
);
if ui.small_button("📋 Copiar").clicked() {
ui.output_mut(|o| o.copied_text = cmd.clone());
}
});
} });
}); // push_id
ui.add_space(4.0);
}
@@ -269,7 +290,7 @@ impl BatchPanel {
self.form_open = true;
}
} else {
self.render_form(ui, &mut events);
self.render_form(ui, &mut events, mkvmerge_available);
}
}
@@ -318,7 +339,7 @@ impl BatchPanel {
}
/// Renderiza o formulário inline de adição ou edição de item.
fn render_form(&mut self, ui: &mut egui::Ui, events: &mut Vec<BatchPanelEvent>) {
fn render_form(&mut self, ui: &mut egui::Ui, events: &mut Vec<BatchPanelEvent>, mkvmerge_available: bool) {
let is_edit = self.editing_index.is_some();
let title = if let Some(idx) = self.editing_index {
format!("Editar item #{}", idx + 1)
@@ -442,7 +463,7 @@ impl BatchPanel {
.allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| self.form_add_audio.ui(ui),
|ui| self.form_add_audio.ui(ui, mkvmerge_available),
)
.inner;
@@ -450,15 +471,15 @@ impl BatchPanel {
.allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| self.form_add_subtitle.ui(ui),
|ui| self.form_add_subtitle.ui(ui, mkvmerge_available),
)
.inner;
(ar, sr)
} else {
let ar = self.form_add_audio.ui(ui);
let ar = self.form_add_audio.ui(ui, mkvmerge_available);
ui.add_space(2.0);
let sr = self.form_add_subtitle.ui(ui);
let sr = self.form_add_subtitle.ui(ui, mkvmerge_available);
(ar, sr)
};
@@ -469,6 +490,7 @@ impl BatchPanel {
language: req.language,
is_default: req.is_default,
title: req.title,
drift_scale: req.drift_scale,
});
}
if let Some(req) = subtitle_req {
@@ -478,6 +500,7 @@ impl BatchPanel {
language: req.language,
is_default: req.is_default,
title: req.title,
drift_scale: req.drift_scale,
});
}
@@ -521,6 +544,7 @@ impl BatchPanel {
t.language,
t.is_default,
t.title,
t.drift_scale,
);
}
for s in subs {
@@ -531,6 +555,7 @@ impl BatchPanel {
s.language,
s.is_default,
s.title,
s.drift_scale,
);
}
if let Some(idx) = self.editing_index {
+20
View File
@@ -15,6 +15,9 @@ pub enum ExecutionState {
pub struct ExecutionPanel {
pub state: ExecutionState,
pub log_lines: Vec<String>,
/// Comando completo enviado ao FFmpeg/mkvmerge na última geração.
/// Exemplo: "ffmpeg -i input.mkv -c copy ... output.mkv"
pub last_command: Option<String>,
}
impl ExecutionPanel {
@@ -22,6 +25,7 @@ impl ExecutionPanel {
ExecutionPanel {
state: ExecutionState::Idle,
log_lines: Vec::new(),
last_command: None,
}
}
@@ -103,6 +107,22 @@ impl ExecutionPanel {
});
});
}
// Painel de debug: comando gerado
if let Some(cmd) = &self.last_command {
ui.collapsing("🛠 Comando gerado", |ui| {
let mut cmd_str = cmd.as_str();
ui.add(
egui::TextEdit::multiline(&mut cmd_str)
.desired_rows(3)
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace),
);
if ui.small_button("📋 Copiar").clicked() {
ui.output_mut(|o| o.copied_text = cmd.clone());
}
});
}
});
(generate_clicked, cancel_clicked)
+162 -12
View File
@@ -7,19 +7,26 @@ use std::collections::HashMap;
/// Evento emitido por interações com a lista de faixas existentes.
pub enum ExistingTrackEvent {
OffsetChanged(TrackId, SyncOffset),
/// O fator de escala temporal de uma faixa existente foi alterado.
DriftChanged(TrackId, f64),
/// O usuário solicitou exportar a faixa identificada por `TrackId`.
ExportRequested(TrackId),
/// O usuário alternou o estado de exclusão da faixa (não será incluída no arquivo de saída).
ExcludeToggled(TrackId),
}
/// Lista as faixas detectadas no arquivo de vídeo e permite editar o offset de cada uma.
/// Lista as faixas detectadas no arquivo de vídeo e permite editar o offset e o drift de cada uma.
pub struct ExistingTrackList {
offset_fields: HashMap<u32, SyncOffsetField>,
/// Campos de drift raw por track id (percentual digitado pelo usuário).
drift_fields: HashMap<u32, String>,
}
impl ExistingTrackList {
pub fn new() -> Self {
ExistingTrackList {
offset_fields: HashMap::new(),
drift_fields: HashMap::new(),
}
}
@@ -29,14 +36,20 @@ impl ExistingTrackList {
self.offset_fields
.entry(track.id.val())
.or_insert_with(|| SyncOffsetField::new(track.offset));
self.drift_fields
.entry(track.id.val())
.or_insert_with(|| format!("{:.5}", track.drift_scale * 100.0));
}
}
/// Renderiza a lista. Retorna eventos de alteração de offset.
/// Renderiza a lista. Retorna eventos de alteração de offset e drift.
///
/// `mkvmerge_available`: quando `true`, exibe a coluna de velocidade original (%).
pub fn ui(
&mut self,
ui: &mut egui::Ui,
tracks: &[MediaTrackInfo],
mkvmerge_available: bool,
) -> Vec<ExistingTrackEvent> {
let mut events = Vec::new();
@@ -48,18 +61,30 @@ impl ExistingTrackList {
return;
}
// Duração do vídeo base para sugestão de velocidade
let video_duration_ms = tracks.iter()
.find(|t| matches!(t.kind, TrackKind::Video))
.and_then(|t| t.duration_ms);
let num_cols = if mkvmerge_available { 8 } else { 7 };
egui::Grid::new("existing_tracks_grid")
.num_columns(5)
.num_columns(num_cols)
.striped(true)
.show(ui, |ui| {
ui.strong("#");
ui.strong("Tipo");
ui.strong("Codec");
ui.strong("Duração");
ui.strong("Atraso");
ui.strong("");
if mkvmerge_available {
ui.strong("Velocidade (%)");
}
ui.strong(""); // exportar
ui.strong(""); // excluir
ui.end_row();
for track in tracks {
let editable = matches!(track.kind, TrackKind::Audio | TrackKind::Subtitle) && !track.excluded;
let kind_label = match track.kind {
TrackKind::Video => "Vídeo",
TrackKind::Audio => "Áudio",
@@ -72,20 +97,109 @@ impl ExistingTrackList {
.map(|l| format!(" ({})", l))
.unwrap_or_default();
ui.label(format!("{}", track.stream_index));
ui.label(format!("{}{}", kind_label, lang));
ui.label(&track.codec);
// Cor de texto: esmaecida quando a faixa está excluída do output
let dim_color = egui::Color32::from_gray(120);
let cell_text = |s: String| -> egui::RichText {
let t = egui::RichText::new(s);
if track.excluded { t.color(dim_color).strikethrough() } else { t }
};
// Offset field
ui.label(cell_text(track.stream_index.to_string()));
ui.label(cell_text(format!("{}{}", kind_label, lang)));
ui.label(cell_text(track.codec.clone()));
// Duração
match track.duration_ms {
Some(ms) => ui.label(cell_text(format_duration(ms))),
None => ui.label(cell_text("-".to_string())),
};
// Offset field (desabilitado quando a faixa está excluída)
let field = self
.offset_fields
.entry(track.id.val())
.or_insert_with(|| SyncOffsetField::new(track.offset));
field.ui(ui, "");
if let Some(offset) = field.parse() {
if offset != track.offset {
events.push(ExistingTrackEvent::OffsetChanged(track.id, offset));
ui.add_enabled_ui(!track.excluded, |ui| {
field.ui(ui, "");
});
if !track.excluded {
if let Some(offset) = field.parse() {
if offset != track.offset {
events.push(ExistingTrackEvent::OffsetChanged(track.id, offset));
}
}
}
// Drift field (somente para áudio e legenda quando mkvmerge disponível)
if mkvmerge_available {
if editable {
let suggestion_pct = video_duration_ms
.zip(track.duration_ms)
.filter(|(_, td)| *td > 0)
.map(|(vd, td)| vd as f64 / td as f64 * 100.0);
let drift_raw = self
.drift_fields
.entry(track.id.val())
.or_insert_with(|| format!("{:.5}", track.drift_scale * 100.0));
let mut text_changed = false;
let mut suggestion_applied: Option<f64> = None;
ui.horizontal(|ui| {
let resp = ui.add(
egui::TextEdit::singleline(drift_raw).desired_width(72.0),
);
text_changed = resp.changed();
if let Some(pct) = suggestion_pct {
let warn = (pct - 100.0).abs() > 0.5;
let hover = format!(
"Sugerido: {:.3}%{}",
pct,
if warn {
"\n⚠ Diferença >0.5% — verifique se é o mesmo conteúdo"
} else {
" — clique para aplicar"
}
);
let color = if warn {
egui::Color32::from_rgb(255, 160, 0)
} else {
egui::Color32::from_rgb(80, 180, 80)
};
if ui
.add(egui::Button::new(
egui::RichText::new("💡").color(color).small(),
))
.on_hover_text(hover)
.clicked()
{
suggestion_applied = Some(pct);
}
}
});
if let Some(pct) = suggestion_applied {
*drift_raw = format!("{:.3}", pct);
events.push(ExistingTrackEvent::DriftChanged(
track.id, pct / 100.0,
));
} else if text_changed {
if let Ok(pct) = drift_raw.trim().parse::<f64>() {
if pct >= 1.0 && pct <= 999.99 {
let scale = pct / 100.0;
if (scale - track.drift_scale).abs() > 1e-9 {
events.push(ExistingTrackEvent::DriftChanged(
track.id, scale,
));
}
}
}
}
} else {
ui.label(""); // célula vazia para vídeo/dados
}
}
@@ -101,6 +215,28 @@ impl ExistingTrackList {
}
}
// Botão de excluir (somente Áudio e Legenda)
match track.kind {
TrackKind::Audio | TrackKind::Subtitle => {
let label = if track.excluded {
egui::RichText::new("↺ Restaurar").color(egui::Color32::from_rgb(255, 160, 0))
} else {
egui::RichText::new("✖ Excluir").color(egui::Color32::from_rgb(210, 70, 70))
};
let tooltip = if track.excluded {
"Restaurar: faixa será incluída no arquivo de saída"
} else {
"Excluir: faixa não será incluída no arquivo de saída"
};
if ui.add(egui::Button::new(label).small()).on_hover_text(tooltip).clicked() {
events.push(ExistingTrackEvent::ExcludeToggled(track.id));
}
}
_ => {
ui.label(""); // célula vazia para vídeo/dados
}
}
ui.end_row();
}
});
@@ -109,3 +245,17 @@ impl ExistingTrackList {
events
}
}
/// Formata duração em ms para exibição: "3:45" ou "1:23:45".
fn format_duration(ms: u64) -> String {
let total_secs = ms / 1000;
let h = total_secs / 3600;
let m = (total_secs % 3600) / 60;
let s = total_secs % 60;
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
+103 -1
View File
@@ -3,9 +3,15 @@ use crate::domain::value_objects::SyncOffset;
/// Campo de entrada para offset de sincronização em segundos (ex: "-1.2").
/// Converte para SyncOffset(ms) internamente ao confirmar.
/// Também suporta campo opcional de velocidade original (%) para correção de drift via mkvmerge.
pub struct SyncOffsetField {
pub raw: String,
pub error: Option<String>,
/// Percentual de velocidade original (ex: "99.983" → scale 0.99983). Padrão: "100.00".
pub drift_raw: String,
drift_error: Option<String>,
/// Velocidade sugerida calculada externamente (% inteiro). Exibida como botão 💡.
suggestion_pct: Option<f64>,
}
impl SyncOffsetField {
@@ -14,10 +20,19 @@ impl SyncOffsetField {
SyncOffsetField {
raw: format!("{:.1}", seconds),
error: None,
drift_raw: "100.00".to_string(),
drift_error: None,
suggestion_pct: None,
}
}
/// Renderiza o campo e retorna true se o valor mudou.
/// Define a velocidade sugerida (%) calculada a partir das durações do vídeo e da faixa.
/// `None` remove o botão de sugestão.
pub fn set_suggestion(&mut self, pct: Option<f64>) {
self.suggestion_pct = pct;
}
/// Renderiza o campo de offset (sem o campo de drift).
pub fn ui(&mut self, ui: &mut egui::Ui, label: &str) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
@@ -35,6 +50,71 @@ impl SyncOffsetField {
changed
}
/// Renderiza offset + campo de velocidade original (drift) quando `mkvmerge_available`.
///
/// - Quando `mkvmerge_available = false`: campo de velocidade é exibido desabilitado
/// com tooltip pedindo instalação do MKVToolNix.
/// - Quando `scale ≠ 1.0`: exibe aviso discreto em laranja.
pub fn ui_with_drift(&mut self, ui: &mut egui::Ui, label: &str, mkvmerge_available: bool) -> bool {
let offset_changed = self.ui(ui, label);
ui.horizontal(|ui| {
ui.label("Velocidade original (%):");
let resp = ui.add_enabled(
mkvmerge_available,
egui::TextEdit::singleline(&mut self.drift_raw).desired_width(70.0),
);
if !mkvmerge_available {
resp.on_hover_text(
"Instale MKVToolNix para usar correção de drift de velocidade.",
);
}
// Botão de sugestão calculada a partir das durações
if mkvmerge_available {
if let Some(pct) = self.suggestion_pct {
let warn = (pct - 100.0).abs() > 0.5;
let hover = format!(
"Velocidade sugerida: {:.3}%{}",
pct,
if warn {
"\n⚠ Diferença >0.5% — verifique se é o mesmo conteúdo"
} else {
" — clique para aplicar"
}
);
let color = if warn {
egui::Color32::from_rgb(255, 160, 0)
} else {
egui::Color32::from_rgb(80, 180, 80)
};
if ui
.add(egui::Button::new(
egui::RichText::new("💡").color(color).small(),
))
.on_hover_text(hover)
.clicked()
{
self.drift_raw = format!("{:.3}", pct);
}
}
}
if let Ok(pct) = self.drift_raw.trim().parse::<f64>() {
if (pct - 100.0).abs() > 0.001 {
ui.colored_label(
egui::Color32::from_rgb(255, 160, 0),
"⚠ requer mkvmerge",
);
}
}
});
if let Some(ref err) = self.drift_error {
ui.colored_label(egui::Color32::RED, err);
}
offset_changed
}
/// Tenta converter o valor atual para SyncOffset.
pub fn parse(&mut self) -> Option<SyncOffset> {
match SyncOffset::from_seconds_str(&self.raw) {
@@ -48,4 +128,26 @@ impl SyncOffsetField {
}
}
}
/// Converte o percentual de velocidade para fator de escala (f64).
/// "100.00" → 1.0, "99.983" → 0.99983.
/// Retorna `1.0` em caso de erro (fallback seguro).
pub fn parse_drift(&mut self) -> f64 {
let trimmed = self.drift_raw.trim().trim_end_matches('%');
match trimmed.parse::<f64>() {
Ok(pct) if pct >= 1.0 && pct <= 999.99 => {
self.drift_error = None;
pct / 100.0
}
Ok(_) => {
self.drift_error =
Some("Velocidade deve estar entre 1.0% e 999.99%".to_string());
1.0
}
Err(_) => {
self.drift_error = Some("Valor inválido".to_string());
1.0
}
}
}
}