Compare commits

..
10 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
Felipe 285f22e2d1 feat: adiciona suporte para correção de drift em faixas de áudio e legenda utilizando mkvmerge 2026-03-01 10:57:57 -03:00
Felipe 61b71f03a7 refactor: melhora a legibilidade do código ao formatar chamadas de função em múltiplas linhas 2026-03-01 01:55:33 -03:00
Felipe 1f163ca0fc feat: adiciona suporte para definir título e faixa padrão em faixas de áudio e legenda 2026-03-01 01:55:22 -03:00
Felipe c7031cbdce feat: atualiza terminologia de "carrinho" para "fila" na interface de lote e ajusta comentários 2026-03-01 00:37:56 -03:00
37 changed files with 2436 additions and 736 deletions
-352
View File
@@ -1,352 +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
---
## 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)
+137 -42
View File
@@ -1,9 +1,9 @@
# PRD — Simple Multimedia Track Audio Editor # PRD — Simple Multimedia Track Audio Editor
**Versão:** 1.2 **Versão:** 1.5
**Data:** 28/02/2026 **Data:** 02/03/2026
**Status:** Em desenvolvimento **Status:** v1.0 funcional — Fases 19 concluídas; 72 testes passando; aplicação executável
**Revisão:** v1.2 — Adicionado RF-09 (Modo Lote); batch promovido de backlog para requisito planejado **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 - Reencoding / transcodificação de vídeo ou áudio
- Preview de vídeo embutido - Preview de vídeo embutido
- Detecção automática de sincronização - 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 - Edição de corte ou splice de vídeo
- Salvamento automático em disco (sem intervenção do usuário)
--- ---
@@ -82,6 +81,20 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
- O usuário pode atribuir um código de idioma a cada faixa (ex: `por`, `eng`) - O usuário pode atribuir um código de idioma a cada faixa (ex: `por`, `eng`)
- Implementado via `ffmpeg -metadata:s` - Implementado via `ffmpeg -metadata:s`
### RF-10 — Definir nome da faixa
- O usuário pode preencher um título livre para cada faixa externa adicionada (ex: `Português`, `Comentários`)
- O campo aceita string vazia, que instrui o `FfmpegCommandBuilder` a emitir `title=` limpando qualquer título herdado do arquivo fonte
- Objetivo principal: sobrescrever títulos gerados automaticamente por ferramentas externas (ex: `"ISO Media file produced by Google Inc."` em arquivos M4A do Google) que confundem players como Jellyfin mobile
- Implementado via `ffmpeg -metadata:s:a:{idx} title=` ou `-metadata:s:s:{idx} title=`; sempre emitido, mesmo quando vazio
### RF-11 — Definir faixa padrão
- O usuário pode marcar uma faixa externa como “faixa padrão” do container MKV
- Quando marcada, o `FfmpegCommandBuilder` emite `-disposition:a/s:{idx} default` para a faixa selecionada e `-disposition:a/s:{orig_idx} 0` para todas as faixas existentes do mesmo tipo, removendo o flag `default=1` que o arquivo fonte carrega
- Objetivo: garantir que players como Jellyfin selecionem automaticamente a faixa preferida sem depender de configuração manual no side do player
- Implementado via `ffmpeg -disposition:a:{idx} default`
### RF-05 — Selecionar arquivo de vídeo base ### RF-05 — Selecionar arquivo de vídeo base
- O usuário seleciona o arquivo de vídeo de entrada - O usuário seleciona o arquivo de vídeo de entrada
@@ -113,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 - 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 - 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 - 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` - É 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)
--- ---
@@ -145,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). 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 ### RNF-05 — Interface responsiva
@@ -170,13 +232,15 @@ Arquivo de saída (.mkv)
### Stack ### Stack
| Camada | Tecnologia | Motivo | | Camada | Tecnologia | Motivo |
| ------------ | ------------------------------ | ------------------------------------ | | ------------- | ------------------------------- | ------------------------------------------- |
| Interface | `egui` / `eframe` | Nativo, simples, multiplataforma | | Interface | `egui` / `eframe` | Nativo, simples, multiplataforma |
| Backend | Rust (`std::process::Command`) | Estável, sem dependências externas | | Backend | Rust (`std::process::Command`) | Estável, sem dependências externas |
| Erros | `anyhow` | Propagação de erros simplificada | | Erros | `anyhow` | Propagação de erros simplificada |
| Configuração | `serde` | Serialização de perfis e histórico | | Configuração | `serde` + `serde_json` | Serialização de sessão em JSON |
| Async | `tokio` (opcional) | Execução não bloqueante da interface | | Async | `tokio` | Execução não bloqueante da interface |
| Mídia | FFmpeg (externo) | Maduro, estável, amplamente testado | | 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 | | Container | MKV | Melhor suporte a múltiplas faixas |
### Dependências Cargo ### Dependências Cargo
@@ -185,7 +249,9 @@ Arquivo de saída (.mkv)
eframe = "0.27" eframe = "0.27"
anyhow = "1" anyhow = "1"
serde = { version = "1", features = ["derive"] } 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"
``` ```
--- ---
@@ -222,7 +288,7 @@ Contém as entidades e objetos de valor do negócio. Não depende de nada extern
| Tipo | Exemplos | | Tipo | Exemplos |
| ------------- | ------------------------------------------------------------------------------------ | | ------------- | ------------------------------------------------------------------------------------ |
| Entities | `Project`, `VideoFile`, `AudioTrack`, `SubtitleTrack`, `MkvOutput`, `MediaTrackInfo` | | 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`** é 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.
> >
@@ -234,9 +300,13 @@ Contém as entidades e objetos de valor do negócio. Não depende de nada extern
> └── output: MkvOutput > └── 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`). > **`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) #### Application (casos de uso)
@@ -244,9 +314,9 @@ 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. Define traits (ports) que as camadas externas devem implementar.
| Tipo | Exemplos | | Tipo | Exemplos |
| --------- | ---------------------------------------------------------------------------------------------------------------------------- | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Use Cases | `LoadMediaInfo`, `AddAudioTrack`, `AddSubtitle`, `AdjustSync`, `EditExistingTrackSync`, `SetTrackLanguage`, `GenerateOutput` | | Use Cases | `LoadMediaInfo`, `AddAudioTrack`, `AddSubtitle`, `AdjustSync`, `AdjustDrift`, `AdjustExistingTrackDrift`, `EditExistingTrackSync`, `ExportTrack`, `RemoveTrack`, `SetTrackLanguage`, `GenerateOutput` |
| Ports | `MediaProcessorPort`, `MediaInfoPort`, `FileSystemPort` | | 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`. > **`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`.
> >
@@ -265,14 +335,19 @@ 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. Implementam os ports definidos na camada de Application. Traduzem dados entre o domínio e o mundo externo.
| Tipo | Exemplos | | Tipo | Exemplos |
| ------------ | --------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------- |
| Gateway | `FfmpegCommandBuilder`, `FfmpegGateway`, `FfprobeGateway` | | Gateway | `FfmpegCommandBuilder`, `FfmpegGateway`, `FfprobeGateway` |
| Presenter | `ErrorPresenter` (formata stderr do FFmpeg) | | Gateway | `MkvmergeCommandBuilder`, `MkvmergeGateway` (motor de drift — RF-12) |
| File Adapter | `FilePickerAdapter` | | 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. > **`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.** > **`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 #### Infrastructure / UI
@@ -292,18 +367,23 @@ Camada mais externa. Contém o framework de UI e a execução real de processos.
src/ src/
├── domain/ ├── domain/
│ ├── entities/ # Project, VideoFile, AudioTrack, SubtitleTrack, MkvOutput, MediaTrackInfo │ ├── 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/ ├── application/
│ ├── use_cases/ # LoadMediaInfo, AddAudioTrack, AddSubtitle, AdjustSync, GenerateOutput │ ├── use_cases/ # LoadMediaInfo, AddAudioTrack, AddSubtitle, AdjustSync, AdjustDrift,
└── ports/ # Traits: MediaProcessorPort, MediaInfoPort, FileSystemPort # AdjustExistingTrackDrift, EditExistingTrackSync, ExportTrack,
│ │ # RemoveTrack, SetTrackLanguage, GenerateOutput
│ └── ports/ # Traits: MediaProcessorPort, MediaInfoPort, FileSystemPort, ContainerMuxPort
├── adapters/ ├── adapters/
│ ├── ffmpeg/ # FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway │ ├── ffmpeg/ # FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway
│ ├── mkvmerge/ # MkvmergeCommandBuilder, MkvmergeGateway (RF-12 — drift)
│ └── filesystem/ # FilePickerAdapter │ └── filesystem/ # FilePickerAdapter
├── infrastructure/ ├── 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/ ├── ui/
│ ├── app.rs # eframe App (ponto de entrada da interface) │ ├── app.rs # eframe App; ActiveTab (Single/Batch); dispatch FFmpeg/mkvmerge
│ └── components/ # Componentes egui reutilizáveis │ └── components/ # VideoSelector, ExistingTrackList, AddAudioTrackForm, AddSubtitleForm,
│ # SyncOffsetField (+ campo drift), OutputSelector, ExecutionPanel, BatchPanel
└── main.rs └── main.rs
``` ```
@@ -311,14 +391,18 @@ src/
## 10. Débitos Técnicos ## 10. Débitos Técnicos
| ID | Descrição | Impacto | Mitigação | | ID | Descrição | Impacto | Mitigação / Status |
| ----- | ----------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------ | | ----- | ------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| DT-01 | FFmpeg não embutido | Usuário precisa instalar | Distribuir junto com a aplicaçã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-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) | Necessário tratamento manual | Parsear saída e exibir mensagem amigável | | 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 | Usar MKV como container padrão | | 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 | | 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 | Validar presença de ffprobe na inicialização; exibir mensagem clara se ausente | | 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 |
--- ---
@@ -326,9 +410,10 @@ src/
- Preview de vídeo embutido na interface - Preview de vídeo embutido na interface
- Detecção automática de sincronização entre faixas - Detecção automática de sincronização entre faixas
- Seleção de faixa padrão no container MKV - Suporte a perfis de configuração reutilizáveis (presets de idioma, offset, drift)
- Remoção de faixas existentes do arquivo original - Mensagens de erro mais amigáveis a partir do stderr do FFmpeg
- Suporte a perfis de configuração salvos - Testes de integração com FFmpeg real no CI (DT-09)
- Testes de snapshot para o `FfmpegCommandBuilder` e `MkvmergeCommandBuilder` com projetos complexos
--- ---
@@ -341,11 +426,21 @@ src/
- [x] É possível definir offset de sincronização por faixa ao adicionar uma nova faixa - [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 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 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] O arquivo MKV é gerado corretamente ao confirmar
- [x] Erros do FFmpeg são exibidos de forma legível - [x] Erros do FFmpeg/mkvmerge são exibidos de forma legível na `ExecutionPanel`
- [x] A interface não trava durante o processamento - [x] A interface não trava durante o processamento (execução assíncrona via `tokio`)
- [x] Nenhum reencoding ocorre (verificável via `ffprobe`) - [x] É possível cancelar a geração em andamento
- [x] O flag `-c copy` está sempre presente no comando gerado (verificável via testes unitários e modo debug) - [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)
--- ---
-247
View File
@@ -1,247 +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
```
---
## 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
---
## 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)
+151 -3
View File
@@ -61,8 +61,8 @@ impl FfmpegCommandBuilder {
} }
// ── Maps ──────────────────────────────────────────────────────────────── // ── Maps ────────────────────────────────────────────────────────────────
// Faixas existentes do source // Faixas existentes do source (excluídas pelo usuário são omitidas)
for track in &project.existing_tracks { for track in project.existing_tracks.iter().filter(|t| !t.excluded) {
args.push("-map".to_string()); args.push("-map".to_string());
if track.offset.is_zero() { if track.offset.is_zero() {
args.push(format!("0:{}", track.stream_index)); 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() { if project.existing_tracks.is_empty() {
args.push("-map".to_string()); args.push("-map".to_string());
args.push("0".to_string()); args.push("0".to_string());
@@ -91,6 +93,18 @@ impl FfmpegCommandBuilder {
ext_idx += 1; ext_idx += 1;
} }
// ── Opções de muxing ─────────────────────────────────────────────────────
// -max_interleave_delta 0: remove o limite do buffer de intercalação (padrão: 10s).
// Sem isso, quando o áudio externo fica à frente do vídeo, o FFmpeg descarta
// pacotes silenciosamente, gerando trechos mudos no output. Com 0, nenhum
// pacote é descartado por excesso de delta — apenas memória RAM é consumida.
// -avoid_negative_ts make_zero: normaliza timestamps negativos comuns em M4A/AAC
// de serviços de streaming (DASH), que causam dessincronização no muxer MKV.
args.push("-max_interleave_delta".to_string());
args.push("0".to_string());
args.push("-avoid_negative_ts".to_string());
args.push("make_zero".to_string());
// ── -c copy (SEMPRE — invariante central do produto) ──────────────────── // ── -c copy (SEMPRE — invariante central do produto) ────────────────────
args.push("-c".to_string()); args.push("-c".to_string());
args.push("copy".to_string()); args.push("copy".to_string());
@@ -115,16 +129,96 @@ impl FfmpegCommandBuilder {
Track::Audio(t) => { Track::Audio(t) => {
args.push(format!("-metadata:s:a:{}", ext_audio_idx)); args.push(format!("-metadata:s:a:{}", ext_audio_idx));
args.push(format!("language={}", t.language)); args.push(format!("language={}", t.language));
// Sempre emite title (mesmo vazio) para sobrescrever qualquer título
// herdado do arquivo fonte (ex: "ISO Media file produced by Google Inc.").
// Players como Jellyfin mobile usam o title para identificar a faixa.
args.push(format!("-metadata:s:a:{}", ext_audio_idx));
args.push(format!("title={}", t.title));
ext_audio_idx += 1; ext_audio_idx += 1;
} }
Track::Subtitle(t) => { Track::Subtitle(t) => {
args.push(format!("-metadata:s:s:{}", ext_sub_idx)); args.push(format!("-metadata:s:s:{}", ext_sub_idx));
args.push(format!("language={}", t.language)); args.push(format!("language={}", t.language));
args.push(format!("-metadata:s:s:{}", ext_sub_idx));
args.push(format!("title={}", t.title));
ext_sub_idx += 1; ext_sub_idx += 1;
} }
} }
} }
// ── Disposições (faixa padrão) ────────────────────────────────────────
// Quando o usuário marca uma faixa externa como "padrão", é necessário:
// 1. Remover o flag `default` de todas as faixas existentes do mesmo tipo
// (o arquivo fonte geralmente já tem `disposition:default=1` na sua
// primeira faixa de áudio, e Jellyfin/Kodi respeitam esse flag).
// 2. Emitir `-disposition:a/s:{idx} default` para a faixa externa marcada.
//
// Índices de disposição seguem a ordem de streams do tipo no output:
// - Faixas existentes (existing_tracks) vêm primeiro, na ordem do map.
// - Faixas externas (tracks) vêm depois, na ordem de adição.
let existing_audio_count_disp = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Audio))
.count();
let existing_sub_count_disp = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Subtitle))
.count();
let any_ext_audio_default = project
.tracks
.iter()
.any(|t| matches!(t, Track::Audio(a) if a.is_default));
let any_ext_sub_default = project
.tracks
.iter()
.any(|t| matches!(t, Track::Subtitle(s) if s.is_default));
// Remove default das faixas existentes quando uma faixa externa é padrão
if any_ext_audio_default {
for i in 0..existing_audio_count_disp {
args.push(format!("-disposition:a:{}", i));
args.push("0".to_string());
}
}
if any_ext_sub_default {
for i in 0..existing_sub_count_disp {
args.push(format!("-disposition:s:{}", i));
args.push("0".to_string());
}
}
// Emite `default` para as faixas externas marcadas
let mut disp_audio_idx = 0usize;
let mut disp_sub_idx = 0usize;
for track in &project.tracks {
match track {
Track::Audio(a) => {
if a.is_default {
args.push(format!(
"-disposition:a:{}",
existing_audio_count_disp + disp_audio_idx
));
args.push("default".to_string());
}
disp_audio_idx += 1;
}
Track::Subtitle(s) => {
if s.is_default {
args.push(format!(
"-disposition:s:{}",
existing_sub_count_disp + disp_sub_idx
));
args.push("default".to_string());
}
disp_sub_idx += 1;
}
}
}
// ── Output ────────────────────────────────────────────────────────────── // ── Output ──────────────────────────────────────────────────────────────
args.push(project.output.path.to_string_lossy().to_string()); args.push(project.output.path.to_string_lossy().to_string());
@@ -216,6 +310,9 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::from_ms(1200), SyncOffset::from_ms(1200),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
) )
.unwrap(); .unwrap();
@@ -233,6 +330,9 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::default(), SyncOffset::default(),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
) )
.unwrap(); .unwrap();
@@ -252,6 +352,9 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::default(), SyncOffset::default(),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
) )
.unwrap(); .unwrap();
@@ -263,4 +366,49 @@ mod tests {
); );
assert!(args.contains(&"language=por".to_string())); 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, index: u32,
codec_type: Option<String>, codec_type: Option<String>,
codec_name: Option<String>, codec_name: Option<String>,
duration: Option<String>,
tags: Option<FfprobeTags>, tags: Option<FfprobeTags>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct FfprobeTags { struct FfprobeTags {
language: Option<String>, 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 { impl MediaInfoPort for FfprobeGateway {
@@ -61,15 +66,78 @@ impl MediaInfoPort for FfprobeGateway {
_ => return None, _ => return None,
}; };
let codec = s.codec_name.unwrap_or_else(|| "unknown".to_string()); let codec = s.codec_name.unwrap_or_else(|| "unknown".to_string());
let language = s let (language, duration_from_tag) = match s.tags {
.tags Some(t) => {
.and_then(|t| t.language) let lang = t.language.and_then(|l| TrackLanguage::new(l).ok());
.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); 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(); .collect();
Ok(tracks) 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 ffmpeg;
pub mod filesystem; pub mod filesystem;
pub mod mkvmerge;
+7
View File
@@ -12,6 +12,13 @@ pub trait MediaProcessorPort {
fn execute(&self, args: Vec<String>) -> Result<()>; 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. /// Port para abstrair acesso ao sistema de arquivos.
#[allow(dead_code)] #[allow(dead_code)]
pub trait FileSystemPort { pub trait FileSystemPort {
+8 -2
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use crate::domain::entities::{AudioTrack, Project, Track}; use crate::domain::entities::{AudioTrack, Project, Track};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use anyhow::Result;
/// Adiciona uma faixa de áudio externa ao projeto. /// Adiciona uma faixa de áudio externa ao projeto.
pub struct AddAudioTrack; pub struct AddAudioTrack;
@@ -11,9 +11,12 @@ impl AddAudioTrack {
path: FilePath, path: FilePath,
offset: SyncOffset, offset: SyncOffset,
language: TrackLanguage, language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
) -> Result<()> { ) -> Result<()> {
let id = project.next_track_id(); let id = project.next_track_id();
let track = AudioTrack::new(id, path, offset, language); let track = AudioTrack::new(id, path, offset, language, is_default, title, drift_scale);
project.tracks.push(Track::Audio(track)); project.tracks.push(Track::Audio(track));
Ok(()) Ok(())
} }
@@ -39,6 +42,9 @@ mod tests {
FilePath::from("audio_pt.aac"), FilePath::from("audio_pt.aac"),
SyncOffset::default(), SyncOffset::default(),
lang, lang,
false,
String::new(),
1.0,
) )
.unwrap(); .unwrap();
+8 -2
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use crate::domain::entities::{Project, SubtitleTrack, Track}; use crate::domain::entities::{Project, SubtitleTrack, Track};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use anyhow::Result;
/// Adiciona uma faixa de legenda externa ao projeto. /// Adiciona uma faixa de legenda externa ao projeto.
pub struct AddSubtitle; pub struct AddSubtitle;
@@ -11,9 +11,12 @@ impl AddSubtitle {
path: FilePath, path: FilePath,
offset: SyncOffset, offset: SyncOffset,
language: TrackLanguage, language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
) -> Result<()> { ) -> Result<()> {
let id = project.next_track_id(); let id = project.next_track_id();
let track = SubtitleTrack::new(id, path, offset, language); let track = SubtitleTrack::new(id, path, offset, language, is_default, title, drift_scale);
project.tracks.push(Track::Subtitle(track)); project.tracks.push(Track::Subtitle(track));
Ok(()) Ok(())
} }
@@ -38,6 +41,9 @@ mod tests {
FilePath::from("sub_en.srt"), FilePath::from("sub_en.srt"),
SyncOffset::default(), SyncOffset::default(),
lang, lang,
false,
String::new(),
1.0,
) )
.unwrap(); .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());
}
}
+3
View File
@@ -37,6 +37,9 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::default(), SyncOffset::default(),
lang, lang,
false,
String::new(),
1.0,
) )
.unwrap(); .unwrap();
let id = project.tracks[0].id(); let id = project.tracks[0].id();
@@ -49,6 +49,9 @@ mod tests {
FilePath::from("audio_pt.aac"), FilePath::from("audio_pt.aac"),
SyncOffset::from_ms(1200), SyncOffset::from_ms(1200),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
) )
.unwrap(); .unwrap();
+2
View File
@@ -1,5 +1,7 @@
pub mod add_audio_track; pub mod add_audio_track;
pub mod add_subtitle; pub mod add_subtitle;
pub mod adjust_drift;
pub mod adjust_existing_track_drift;
pub mod adjust_sync; pub mod adjust_sync;
pub mod edit_existing_track_sync; pub mod edit_existing_track_sync;
pub mod export_track; pub mod export_track;
@@ -38,6 +38,9 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::from_ms(0), SyncOffset::from_ms(0),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
))); )));
assert_eq!(project.tracks.len(), 1); assert_eq!(project.tracks.len(), 1);
@@ -36,6 +36,9 @@ mod tests {
FilePath::from("audio.aac"), FilePath::from("audio.aac"),
SyncOffset::default(), SyncOffset::default(),
TrackLanguage::new("por").unwrap(), TrackLanguage::new("por").unwrap(),
false,
String::new(),
1.0,
) )
.unwrap(); .unwrap();
let id = project.tracks[0].id(); let id = project.tracks[0].id();
+29 -3
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
use serde::{Deserialize, Serialize};
/// Faixa de áudio externa adicionada pelo usuário. /// Faixa de áudio externa adicionada pelo usuário.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -8,10 +8,36 @@ pub struct AudioTrack {
pub path: FilePath, pub path: FilePath,
pub offset: SyncOffset, pub offset: SyncOffset,
pub language: TrackLanguage, pub language: TrackLanguage,
/// Quando verdadeiro, emite `-disposition:a:{idx} default` no FFmpeg,
/// tornando esta faixa a padrão para players como Jellyfin.
pub is_default: bool,
/// Título exibido na lista de faixas do player (ex: "Português", "Comentários").
/// 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 { impl AudioTrack {
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self { pub fn new(
AudioTrack { id, path, offset, language } id: TrackId,
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
) -> Self {
AudioTrack {
id,
path,
offset,
language,
is_default,
title,
drift_scale,
}
} }
} }
+12
View File
@@ -32,6 +32,15 @@ pub struct MediaTrackInfo {
pub offset: SyncOffset, pub offset: SyncOffset,
/// Índice do stream no container original (usado internamente pelo adapter). /// Índice do stream no container original (usado internamente pelo adapter).
pub stream_index: u32, 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 { impl MediaTrackInfo {
@@ -49,6 +58,9 @@ impl MediaTrackInfo {
language, language,
offset: SyncOffset::default(), offset: SyncOffset::default(),
stream_index, 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) 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. /// Remove uma faixa externa pelo TrackId. Retorna true se a faixa foi encontrada e removida.
pub fn remove_track(&mut self, id: TrackId) -> bool { pub fn remove_track(&mut self, id: TrackId) -> bool {
let before = self.tracks.len(); let before = self.tracks.len();
self.tracks.retain(|t| t.id() != id); self.tracks.retain(|t| t.id() != id);
self.tracks.len() < before 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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> { fn make_project(src: &str, out: &str) -> Result<Project> {
Project::new( 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] #[test]
fn projeto_valido() { fn projeto_valido() {
assert!(make_project("input.mkv", "output.mkv").is_ok()); assert!(make_project("input.mkv", "output.mkv").is_ok());
@@ -86,4 +137,126 @@ mod tests {
let id2 = id; let id2 = id;
assert_eq!(id, id2); 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
}
} }
+27 -3
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage}; use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
use serde::{Deserialize, Serialize};
/// Faixa de legenda externa adicionada pelo usuário. /// Faixa de legenda externa adicionada pelo usuário.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -8,10 +8,34 @@ pub struct SubtitleTrack {
pub path: FilePath, pub path: FilePath,
pub offset: SyncOffset, pub offset: SyncOffset,
pub language: TrackLanguage, pub language: TrackLanguage,
/// Quando verdadeiro, emite `-disposition:s:{idx} default` no FFmpeg.
pub is_default: bool,
/// 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 { impl SubtitleTrack {
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self { pub fn new(
SubtitleTrack { id, path, offset, language } id: TrackId,
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
) -> Self {
SubtitleTrack {
id,
path,
offset,
language,
is_default,
title,
drift_scale,
}
} }
} }
+16
View File
@@ -47,4 +47,20 @@ impl Track {
Track::Subtitle(t) => t.language = language, 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 file_path;
pub mod sync_offset; pub mod sync_offset;
pub mod sync_transform;
pub mod track_id; pub mod track_id;
pub mod track_language; pub mod track_language;
pub use file_path::FilePath; pub use file_path::FilePath;
pub use sync_offset::SyncOffset; pub use sync_offset::SyncOffset;
pub use sync_transform::SyncTransform;
pub use track_id::TrackId; pub use track_id::TrackId;
pub use track_language::TrackLanguage; 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")?; check_binary_available("ffprobe")?;
Ok(()) 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"))
}
}
}
+123 -25
View File
@@ -1,13 +1,15 @@
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway}; use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
use crate::adapters::mkvmerge::MkvmergeCommandBuilder;
use crate::adapters::filesystem::file_picker::FilePickerAdapter; use crate::adapters::filesystem::file_picker::FilePickerAdapter;
use crate::application::use_cases::{ use crate::application::use_cases::{
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle, add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
adjust_existing_track_drift::AdjustExistingTrackDrift,
edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack, edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack,
load_media_info::LoadMediaInfo, remove_track::RemoveTrack, 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::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::{ use crate::ui::components::{
add_audio_track_form::AddAudioTrackForm, add_audio_track_form::AddAudioTrackForm,
add_subtitle_form::AddSubtitleForm, add_subtitle_form::AddSubtitleForm,
@@ -70,6 +72,8 @@ pub struct App {
batch_panel: BatchPanel, batch_panel: BatchPanel,
// Índice do item sendo processado no lote (None = nenhum) // Índice do item sendo processado no lote (None = nenhum)
batch_processing_index: Option<usize>, batch_processing_index: Option<usize>,
// mkvmerge disponível no PATH (verificação soft na inicialização)
mkvmerge_available: bool,
} }
impl App { impl App {
@@ -109,6 +113,7 @@ impl App {
batch_items: Vec::new(), batch_items: Vec::new(),
batch_panel: BatchPanel::new(), batch_panel: BatchPanel::new(),
batch_processing_index: None, batch_processing_index: None,
mkvmerge_available: crate::infrastructure::process::mkvmerge_available(),
} }
} }
@@ -130,6 +135,12 @@ impl App {
Ok(_) => { Ok(_) => {
self.existing_track_list self.existing_track_list
.sync_tracks(&project.existing_tracks); .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) => { Err(e) => {
self.execution_panel.state = ExecutionState::Error(e.to_string()); 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. /// 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) { fn start_generation(&mut self, ctx: egui::Context) {
let project = match &self.project { let project = match &self.project {
Some(p) => p.clone(), Some(p) => p.clone(),
None => return, 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>(); let (tx, rx) = mpsc::channel::<BackgroundMsg>();
self.bg_rx = Some(rx); self.bg_rx = Some(rx);
self.execution_panel.state = ExecutionState::Running; self.execution_panel.state = ExecutionState::Running;
@@ -237,7 +267,7 @@ impl App {
self.cancel_tx = Some(cancel_tx); self.cancel_tx = Some(cancel_tx);
std::thread::spawn(move || { 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>(); let (log_tx, log_rx) = std::sync::mpsc::channel::<String>();
// Thread auxiliar: encaminha cada linha de log para o canal da UI // 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 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(); let _ = fwd_handle.join();
match result { match result {
@@ -279,13 +311,32 @@ impl App {
} }
/// Inicia o processamento de um item específico do carrinho de lote. /// 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) { fn start_batch_item(&mut self, index: usize, ctx: egui::Context) {
let project = self.batch_items[index].project.clone(); let project = self.batch_items[index].project.clone();
self.batch_items[index].state = ExecutionState::Running; self.batch_items[index].state = ExecutionState::Running;
self.batch_items[index].log_lines.clear(); self.batch_items[index].log_lines.clear();
self.batch_processing_index = Some(index); 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>(); let (tx, rx) = mpsc::channel::<BackgroundMsg>();
self.bg_rx = Some(rx); self.bg_rx = Some(rx);
@@ -305,7 +356,11 @@ impl App {
}); });
let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio"); 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(); let _ = fwd_handle.join();
match result { match result {
@@ -410,8 +465,12 @@ impl eframe::App for App {
{ {
match crate::infrastructure::persistence::load_session() { match crate::infrastructure::persistence::load_session() {
Ok(data) => { Ok(data) => {
let probe = FfprobeGateway;
// Restaura projeto único // Restaura projeto único
if let Some(project) = data.single_project { if let Some(mut project) = data.single_project {
// Re-executa ffprobe para garantir que existing_tracks
// está atualizado independentemente do que foi salvo.
let _ = LoadMediaInfo::execute(&mut project, &probe);
self.pending_source = self.pending_source =
Some(project.source.path.clone()); Some(project.source.path.clone());
self.pending_output = self.pending_output =
@@ -421,12 +480,20 @@ impl eframe::App for App {
self.project = Some(project); self.project = Some(project);
} }
// Restaura itens do lote (estado resetado para Idle) // Restaura itens do lote (estado resetado para Idle)
// Re-executa ffprobe em cada projeto para popular
// existing_tracks que podem estar vazios na sessão salva.
if !data.batch_projects.is_empty() { if !data.batch_projects.is_empty() {
use crate::ui::components::batch_panel::BatchItem; use crate::ui::components::batch_panel::BatchItem;
self.batch_items = data self.batch_items = data
.batch_projects .batch_projects
.into_iter() .into_iter()
.map(BatchItem::new) .map(|mut project| {
let _ = LoadMediaInfo::execute(
&mut project,
&probe,
);
BatchItem::new(project)
})
.collect(); .collect();
} }
let single_ok = self.project.is_some(); let single_ok = self.project.is_some();
@@ -565,13 +632,17 @@ impl eframe::App for App {
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
let is_processing = self.batch_processing_index.is_some(); 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 { for event in events {
match event { match event {
BatchPanelEvent::AddItem(project) => { BatchPanelEvent::AddItem(mut project) => {
let probe = FfprobeGateway;
let _ = LoadMediaInfo::execute(&mut project, &probe);
self.batch_items.push(BatchItem::new(project)); self.batch_items.push(BatchItem::new(project));
} }
BatchPanelEvent::EditItem(idx, project) => { BatchPanelEvent::EditItem(idx, mut project) => {
let probe = FfprobeGateway;
let _ = LoadMediaInfo::execute(&mut project, &probe);
if let Some(item) = self.batch_items.get_mut(idx) { if let Some(item) = self.batch_items.get_mut(idx) {
item.project = project; item.project = project;
item.state = ExecutionState::Idle; item.state = ExecutionState::Idle;
@@ -605,7 +676,8 @@ impl eframe::App for App {
.show(ui, |ui| { .show(ui, |ui| {
if let Some(project) = &mut self.project { if let Some(project) = &mut self.project {
// ── Faixas existentes ───────────────────────────────── // ── 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 { for event in events {
use crate::domain::entities::TrackKind; use crate::domain::entities::TrackKind;
use crate::ui::components::existing_track_list::ExistingTrackEvent; use crate::ui::components::existing_track_list::ExistingTrackEvent;
@@ -613,6 +685,12 @@ impl eframe::App for App {
ExistingTrackEvent::OffsetChanged(id, offset) => { ExistingTrackEvent::OffsetChanged(id, offset) => {
let _ = EditExistingTrackSync::execute(project, 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) => { ExistingTrackEvent::ExportRequested(id) => {
if let Some(track) = if let Some(track) =
project.existing_tracks.iter().find(|t| t.id == id).cloned() project.existing_tracks.iter().find(|t| t.id == id).cloned()
@@ -681,7 +759,7 @@ impl eframe::App for App {
.allocate_ui_with_layout( .allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0), egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min), egui::Layout::top_down(egui::Align::Min),
|ui| self.add_audio_form.ui(ui), |ui| self.add_audio_form.ui(ui, mkvmerge_available),
) )
.inner; .inner;
@@ -689,37 +767,57 @@ impl eframe::App for App {
.allocate_ui_with_layout( .allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0), egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min), egui::Layout::top_down(egui::Align::Min),
|ui| self.add_subtitle_form.ui(ui), |ui| self.add_subtitle_form.ui(ui, mkvmerge_available),
) )
.inner; .inner;
(audio_req, subtitle_req) (audio_req, subtitle_req)
} else { } else {
let ar = self.add_audio_form.ui(ui); let ar = self.add_audio_form.ui(ui, mkvmerge_available);
ui.add_space(4.0); 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) (ar, sr)
}; };
if let Some(req) = audio_req { if let Some(req) = audio_req {
let _ = let _ = AddAudioTrack::execute(
AddAudioTrack::execute(project, req.path, req.offset, req.language); project,
req.path,
req.offset,
req.language,
req.is_default,
req.title,
req.drift_scale,
);
} }
if let Some(req) = subtitle_req { if let Some(req) = subtitle_req {
let _ = let _ = AddSubtitle::execute(
AddSubtitle::execute(project, req.path, req.offset, req.language); project,
req.path,
req.offset,
req.language,
req.is_default,
req.title,
req.drift_scale,
);
} }
ui.add_space(8.0); ui.add_space(8.0);
// ── Faixas externas adicionadas ─────────────────────── // ── Faixas externas adicionadas ───────────────────────
let remove_events = self.added_track_list.ui(ui, &project.tracks); let track_events = self.added_track_list.ui(ui, &project.tracks);
for event in remove_events { for event in track_events {
use crate::ui::components::added_track_list::AddedTrackEvent; use crate::ui::components::added_track_list::AddedTrackEvent;
match event { match event {
AddedTrackEvent::RemoveRequested(id) => { AddedTrackEvent::RemoveRequested(id) => {
let _ = RemoveTrack::execute(project, id); let _ = RemoveTrack::execute(project, id);
} }
AddedTrackEvent::MoveUp(id) => {
project.move_track(id, -1);
}
AddedTrackEvent::MoveDown(id) => {
project.move_track(id, 1);
}
} }
} }
+72 -4
View File
@@ -1,13 +1,20 @@
use eframe::egui; use crate::adapters::ffmpeg::{FfprobeGateway};
use crate::adapters::filesystem::FilePickerAdapter; 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::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField}; use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui;
/// Evento emitido ao confirmar adição de faixa de áudio. /// Evento emitido ao confirmar adição de faixa de áudio.
pub struct AddAudioTrackRequest { pub struct AddAudioTrackRequest {
pub path: FilePath, pub path: FilePath,
pub offset: SyncOffset, pub offset: SyncOffset,
pub language: TrackLanguage, 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. /// Formulário para adicionar uma faixa de áudio externa.
@@ -15,6 +22,12 @@ pub struct AddAudioTrackForm {
selected_path: Option<FilePath>, selected_path: Option<FilePath>,
offset_field: SyncOffsetField, offset_field: SyncOffsetField,
language_field: LanguageField, 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 { impl AddAudioTrackForm {
@@ -23,11 +36,29 @@ impl AddAudioTrackForm {
selected_path: None, selected_path: None,
offset_field: SyncOffsetField::new(SyncOffset::default()), offset_field: SyncOffsetField::new(SyncOffset::default()),
language_field: LanguageField::new(None), 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. /// 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; let mut result = None;
ui.group(|ui| { ui.group(|ui| {
@@ -42,12 +73,37 @@ impl AddAudioTrackForm {
ui.label(&label); ui.label(&label);
if ui.button("Selecionar áudio…").clicked() { if ui.button("Selecionar áudio…").clicked() {
self.selected_path = FilePickerAdapter::pick_audio(); 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); self.language_field.ui(ui);
ui.horizontal(|ui| {
ui.label("Nome da faixa:");
ui.text_edit_singleline(&mut self.title_input)
.on_hover_text(
"Nome exibido no player (ex: \"Português\", \"Comentários\").\n\
Deixe vazio para remover o nome original do arquivo fonte.\n\
Recomendado para evitar nomes como \"ISO Media file produced by Google Inc.\"",
);
});
ui.checkbox(&mut self.is_default, "Definir como faixa padrão")
.on_hover_text(
"Marca esta faixa como padrão no container MKV.\n\
Recomendado para que players como Jellyfin selecionem este áudio automaticamente.",
);
ui.add_space(4.0); ui.add_space(4.0);
let can_add = self.selected_path.is_some(); let can_add = self.selected_path.is_some();
@@ -60,11 +116,23 @@ impl AddAudioTrackForm {
self.offset_field.parse(), self.offset_field.parse(),
self.language_field.parse(), self.language_field.parse(),
) { ) {
result = Some(AddAudioTrackRequest { path, offset, language }); 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 // Reset form
self.selected_path = None; self.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default()); self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.selected_duration_ms = None;
self.update_suggestion();
self.language_field = LanguageField::new(None); self.language_field = LanguageField::new(None);
self.is_default = false;
self.title_input = String::new();
} }
} }
}); });
+71 -4
View File
@@ -1,13 +1,20 @@
use eframe::egui; use crate::adapters::ffmpeg::FfprobeGateway;
use crate::adapters::filesystem::FilePickerAdapter; 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::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField}; use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui;
/// Evento emitido ao confirmar adição de legenda. /// Evento emitido ao confirmar adição de legenda.
pub struct AddSubtitleRequest { pub struct AddSubtitleRequest {
pub path: FilePath, pub path: FilePath,
pub offset: SyncOffset, pub offset: SyncOffset,
pub language: TrackLanguage, 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. /// Formulário para adicionar uma faixa de legenda externa.
@@ -15,6 +22,12 @@ pub struct AddSubtitleForm {
selected_path: Option<FilePath>, selected_path: Option<FilePath>,
offset_field: SyncOffsetField, offset_field: SyncOffsetField,
language_field: LanguageField, 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 { impl AddSubtitleForm {
@@ -23,11 +36,29 @@ impl AddSubtitleForm {
selected_path: None, selected_path: None,
offset_field: SyncOffsetField::new(SyncOffset::default()), offset_field: SyncOffsetField::new(SyncOffset::default()),
language_field: LanguageField::new(None), 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. /// 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; let mut result = None;
ui.group(|ui| { ui.group(|ui| {
@@ -42,12 +73,36 @@ impl AddSubtitleForm {
ui.label(&label); ui.label(&label);
if ui.button("Selecionar legenda…").clicked() { if ui.button("Selecionar legenda…").clicked() {
self.selected_path = FilePickerAdapter::pick_subtitle(); 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); self.language_field.ui(ui);
ui.horizontal(|ui| {
ui.label("Nome da faixa:");
ui.text_edit_singleline(&mut self.title_input)
.on_hover_text(
"Nome exibido no player (ex: \"Português\", \"Forçada\").\n\
Deixe vazio para remover o nome original do arquivo fonte.",
);
});
ui.checkbox(&mut self.is_default, "Definir como faixa padrão")
.on_hover_text(
"Marca esta faixa como padrão no container MKV.\n\
Recomendado para que players como Jellyfin selecionem esta legenda automaticamente.",
);
ui.add_space(4.0); ui.add_space(4.0);
let can_add = self.selected_path.is_some(); let can_add = self.selected_path.is_some();
@@ -60,10 +115,22 @@ impl AddSubtitleForm {
self.offset_field.parse(), self.offset_field.parse(),
self.language_field.parse(), self.language_field.parse(),
) { ) {
result = Some(AddSubtitleRequest { path, offset, language }); 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.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default()); self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.selected_duration_ms = None;
self.update_suggestion();
self.language_field = LanguageField::new(None); 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. /// Evento emitido por interações com a lista de faixas externas adicionadas.
pub enum AddedTrackEvent { pub enum AddedTrackEvent {
RemoveRequested(TrackId), RemoveRequested(TrackId),
MoveUp(TrackId),
MoveDown(TrackId),
} }
/// Lista as faixas externas adicionadas pelo usuário e permite removê-las. /// Lista as faixas externas adicionadas pelo usuário e permite removê-las.
@@ -15,9 +17,10 @@ impl AddedTrackList {
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> { pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> {
let mut events = Vec::new(); let mut events = Vec::new();
let total = tracks.len();
ui.group(|ui| { ui.group(|ui| {
ui.heading("Faixas adicionadas"); ui.heading("Faixas adicionadas");
@@ -28,17 +31,18 @@ impl AddedTrackList {
} }
egui::Grid::new("added_tracks_grid") egui::Grid::new("added_tracks_grid")
.num_columns(4) .num_columns(5)
.max_col_width(200.0) .max_col_width(200.0)
.striped(true) .striped(true)
.show(ui, |ui| { .show(ui, |ui| {
ui.strong("Tipo"); ui.strong("Tipo");
ui.strong("Arquivo"); ui.strong("Arquivo");
ui.strong("Idioma"); ui.strong("Idioma");
ui.strong("Ordem");
ui.strong(""); ui.strong("");
ui.end_row(); ui.end_row();
for track in tracks { for (idx, track) in tracks.iter().enumerate() {
let (kind_label, file_name, language) = match track { let (kind_label, file_name, language) = match track {
Track::Audio(t) => ( Track::Audio(t) => (
"Áudio", "Áudio",
@@ -67,6 +71,28 @@ impl AddedTrackList {
.on_hover_text(track_full_path(track)); .on_hover_text(track_full_path(track));
ui.label(language); 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() { if ui.small_button("🗑 Remover").clicked() {
events.push(AddedTrackEvent::RemoveRequested(track.id())); events.push(AddedTrackEvent::RemoveRequested(track.id()));
} }
+57 -15
View File
@@ -11,11 +11,14 @@ use crate::ui::components::{
// ── Tipos públicos ───────────────────────────────────────────────────────────── // ── Tipos públicos ─────────────────────────────────────────────────────────────
/// Um item no carrinho de lote — projeto completo com estado individual de execução. /// Um item na fila
/// de lote — projeto completo com estado individual de execução.
pub struct BatchItem { pub struct BatchItem {
pub project: Project, pub project: Project,
pub state: ExecutionState, pub state: ExecutionState,
pub log_lines: Vec<String>, pub log_lines: Vec<String>,
/// Comando completo enviado ao FFmpeg/mkvmerge na última geração deste item.
pub last_command: Option<String>,
} }
impl BatchItem { impl BatchItem {
@@ -24,17 +27,18 @@ impl BatchItem {
project, project,
state: ExecutionState::Idle, state: ExecutionState::Idle,
log_lines: Vec::new(), log_lines: Vec::new(),
last_command: None,
} }
} }
} }
/// Eventos emitidos pelo `BatchPanel` para o `App`. /// Eventos emitidos pelo `BatchPanel` para o `App`.
pub enum BatchPanelEvent { pub enum BatchPanelEvent {
/// Novo projeto confirmado pelo usuário — adicionar ao carrinho. /// Novo projeto confirmado pelo usuário — adicionar à fila.
AddItem(Project), AddItem(Project),
/// Substituir o projeto de um item existente (edição confirmada). /// Substituir o projeto de um item existente (edição confirmada).
EditItem(usize, Project), EditItem(usize, Project),
/// Remover item do carinho pelo índice. /// Remover item da fila pelo índice.
RemoveItem(usize), RemoveItem(usize),
/// Iniciar o processamento sequencial de todos os itens aguardando. /// Iniciar o processamento sequencial de todos os itens aguardando.
ProcessAll, ProcessAll,
@@ -48,17 +52,23 @@ struct PendingAudio {
path: FilePath, path: FilePath,
offset: SyncOffset, offset: SyncOffset,
language: TrackLanguage, language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
} }
struct PendingSubtitle { struct PendingSubtitle {
path: FilePath, path: FilePath,
offset: SyncOffset, offset: SyncOffset,
language: TrackLanguage, language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
} }
// ── BatchPanel ───────────────────────────────────────────────────────────────── // ── BatchPanel ─────────────────────────────────────────────────────────────────
/// Painel de modo lote: exibe o carrinho de projetos e um formulário inline /// Painel de modo lote: exibe a fila de projetos e um formulário inline
/// para adicionar ou editar itens sem abandonar a aba. /// para adicionar ou editar itens sem abandonar a aba.
pub struct BatchPanel { pub struct BatchPanel {
// Estado do formulário de adição / edição // Estado do formulário de adição / edição
@@ -116,11 +126,17 @@ impl BatchPanel {
path: t.path.clone(), path: t.path.clone(),
offset: t.offset, offset: t.offset,
language: t.language.clone(), 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 { Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle {
path: t.path.clone(), path: t.path.clone(),
offset: t.offset, offset: t.offset,
language: t.language.clone(), language: t.language.clone(),
is_default: t.is_default,
title: t.title.clone(),
drift_scale: t.drift_scale,
}), }),
} }
} }
@@ -132,24 +148,25 @@ impl BatchPanel {
ui: &mut egui::Ui, ui: &mut egui::Ui,
items: &[BatchItem], items: &[BatchItem],
is_processing: bool, is_processing: bool,
mkvmerge_available: bool,
) -> Vec<BatchPanelEvent> { ) -> Vec<BatchPanelEvent> {
let mut events = Vec::new(); let mut events = Vec::new();
ui.heading("Modo Lote"); ui.heading("Modo Lote");
ui.label( ui.label(
egui::RichText::new( egui::RichText::new(
"Adicione múltiplos projetos ao carrinho e processe-os sequencialmente.", "Adicione múltiplos projetos à fila e processe-os sequencialmente.",
) )
.weak() .weak()
.italics(), .italics(),
); );
ui.add_space(8.0); ui.add_space(8.0);
// ── Lista de itens do carrinho ───────────────────────────────────────── // ── Lista de itens da fila ─────────────────────────────────────────
if items.is_empty() && !self.form_open { if items.is_empty() && !self.form_open {
ui.label( ui.label(
egui::RichText::new( egui::RichText::new(
"Nenhum item no carrinho. Clique em \"+ Adicionar item\" para começar.", "Nenhum item na fila. Clique em \"+ Adicionar item\" para começar.",
) )
.weak() .weak()
.italics(), .italics(),
@@ -240,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 }); // push_id
ui.add_space(4.0); ui.add_space(4.0);
} }
@@ -260,7 +290,7 @@ impl BatchPanel {
self.form_open = true; self.form_open = true;
} }
} else { } else {
self.render_form(ui, &mut events); self.render_form(ui, &mut events, mkvmerge_available);
} }
} }
@@ -289,7 +319,7 @@ impl BatchPanel {
.fill(egui::Color32::DARK_RED), .fill(egui::Color32::DARK_RED),
) )
.on_hover_text( .on_hover_text(
"Interrompe o item em execução; os demais permanecem no carrinho.", "Interrompe o item em execução; os demais permanecem na fila.",
) )
.clicked() .clicked()
{ {
@@ -309,7 +339,7 @@ impl BatchPanel {
} }
/// Renderiza o formulário inline de adição ou edição de item. /// 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 is_edit = self.editing_index.is_some();
let title = if let Some(idx) = self.editing_index { let title = if let Some(idx) = self.editing_index {
format!("Editar item #{}", idx + 1) format!("Editar item #{}", idx + 1)
@@ -433,7 +463,7 @@ impl BatchPanel {
.allocate_ui_with_layout( .allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0), egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min), egui::Layout::top_down(egui::Align::Min),
|ui| self.form_add_audio.ui(ui), |ui| self.form_add_audio.ui(ui, mkvmerge_available),
) )
.inner; .inner;
@@ -441,15 +471,15 @@ impl BatchPanel {
.allocate_ui_with_layout( .allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0), egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min), egui::Layout::top_down(egui::Align::Min),
|ui| self.form_add_subtitle.ui(ui), |ui| self.form_add_subtitle.ui(ui, mkvmerge_available),
) )
.inner; .inner;
(ar, sr) (ar, sr)
} else { } else {
let ar = self.form_add_audio.ui(ui); let ar = self.form_add_audio.ui(ui, mkvmerge_available);
ui.add_space(2.0); 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) (ar, sr)
}; };
@@ -458,6 +488,9 @@ impl BatchPanel {
path: req.path, path: req.path,
offset: req.offset, offset: req.offset,
language: req.language, language: req.language,
is_default: req.is_default,
title: req.title,
drift_scale: req.drift_scale,
}); });
} }
if let Some(req) = subtitle_req { if let Some(req) = subtitle_req {
@@ -465,6 +498,9 @@ impl BatchPanel {
path: req.path, path: req.path,
offset: req.offset, offset: req.offset,
language: req.language, language: req.language,
is_default: req.is_default,
title: req.title,
drift_scale: req.drift_scale,
}); });
} }
@@ -485,7 +521,7 @@ impl BatchPanel {
let confirm_label = if is_edit { let confirm_label = if is_edit {
"Salvar alterações" "Salvar alterações"
} else { } else {
"Adicionar ao carrinho" "Adicionar à fila"
}; };
if ui if ui
@@ -506,6 +542,9 @@ impl BatchPanel {
t.path, t.path,
t.offset, t.offset,
t.language, t.language,
t.is_default,
t.title,
t.drift_scale,
); );
} }
for s in subs { for s in subs {
@@ -514,6 +553,9 @@ impl BatchPanel {
s.path, s.path,
s.offset, s.offset,
s.language, s.language,
s.is_default,
s.title,
s.drift_scale,
); );
} }
if let Some(idx) = self.editing_index { if let Some(idx) = self.editing_index {
+20
View File
@@ -15,6 +15,9 @@ pub enum ExecutionState {
pub struct ExecutionPanel { pub struct ExecutionPanel {
pub state: ExecutionState, pub state: ExecutionState,
pub log_lines: Vec<String>, 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 { impl ExecutionPanel {
@@ -22,6 +25,7 @@ impl ExecutionPanel {
ExecutionPanel { ExecutionPanel {
state: ExecutionState::Idle, state: ExecutionState::Idle,
log_lines: Vec::new(), 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) (generate_clicked, cancel_clicked)
+158 -8
View File
@@ -7,19 +7,26 @@ use std::collections::HashMap;
/// Evento emitido por interações com a lista de faixas existentes. /// Evento emitido por interações com a lista de faixas existentes.
pub enum ExistingTrackEvent { pub enum ExistingTrackEvent {
OffsetChanged(TrackId, SyncOffset), 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`. /// O usuário solicitou exportar a faixa identificada por `TrackId`.
ExportRequested(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 { pub struct ExistingTrackList {
offset_fields: HashMap<u32, SyncOffsetField>, offset_fields: HashMap<u32, SyncOffsetField>,
/// Campos de drift raw por track id (percentual digitado pelo usuário).
drift_fields: HashMap<u32, String>,
} }
impl ExistingTrackList { impl ExistingTrackList {
pub fn new() -> Self { pub fn new() -> Self {
ExistingTrackList { ExistingTrackList {
offset_fields: HashMap::new(), offset_fields: HashMap::new(),
drift_fields: HashMap::new(),
} }
} }
@@ -29,14 +36,20 @@ impl ExistingTrackList {
self.offset_fields self.offset_fields
.entry(track.id.val()) .entry(track.id.val())
.or_insert_with(|| SyncOffsetField::new(track.offset)); .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( pub fn ui(
&mut self, &mut self,
ui: &mut egui::Ui, ui: &mut egui::Ui,
tracks: &[MediaTrackInfo], tracks: &[MediaTrackInfo],
mkvmerge_available: bool,
) -> Vec<ExistingTrackEvent> { ) -> Vec<ExistingTrackEvent> {
let mut events = Vec::new(); let mut events = Vec::new();
@@ -48,18 +61,30 @@ impl ExistingTrackList {
return; 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") egui::Grid::new("existing_tracks_grid")
.num_columns(5) .num_columns(num_cols)
.striped(true) .striped(true)
.show(ui, |ui| { .show(ui, |ui| {
ui.strong("#"); ui.strong("#");
ui.strong("Tipo"); ui.strong("Tipo");
ui.strong("Codec"); ui.strong("Codec");
ui.strong("Duração");
ui.strong("Atraso"); ui.strong("Atraso");
ui.strong(""); if mkvmerge_available {
ui.strong("Velocidade (%)");
}
ui.strong(""); // exportar
ui.strong(""); // excluir
ui.end_row(); ui.end_row();
for track in tracks { for track in tracks {
let editable = matches!(track.kind, TrackKind::Audio | TrackKind::Subtitle) && !track.excluded;
let kind_label = match track.kind { let kind_label = match track.kind {
TrackKind::Video => "Vídeo", TrackKind::Video => "Vídeo",
TrackKind::Audio => "Áudio", TrackKind::Audio => "Áudio",
@@ -72,22 +97,111 @@ impl ExistingTrackList {
.map(|l| format!(" ({})", l)) .map(|l| format!(" ({})", l))
.unwrap_or_default(); .unwrap_or_default();
ui.label(format!("{}", track.stream_index)); // Cor de texto: esmaecida quando a faixa está excluída do output
ui.label(format!("{}{}", kind_label, lang)); let dim_color = egui::Color32::from_gray(120);
ui.label(&track.codec); 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 let field = self
.offset_fields .offset_fields
.entry(track.id.val()) .entry(track.id.val())
.or_insert_with(|| SyncOffsetField::new(track.offset)); .or_insert_with(|| SyncOffsetField::new(track.offset));
ui.add_enabled_ui(!track.excluded, |ui| {
field.ui(ui, ""); field.ui(ui, "");
});
if !track.excluded {
if let Some(offset) = field.parse() { if let Some(offset) = field.parse() {
if offset != track.offset { if offset != track.offset {
events.push(ExistingTrackEvent::OffsetChanged(track.id, 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
}
}
// Botão de exportar (somente Áudio e Legenda) // Botão de exportar (somente Áudio e Legenda)
match track.kind { match track.kind {
@@ -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(); ui.end_row();
} }
}); });
@@ -109,3 +245,17 @@ impl ExistingTrackList {
events 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"). /// Campo de entrada para offset de sincronização em segundos (ex: "-1.2").
/// Converte para SyncOffset(ms) internamente ao confirmar. /// 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 struct SyncOffsetField {
pub raw: String, pub raw: String,
pub error: Option<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 { impl SyncOffsetField {
@@ -14,10 +20,19 @@ impl SyncOffsetField {
SyncOffsetField { SyncOffsetField {
raw: format!("{:.1}", seconds), raw: format!("{:.1}", seconds),
error: None, 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 { pub fn ui(&mut self, ui: &mut egui::Ui, label: &str) -> bool {
let mut changed = false; let mut changed = false;
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -35,6 +50,71 @@ impl SyncOffsetField {
changed 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. /// Tenta converter o valor atual para SyncOffset.
pub fn parse(&mut self) -> Option<SyncOffset> { pub fn parse(&mut self) -> Option<SyncOffset> {
match SyncOffset::from_seconds_str(&self.raw) { 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
}
}
}
} }