Compare commits

..
15 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
Felipe 3e38c51e3a feat: adiciona suporte para sobrescrever arquivos de saída no FFmpeg e atualiza ícones de estado na interface 2026-02-28 20:53:19 -03:00
Felipe d69ce92e32 feat: ajusta rótulo do botão de lote para remover ícone e melhorar legibilidade 2026-02-28 20:40:04 -03:00
Felipe 121e919bbf feat: implement batch processing feature with tabbed interface
- Added `ActiveTab` enum to manage active tab state in `App`.
- Created `BatchItem` struct and integrated `batch_items` vector in `App`.
- Developed `BatchPanel` component for managing batch projects with inline forms.
- Implemented sequential processing in `App::start_batch_item()` with automatic advancement in `poll_background()`.
- Updated UI to display individual item states (` Aguardando | ⟳ Processando | ✓ Concluído | ⊸ Cancelado | ✗ Erro`).
- Blocked item addition/removal during processing.
- Enhanced session persistence to include batch projects in `SessionData`.
- Updated session save/load functions to handle new session structure.
2026-02-28 20:28:24 -03:00
Felipe 5d8e92f33f feat: implementa persistência de sessão com opções para salvar e carregar estado do projeto 2026-02-28 19:44:45 -03:00
Felipe 18739c3ce7 feat: atualiza versão do projeto para 1.3 e revisa o plano de desenvolvimento e PRD com novas funcionalidades e requisitos 2026-02-28 18:19:03 -03:00
40 changed files with 3469 additions and 695 deletions
-245
View File
@@ -1,245 +0,0 @@
# Plano de Desenvolvimento — Simple Multimedia Track Audio Editor
**Versão:** 1.1
**Data:** 28/02/2026
**Status:** Concluído — todas as fases implementadas, 33 testes passando
**Referência:** PRD v1.1
---
## 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`
---
## 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)
+175 -69
View File
@@ -1,9 +1,9 @@
# PRD — Simple Multimedia Track Audio Editor # PRD — Simple Multimedia Track Audio Editor
**Versão:** 1.1 **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:** Incorporados feedbacks de arquitetura — modelo de sessão, ffprobe, tipos fortes e regra de no-reencode **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
@@ -105,6 +118,64 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
- A faixa original é remapeada com o novo timestamp via `ffmpeg -itsoffset` combinado com `-map` - A faixa original é remapeada com o novo timestamp via `ffmpeg -itsoffset` combinado com `-map`
- Útil para corrigir atrasos em faixas de dublagem ou legenda já incorporadas ao arquivo - Útil para corrigir atrasos em faixas de dublagem ou legenda já incorporadas ao arquivo
### RF-09 — Processamento em lote
- A interface oferece duas abas: **Projeto Único** (fluxo atual) e **Lote**
- A aba Lote funciona como um **carrinho**: o usuário adiciona, revisa e remove itens livremente antes de processar
- Cada item é um projeto completo e independente (vídeo, faixas e saída próprios), configurado via **formulário inline** na própria aba Lote — sem navegação para outra tela
- A seleção de arquivos usa **diálogos nativos** (igual ao modo Projeto Único) — nenhum path é digitado manualmente
- O processamento é **sequencial** — um projeto por vez, sem paralelismo, para evitar sobrecarga de I/O
- Durante o processamento, o carrinho é bloqueado: não é possível adicionar nem remover itens
- Cada item exibe seu estado individual: `⏳ Aguardando | ⟳ Processando | ✓ Concluído | ⊸ Cancelado | ✗ Erro`
- É possível cancelar o item em execução; os demais permanecem no carrinho com estado `Aguardando`
- O dispatch FFmpeg/mkvmerge por item é automático: se qualquer faixa do item tiver `drift_scale ≠ 1.0`, o motor mkvmerge é usado
### RF-12 — Correção de drift progressivo de sincronização
- O usuário pode especificar um fator de velocidade original da faixa em porcentagem (ex: `99.983%`)
- Internamente representado como `drift_scale: f64` em cada faixa (`AudioTrack`, `SubtitleTrack`, `MediaTrackInfo`)
- Implementado via `mkvmerge --sync TID:DELAY,NUM/DEN` — aplica escala de timestamps no nível do container, sem reencoding
- **Dispatch automático:** se qualquer faixa tiver `scale ≠ 1.0`, o projeto inteiro usa o pipeline `mkvmerge` em vez do FFmpeg
- Campo exibido na interface como **"Velocidade original (%)"** — jamais expõe termos técnicos como `scale`, `drift` ou `mkvmerge`
- O campo é desabilitado com tooltip explicativo quando `mkvmerge` não está disponível no `PATH`
- Funciona tanto no modo Projeto Único quanto no modo Lote
- Preserva a invariante RNF-01: nenhum byte de mídia é reprocessado
### RF-13 — Exportar faixa existente
- A partir da lista de faixas detectadas pelo `ffprobe`, o usuário pode exportar qualquer faixa de áudio ou legenda para um arquivo separado
- O formato do arquivo de saída é inferido a partir do codec da faixa (ex: AAC → `.aac`, SRT → `.srt`)
- Implementado via `FfmpegCommandBuilder::build_export()` com `-c:a copy` para áudio; legendas omitem `-c` (conversão de container de texto, sem processamento de mídia)
- O diálogo de salvamento usa filtro por extensão correspondente ao codec detectado
### RF-14 — Persistência de sessão
- O usuário pode salvar o estado completo do projeto em disco com o botão **"💾 Salvar sessão"**
- O estado é restaurado com o botão **"📂 Carregar sessão"**
- Formato: JSON via `serde_json`; local: `~/.config/simple-mkv-editor/session.json` (Linux) / `AppData` (Windows)
- Feedback visual no header: indicador verde (sucesso) ou vermelho (erro) com botão ✕ para dispensar
- Erros de desserialização (arquivo corrompido ou versão incompatível) são tratados com mensagem amigável
- Salvamento automático não é realizado — apenas sob demanda do usuário
### RF-15 — Excluir faixa existente do arquivo de saída
- O usuário pode marcar qualquer faixa de áudio ou legenda **já presente no arquivo de entrada** para ser omitida do arquivo de saída
- A operação é reversível: a faixa pode ser restaurada antes da geração do MKV
- Faixas marcadas como excluídas são exibidas com texto tachado e cor esmaecida na lista; seus campos de offset e drift ficam desabilitados
- Faixas de vídeo e dados **não** podem ser excluídas — somente áudio e legenda
- Implementado via campo `excluded: bool` em `MediaTrackInfo`; método `toggle_existing_track_excluded()` em `Project`
- Ambos os pipelines (FFmpeg e mkvmerge) respeitam o flag: faixas excluídas são filtradas antes da geração dos argumentos de `-map` / `--audio-tracks` / `--subtitle-tracks`
- O estado `excluded` é persistido junto com a sessão (RF-14)
### RF-16 — Reordenar faixas externas
- O usuário pode alterar a ordem das faixas externas adicionadas (áudio e legenda) usando os botões **↑** e **↓** na lista de faixas
- A ordem determina o índice de stream no container MKV final — relevante para players que selecionam faixas por posição (ex: VLC, Jellyfin)
- O botão ↑ fica desabilitado na primeira faixa da lista; o botão ↓, na última
- Implementado via método `move_track(id, delta: i8)` em `Project`, usando `Vec::swap` — ambos os pipelines (FFmpeg e mkvmerge) iteram `project.tracks` em ordem e não precisam de alteração
- Somente faixas **externas** (`project.tracks`) são reordenáveis; faixas existentes (`project.existing_tracks`, lidas via ffprobe) permanecem na ordem detectada
- A nova ordem é persistida automaticamente junto com a sessão (RF-14)
--- ---
## 7. Requisitos Não Funcionais ## 7. Requisitos Não Funcionais
@@ -134,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
@@ -158,15 +231,17 @@ 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 |
| Container | MKV | Melhor suporte a múltiplas faixas | | Mídia (mux) | FFmpeg (externo) | Mux sem reencoding; caminho padrão |
| Mídia (drift) | mkvmerge / MKVToolNix (externo) | Correção de drift sem reencoding (opcional) |
| Container | MKV | Melhor suporte a múltiplas faixas |
### Dependências Cargo ### Dependências Cargo
@@ -174,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"
``` ```
--- ---
@@ -208,34 +285,38 @@ As dependências sempre apontam de fora para dentro. Camadas internas não conhe
Contém as entidades e objetos de valor do negócio. Não depende de nada externo. Contém as entidades e objetos de valor do negócio. Não depende de nada externo.
| 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.
> >
> ``` > ```
> Project > Project
> ├── source: VideoFile > ├── source: VideoFile
> ├── tracks: Vec<Track> // faixas externas adicionadas > ├── tracks: Vec<Track> // faixas externas adicionadas
> ├── existing_tracks: Vec<MediaTrackInfo> // faixas lidas do arquivo via ffprobe > ├── existing_tracks: Vec<MediaTrackInfo> // faixas lidas do arquivo via ffprobe
> └── 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)
Contém as regras de negócio da aplicação. Orquestra as entidades do domínio. 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`.
> >
@@ -253,25 +334,30 @@ 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
Camada mais externa. Contém o framework de UI e a execução real de processos. Camada mais externa. Contém o framework de UI e a execução real de processos.
| Tipo | Exemplos | | Tipo | Exemplos |
|--------------|-------------------------------------------------------| | ---------- | ------------------------------------------------------ |
| UI | `App` (eframe), componentes egui | | UI | `App` (eframe), componentes egui |
| Process | Execução de `std::process::Command` / `tokio::process`| | Process | Execução de `std::process::Command` / `tokio::process` |
| Filesystem | Leitura e escrita de arquivos | | Filesystem | Leitura e escrita de arquivos |
--- ---
@@ -281,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
``` ```
@@ -300,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 |
--- ---
@@ -315,10 +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)
- Modo de processamento em lote (batch) - Testes de snapshot para o `FfmpegCommandBuilder` e `MkvmergeCommandBuilder` com projetos complexos
--- ---
@@ -331,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)
--- ---
@@ -345,18 +450,18 @@ src/
O FFmpeg é uma ferramenta de poder absurdo — e opacidade equivalente. O usuário não entende `track index`, `stream mapping` ou `itsoffset`. O usuário entende: O FFmpeg é uma ferramenta de poder absurdo — e opacidade equivalente. O usuário não entende `track index`, `stream mapping` ou `itsoffset`. O usuário entende:
- *"Áudio em português está 1.2 segundos atrasado"* - _"Áudio em português está 1.2 segundos atrasado"_
- *"Quero adicionar a legenda em inglês"* - _"Quero adicionar a legenda em inglês"_
- *"Gerar o arquivo final"* - _"Gerar o arquivo final"_
Toda decisão de UX deve partir dessa perspectiva. Termos técnicos do FFmpeg nunca devem aparecer na interface. Toda decisão de UX deve partir dessa perspectiva. Termos técnicos do FFmpeg nunca devem aparecer na interface.
| FFmpeg (interno) | Interface (usuário) | | FFmpeg (interno) | Interface (usuário) |
|-----------------------|--------------------------------------| | -------------------- | ------------------------------ |
| `-itsoffset -1200ms` | "Adiantar 1.2s" | | `-itsoffset -1200ms` | "Adiantar 1.2s" |
| `stream 0:a:1` | "Faixa de áudio 2 — Português" | | `stream 0:a:1` | "Faixa de áudio 2 — Português" |
| `-c copy` | *(invisível — nunca exposto)* | | `-c copy` | _(invisível — nunca exposto)_ |
| `ffprobe output` | Lista de faixas detectadas | | `ffprobe output` | Lista de faixas detectadas |
--- ---
@@ -373,6 +478,7 @@ O MKV (Matroska) é estruturalmente um banco de dados multimídia. Suas operaç
O arquivo de saída não é uma criação nova — é uma nova **visão** do container original com faixas adicionadas e metadados ajustados, sem tocar nos bytes de mídia. O arquivo de saída não é uma criação nova — é uma nova **visão** do container original com faixas adicionadas e metadados ajustados, sem tocar nos bytes de mídia.
Esse modelo mental simplifica a implementação: Esse modelo mental simplifica a implementação:
- Sem buffers de vídeo - Sem buffers de vídeo
- Sem pipelines de transcoding - Sem pipelines de transcoding
- Apenas mapeamento de streams e metadados - Apenas mapeamento de streams e metadados
-231
View File
@@ -1,231 +0,0 @@
# Progresso de Implementação
**Data:** 28/02/2026
**Status:** Fases 16 concluídas + exportação de faixa + exibição/remoção de faixas adicionadas + layout responsivo + progresso em tempo real + cancelamento de geração — compilando, 33 testes passando, zero warnings do projeto, aplicação executável
**Referência:** DEVELOPMENT_PLAN.md v1.1
---
## 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
---
## 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 |
---
## 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
```
---
## 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
### 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)
+155 -3
View File
@@ -14,6 +14,10 @@ impl FfmpegCommandBuilder {
pub fn build(project: &Project) -> Vec<String> { pub fn build(project: &Project) -> Vec<String> {
let mut args: Vec<String> = Vec::new(); let mut args: Vec<String> = Vec::new();
// Sobrescreve o arquivo de saída sem prompt interativo.
// Sem isso o FFmpeg aguarda [y/N] no stdin e o processo trava.
args.push("-y".to_string());
// ── Inputs ────────────────────────────────────────────────────────────── // ── Inputs ──────────────────────────────────────────────────────────────
// Input 0: arquivo fonte (sempre sem itsoffset próprio) // Input 0: arquivo fonte (sempre sem itsoffset próprio)
args.push("-i".to_string()); args.push("-i".to_string());
@@ -57,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));
@@ -70,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());
@@ -87,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());
@@ -111,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());
@@ -212,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();
@@ -229,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();
@@ -248,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();
@@ -259,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);
}
}
+1
View File
@@ -1 +1,2 @@
pub mod persistence;
pub mod process; pub mod process;
+92
View File
@@ -0,0 +1,92 @@
use crate::domain::entities::Project;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Dados de sessão persistidos — abrange o Projeto Único e o carrinho de Lote.
#[derive(Serialize, Deserialize, Default)]
pub struct SessionData {
/// Projeto aberto na aba "Projeto Único".
pub single_project: Option<Project>,
/// Projetos do carrinho de Lote (estados de execução são descartados ao salvar).
pub batch_projects: Vec<Project>,
}
/// Retorna o caminho do arquivo de sessão de acordo com o SO.
///
/// - Linux / macOS : `$HOME/.config/simple-mkv-editor/session.json`
/// - Windows : `%APPDATA%\simple-mkv-editor\session.json`
fn session_path() -> PathBuf {
#[cfg(target_os = "windows")]
let base = std::env::var("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
#[cfg(not(target_os = "windows"))]
let base = std::env::var("HOME")
.map(|h| PathBuf::from(h).join(".config"))
.unwrap_or_else(|_| PathBuf::from("."));
base.join("simple-mkv-editor").join("session.json")
}
/// Verifica se existe um arquivo de sessão gravado em disco.
pub fn session_exists() -> bool {
session_path().exists()
}
/// Serializa a `SessionData` para JSON e grava no arquivo de sessão.
///
/// Cria o diretório pai automaticamente se ele não existir.
pub fn save_session(data: &SessionData) -> Result<()> {
let path = session_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Não foi possível criar o diretório de sessão: {}",
parent.display()
)
})?;
}
let json =
serde_json::to_string_pretty(data).context("Falha ao serializar a sessão")?;
std::fs::write(&path, json)
.with_context(|| format!("Falha ao gravar sessão em: {}", path.display()))?;
Ok(())
}
/// Lê e desserializa a `SessionData` do arquivo de sessão.
///
/// Retorna erro amigável se o arquivo estiver ausente, corrompido ou em
/// versão incompatível com a estrutura atual do domínio.
pub fn load_session() -> Result<SessionData> {
let path = session_path();
let json = std::fs::read_to_string(&path).with_context(|| {
format!(
"Não foi possível ler o arquivo de sessão: {}",
path.display()
)
})?;
// Tenta desserializar como SessionData (formato novo).
// Se falhar, tenta o formato antigo (Project direto) e migra automaticamente.
if let Ok(data) = serde_json::from_str::<SessionData>(&json) {
return Ok(data);
}
// Migração transparente: arquivo gerado pela Fase 7 continha apenas um Project.
serde_json::from_str::<crate::domain::entities::Project>(&json)
.map(|project| SessionData {
single_project: Some(project),
batch_projects: Vec::new(),
})
.with_context(|| {
"Falha ao carregar a sessão. O arquivo pode estar corrompido ou em versão incompatível. \
Delete o arquivo de sessão e tente novamente."
})
}
+74
View File
@@ -13,6 +13,7 @@ pub async fn run_ffmpeg_async(
) -> Result<()> { ) -> Result<()> {
let mut child = Command::new("ffmpeg") let mut child = Command::new("ffmpeg")
.args(&args) .args(&args)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped())
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.spawn() .spawn()
@@ -70,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"))
}
}
}
+509 -104
View File
@@ -1,17 +1,20 @@
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,
added_track_list::AddedTrackList, added_track_list::AddedTrackList,
batch_panel::{BatchItem, BatchPanel, BatchPanelEvent},
execution_panel::{ExecutionPanel, ExecutionState}, execution_panel::{ExecutionPanel, ExecutionState},
existing_track_list::ExistingTrackList, existing_track_list::ExistingTrackList,
output_selector::OutputSelector, output_selector::OutputSelector,
@@ -20,6 +23,13 @@ use crate::ui::components::{
use eframe::egui; use eframe::egui;
use std::sync::mpsc; use std::sync::mpsc;
/// Aba ativa da interface.
#[derive(PartialEq)]
enum ActiveTab {
Single,
Batch,
}
/// Mensagens enviadas do background thread para a UI. /// Mensagens enviadas do background thread para a UI.
enum BackgroundMsg { enum BackgroundMsg {
LogLine(String), LogLine(String),
@@ -50,6 +60,20 @@ pub struct App {
// Mensagem de erro global (ex: dependências ausentes) // Mensagem de erro global (ex: dependências ausentes)
global_error: Option<String>, global_error: Option<String>,
// Mensagem de feedback de persistência (sucesso ou erro)
session_msg: Option<(String, bool)>, // (texto, é_erro)
// Aba ativa (Projeto Único ou Lote)
active_tab: ActiveTab,
// Itens do carrinho de lote
batch_items: Vec<BatchItem>,
// Painel de modo lote com estado próprio
batch_panel: BatchPanel,
// Índice do item sendo processado no lote (None = nenhum)
batch_processing_index: Option<usize>,
// mkvmerge disponível no PATH (verificação soft na inicialização)
mkvmerge_available: bool,
} }
impl App { impl App {
@@ -59,6 +83,17 @@ impl App {
.err() .err()
.map(|e| e.to_string()); .map(|e| e.to_string());
// Notifica o usuário se existe sessão salva ao iniciar
let session_msg = if crate::infrastructure::persistence::session_exists() {
Some((
"Sessão anterior encontrada. Clique em \"Carregar sessão\" para restaurar."
.to_string(),
false,
))
} else {
None
};
App { App {
project: None, project: None,
pending_source: None, pending_source: None,
@@ -73,6 +108,12 @@ impl App {
bg_rx: None, bg_rx: None,
cancel_tx: None, cancel_tx: None,
global_error, global_error,
session_msg,
active_tab: ActiveTab::Single,
batch_items: Vec::new(),
batch_panel: BatchPanel::new(),
batch_processing_index: None,
mkvmerge_available: crate::infrastructure::process::mkvmerge_available(),
} }
} }
@@ -94,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());
@@ -108,45 +155,109 @@ impl App {
} }
/// Processa mensagens pendentes do background thread. /// Processa mensagens pendentes do background thread.
fn poll_background(&mut self) { /// Em modo lote, atualiza o item em execução e avança para o próximo automaticamente.
let mut done = false; fn poll_background(&mut self, ctx: &egui::Context) {
// Resultado do término: Ok ou Err(mensagem)
enum DoneResult {
Ok,
Err(String),
}
let mut done_result: Option<DoneResult> = None;
if let Some(rx) = &self.bg_rx { if let Some(rx) = &self.bg_rx {
loop { loop {
match rx.try_recv() { match rx.try_recv() {
Ok(BackgroundMsg::LogLine(line)) => { Ok(BackgroundMsg::LogLine(line)) => {
self.execution_panel.add_log(line); if let Some(idx) = self.batch_processing_index {
let item = &mut self.batch_items[idx];
item.log_lines.push(line);
if item.log_lines.len() > 200 {
item.log_lines.remove(0);
}
} else {
self.execution_panel.add_log(line);
}
} }
Ok(BackgroundMsg::Done) => { Ok(BackgroundMsg::Done) => {
self.execution_panel.state = ExecutionState::Success; done_result = Some(DoneResult::Ok);
done = true;
break; break;
} }
Ok(BackgroundMsg::Error(e)) => { Ok(BackgroundMsg::Error(e)) => {
self.execution_panel.state = ExecutionState::Error(e); done_result = Some(DoneResult::Err(e));
done = true;
break; break;
} }
Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => { Err(mpsc::TryRecvError::Disconnected) => {
done = true; done_result = Some(DoneResult::Ok);
break; break;
} }
} }
} }
} }
if done {
if let Some(result) = done_result {
self.bg_rx = None; self.bg_rx = None;
if let Some(idx) = self.batch_processing_index.take() {
// Modo lote: atualiza estado do item concluído
match result {
DoneResult::Ok => {
self.batch_items[idx].state = ExecutionState::Success;
}
DoneResult::Err(e) => {
self.batch_items[idx].state = ExecutionState::Error(e);
}
}
// Avança automaticamente para o próximo item aguardando
let next = self
.batch_items
.iter()
.position(|item| matches!(item.state, ExecutionState::Idle));
if let Some(next_idx) = next {
self.start_batch_item(next_idx, ctx.clone());
}
} else {
// Modo único
match result {
DoneResult::Ok => {
self.execution_panel.state = ExecutionState::Success;
}
DoneResult::Err(e) => {
self.execution_panel.state = ExecutionState::Error(e);
}
}
}
} }
} }
/// 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;
@@ -156,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
@@ -170,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 {
@@ -188,7 +301,7 @@ impl App {
}); });
} }
/// Cancela a geração em andamento, encerrando o processo FFmpeg. /// Cancela a geração em andamento (modo projeto único), encerrando o processo FFmpeg.
fn cancel_generation(&mut self) { fn cancel_generation(&mut self) {
if let Some(tx) = self.cancel_tx.take() { if let Some(tx) = self.cancel_tx.take() {
let _ = tx.send(()); let _ = tx.send(());
@@ -196,113 +309,375 @@ impl App {
self.bg_rx = None; self.bg_rx = None;
self.execution_panel.state = ExecutionState::Cancelled; self.execution_panel.state = ExecutionState::Cancelled;
} }
/// Inicia o processamento de um item específico do carrinho de lote.
/// Despacha para FFmpeg ou mkvmerge dependendo de `project.needs_mkvmerge()`.
fn start_batch_item(&mut self, index: usize, ctx: egui::Context) {
let project = self.batch_items[index].project.clone();
self.batch_items[index].state = ExecutionState::Running;
self.batch_items[index].log_lines.clear();
self.batch_processing_index = Some(index);
let uses_mkvmerge = project.needs_mkvmerge();
let args = if uses_mkvmerge {
MkvmergeCommandBuilder::build(&project)
} else {
FfmpegCommandBuilder::build(&project)
};
// Formatar comando legível para o painel de debug do item de lote
let binary = if uses_mkvmerge { "mkvmerge" } else { "ffmpeg" };
let cmd_str = format!(
"{} {}",
binary,
args.iter()
.map(|a| if a.contains(' ') { format!("\"{}\"", a) } else { a.clone() })
.collect::<Vec<_>>()
.join(" ")
);
self.batch_items[index].last_command = Some(cmd_str);
let (tx, rx) = mpsc::channel::<BackgroundMsg>();
self.bg_rx = Some(rx);
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
self.cancel_tx = Some(cancel_tx);
std::thread::spawn(move || {
let (log_tx, log_rx) = std::sync::mpsc::channel::<String>();
let fwd_tx = tx.clone();
let fwd_ctx = ctx.clone();
let fwd_handle = std::thread::spawn(move || {
for line in log_rx {
let _ = fwd_tx.send(BackgroundMsg::LogLine(line));
fwd_ctx.request_repaint();
}
});
let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio");
let result = if uses_mkvmerge {
rt.block_on(run_mkvmerge_async(args, log_tx, cancel_rx))
} else {
rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx))
};
let _ = fwd_handle.join();
match result {
Ok(_) => {
let _ = tx.send(BackgroundMsg::Done);
}
Err(e) => {
let _ = tx.send(BackgroundMsg::Error(e.to_string()));
}
}
ctx.request_repaint();
});
}
/// Cancela o item atual do lote, mantendo os demais como Aguardando.
fn cancel_batch_current(&mut self) {
if let Some(tx) = self.cancel_tx.take() {
let _ = tx.send(());
}
self.bg_rx = None;
if let Some(idx) = self.batch_processing_index.take() {
self.batch_items[idx].state = ExecutionState::Cancelled;
}
}
} }
impl eframe::App for App { impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Consome mensagens assíncronas // Consome mensagens assíncronas
self.poll_background(); self.poll_background(ctx);
if self.bg_rx.is_some() { if self.bg_rx.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100)); ctx.request_repaint_after(std::time::Duration::from_millis(100));
} }
// ── Cabeçalho (fixo no topo) ────────────────────────────────────────── // ── Cabeçalho (fixo no topo) ──────────────────────────────────────────
egui::TopBottomPanel::top("header_panel") let header_height =
.exact_height(if self.global_error.is_some() { 42.0 + if self.global_error.is_some() {
58.0 22.0
} else { } else {
38.0 0.0
}) } + if self.session_msg.is_some() {
22.0
} else {
0.0
};
egui::TopBottomPanel::top("header_panel")
.exact_height(header_height)
.show(ctx, |ui| { .show(ctx, |ui| {
ui.add_space(6.0); ui.add_space(4.0);
ui.heading("Editor de Faixas de Mídia"); ui.horizontal(|ui| {
ui.heading("Editor de Faixas de Mídia");
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
// Botão "Salvar sessão"
let can_save =
self.project.is_some() || !self.batch_items.is_empty();
if ui
.add_enabled(
can_save,
egui::Button::new("💾 Salvar sessão"),
)
.on_disabled_hover_text(
"Abra um projeto ou adicione itens ao lote para salvar a sessão.",
)
.clicked()
{
use crate::infrastructure::persistence::SessionData;
let data = SessionData {
single_project: self.project.clone(),
batch_projects: self
.batch_items
.iter()
.map(|item| item.project.clone())
.collect(),
};
match crate::infrastructure::persistence::save_session(&data) {
Ok(_) => {
self.session_msg = Some((
"Sessão salva com sucesso.".to_string(),
false,
));
}
Err(e) => {
self.session_msg =
Some((format!("Erro ao salvar: {}", e), true));
}
}
}
// Botão "Carregar sessão"
let can_load =
crate::infrastructure::persistence::session_exists();
if ui
.add_enabled(
can_load,
egui::Button::new("📂 Carregar sessão"),
)
.on_disabled_hover_text("Nenhuma sessão salva encontrada.")
.clicked()
{
match crate::infrastructure::persistence::load_session() {
Ok(data) => {
let probe = FfprobeGateway;
// Restaura projeto único
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 =
Some(project.source.path.clone());
self.pending_output =
Some(project.output.path.clone());
self.existing_track_list
.sync_tracks(&project.existing_tracks);
self.project = Some(project);
}
// 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() {
use crate::ui::components::batch_panel::BatchItem;
self.batch_items = data
.batch_projects
.into_iter()
.map(|mut project| {
let _ = LoadMediaInfo::execute(
&mut project,
&probe,
);
BatchItem::new(project)
})
.collect();
}
let single_ok = self.project.is_some();
let batch_count = self.batch_items.len();
self.session_msg = Some((
match (single_ok, batch_count) {
(true, 0) => "Sessão carregada com sucesso (projeto único).".to_string(),
(false, n) => format!("Sessão carregada com sucesso ({} ite{} no lote).", n, if n == 1 { "m" } else { "ns" }),
(true, n) => format!("Sessão carregada com sucesso (projeto único + {} ite{} no lote).", n, if n == 1 { "m" } else { "ns" }),
},
false,
));
}
Err(e) => {
self.session_msg = Some((
format!("Erro ao carregar sessão: {}", e),
true,
));
}
}
}
},
);
});
if let Some(ref err) = self.global_error { if let Some(ref err) = self.global_error {
ui.add_space(2.0);
ui.colored_label( ui.colored_label(
egui::Color32::RED, egui::Color32::RED,
format!("⚠ Dependência ausente: {}", err), format!("⚠ Dependência ausente: {}", err),
); );
} }
if let Some((msg, is_err)) = self.session_msg.clone() {
let color = if is_err {
egui::Color32::RED
} else {
egui::Color32::from_rgb(0, 180, 80)
};
ui.horizontal(|ui| {
ui.colored_label(color, &msg);
if ui.small_button("").clicked() {
self.session_msg = None;
}
});
}
}); });
// ── Painel de execução (fixo no rodapé) ─────────────────────────────── // ── Barra de abas ─────────────────────────────────────────────────────
egui::TopBottomPanel::bottom("execution_panel") egui::TopBottomPanel::top("tab_bar").show(ctx, |ui| {
.resizable(false) ui.horizontal(|ui| {
.show(ctx, |ui| { ui.selectable_value(&mut self.active_tab, ActiveTab::Single, "Projeto Único");
ui.add_space(4.0); ui.selectable_value(&mut self.active_tab, ActiveTab::Batch, "Lote");
let can_generate = self.pending_source.is_some() && self.pending_output.is_some();
let (generate, cancel) = self.execution_panel.ui(ui, can_generate);
if generate {
let ctx_clone = ctx.clone();
self.start_generation(ctx_clone);
}
if cancel {
self.cancel_generation();
}
ui.add_space(4.0);
}); });
});
// ── Barra lateral esquerda: seleção de arquivos ─────────────────────── // ── Painel de execução (fixo no rodapé, apenas modo Projeto Único) ─────
egui::SidePanel::left("sidebar_panel") if self.active_tab == ActiveTab::Single {
.min_width(220.0) egui::TopBottomPanel::bottom("execution_panel")
.max_width(400.0) .resizable(false)
.resizable(true) .show(ctx, |ui| {
.show(ctx, |ui| { ui.add_space(4.0);
let can_generate =
self.pending_source.is_some() && self.pending_output.is_some();
let (generate, cancel) = self.execution_panel.ui(ui, can_generate);
if generate {
let ctx_clone = ctx.clone();
self.start_generation(ctx_clone);
}
if cancel {
self.cancel_generation();
}
ui.add_space(4.0);
});
}
// ── Barra lateral esquerda (apenas modo Projeto Único) ─────────────────
if self.active_tab == ActiveTab::Single {
egui::SidePanel::left("sidebar_panel")
.min_width(220.0)
.max_width(400.0)
.resizable(true)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add_space(8.0);
// ── Seleção de vídeo ──────────────────────────────────
if let Some(path) = self.video_selector.ui(ui) {
self.pending_source = Some(path);
if self.pending_output.is_some() {
self.try_build_project();
}
}
ui.add_space(8.0);
// ── Seleção de saída ──────────────────────────────────
// Passa o stem do vídeo para pré-preencher o nome sugerido
let source_stem: Option<&str> = self
.pending_source
.as_ref()
.and_then(|p| p.as_path().file_stem())
.and_then(|s| s.to_str());
if let Some(path) = self.output_selector.ui(ui, source_stem) {
self.pending_output = Some(path);
if self.pending_source.is_some() {
self.try_build_project();
}
}
if self.project.is_none() {
ui.add_space(16.0);
ui.separator();
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"Selecione o arquivo de vídeo e o destino \
de saída para começar.",
)
.italics()
.weak(),
);
}
ui.add_space(8.0);
});
});
} // fim da barra lateral (modo Projeto Único)
// ── Painel central: faixas / formulários ou painel de lote ───────────
egui::CentralPanel::default().show(ctx, |ui| {
// Modo Lote
if self.active_tab == ActiveTab::Batch {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
ui.add_space(8.0); let is_processing = self.batch_processing_index.is_some();
let events = self.batch_panel.ui(ui, &self.batch_items, is_processing, self.mkvmerge_available);
// ── Seleção de vídeo ────────────────────────────────── for event in events {
if let Some(path) = self.video_selector.ui(ui) { match event {
self.pending_source = Some(path); BatchPanelEvent::AddItem(mut project) => {
if self.pending_output.is_some() { let probe = FfprobeGateway;
self.try_build_project(); let _ = LoadMediaInfo::execute(&mut project, &probe);
self.batch_items.push(BatchItem::new(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) {
item.project = project;
item.state = ExecutionState::Idle;
item.log_lines.clear();
}
}
BatchPanelEvent::RemoveItem(idx) => {
self.batch_items.remove(idx);
}
BatchPanelEvent::ProcessAll => {
if let Some(idx) = self
.batch_items
.iter()
.position(|item| matches!(item.state, ExecutionState::Idle))
{
self.start_batch_item(idx, ctx.clone());
}
}
BatchPanelEvent::CancelCurrent => {
self.cancel_batch_current();
}
} }
} }
ui.add_space(8.0);
// ── Seleção de saída ──────────────────────────────────
// Passa o stem do vídeo para pré-preencher o nome sugerido
let source_stem: Option<&str> = self
.pending_source
.as_ref()
.and_then(|p| p.as_path().file_stem())
.and_then(|s| s.to_str());
if let Some(path) = self.output_selector.ui(ui, source_stem) {
self.pending_output = Some(path);
if self.pending_source.is_some() {
self.try_build_project();
}
}
if self.project.is_none() {
ui.add_space(16.0);
ui.separator();
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"Selecione o arquivo de vídeo e o destino \
de saída para começar.",
)
.italics()
.weak(),
);
}
ui.add_space(8.0);
}); });
}); return;
}
// ── Painel central: faixas e formulários ───────────────────────────── // Modo Projeto Único
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.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;
@@ -310,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()
@@ -323,11 +704,15 @@ impl eframe::App for App {
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.unwrap_or("track"); .unwrap_or("track");
let maybe_output = match track.kind { let maybe_output = match track.kind {
TrackKind::Audio => { TrackKind::Audio => FilePickerAdapter::save_audio(
FilePickerAdapter::save_audio(base_stem, &track.codec) base_stem,
} &track.codec,
),
TrackKind::Subtitle => { TrackKind::Subtitle => {
FilePickerAdapter::save_subtitle(base_stem, &track.codec) FilePickerAdapter::save_subtitle(
base_stem,
&track.codec,
)
} }
_ => None, _ => None,
}; };
@@ -374,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;
@@ -382,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()));
} }
+576
View File
@@ -0,0 +1,576 @@
use eframe::egui;
use crate::adapters::filesystem::FilePickerAdapter;
use crate::application::use_cases::{add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle};
use crate::domain::entities::{MkvOutput, Project, Track, VideoFile};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{
add_audio_track_form::AddAudioTrackForm, add_subtitle_form::AddSubtitleForm,
execution_panel::ExecutionState,
};
// ── Tipos públicos ─────────────────────────────────────────────────────────────
/// Um item na fila
/// de lote — projeto completo com estado individual de execução.
pub struct BatchItem {
pub project: Project,
pub state: ExecutionState,
pub log_lines: Vec<String>,
/// Comando completo enviado ao FFmpeg/mkvmerge na última geração deste item.
pub last_command: Option<String>,
}
impl BatchItem {
pub fn new(project: Project) -> Self {
BatchItem {
project,
state: ExecutionState::Idle,
log_lines: Vec::new(),
last_command: None,
}
}
}
/// Eventos emitidos pelo `BatchPanel` para o `App`.
pub enum BatchPanelEvent {
/// Novo projeto confirmado pelo usuário — adicionar à fila.
AddItem(Project),
/// Substituir o projeto de um item existente (edição confirmada).
EditItem(usize, Project),
/// Remover item da fila pelo índice.
RemoveItem(usize),
/// Iniciar o processamento sequencial de todos os itens aguardando.
ProcessAll,
/// Cancelar o item que está atualmente em execução.
CancelCurrent,
}
// ── Tipos internos do formulário ──────────────────────────────────────────────
struct PendingAudio {
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
}
struct PendingSubtitle {
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
drift_scale: f64,
}
// ── BatchPanel ─────────────────────────────────────────────────────────────────
/// Painel de modo lote: exibe a fila de projetos e um formulário inline
/// para adicionar ou editar itens sem abandonar a aba.
pub struct BatchPanel {
// Estado do formulário de adição / edição
form_open: bool,
/// Índice do item sendo editado; `None` quando o formulário é para adição.
editing_index: Option<usize>,
form_video: Option<FilePath>,
form_output: Option<FilePath>,
form_error: Option<String>,
form_pending_audio: Vec<PendingAudio>,
form_pending_subtitles: Vec<PendingSubtitle>,
form_add_audio: AddAudioTrackForm,
form_add_subtitle: AddSubtitleForm,
}
impl BatchPanel {
pub fn new() -> Self {
BatchPanel {
form_open: false,
editing_index: None,
form_video: None,
form_output: None,
form_error: None,
form_pending_audio: Vec::new(),
form_pending_subtitles: Vec::new(),
form_add_audio: AddAudioTrackForm::new(),
form_add_subtitle: AddSubtitleForm::new(),
}
}
/// Limpa todos os campos do formulário e fecha-o.
fn reset_form(&mut self) {
self.form_open = false;
self.editing_index = None;
self.form_video = None;
self.form_output = None;
self.form_error = None;
self.form_pending_audio.clear();
self.form_pending_subtitles.clear();
self.form_add_audio = AddAudioTrackForm::new();
self.form_add_subtitle = AddSubtitleForm::new();
}
/// Abre o formulário pré-preenchido com os dados de um item existente.
fn open_edit_form(&mut self, index: usize, item: &BatchItem) {
self.reset_form();
self.form_open = true;
self.editing_index = Some(index);
self.form_video = Some(item.project.source.path.clone());
self.form_output = Some(item.project.output.path.clone());
for track in &item.project.tracks {
match track {
Track::Audio(t) => self.form_pending_audio.push(PendingAudio {
path: t.path.clone(),
offset: t.offset,
language: t.language.clone(),
is_default: t.is_default,
title: t.title.clone(),
drift_scale: t.drift_scale,
}),
Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle {
path: t.path.clone(),
offset: t.offset,
language: t.language.clone(),
is_default: t.is_default,
title: t.title.clone(),
drift_scale: t.drift_scale,
}),
}
}
}
/// Renderiza o painel completo. Retorna a lista de eventos gerados pelo usuário.
pub fn ui(
&mut self,
ui: &mut egui::Ui,
items: &[BatchItem],
is_processing: bool,
mkvmerge_available: bool,
) -> Vec<BatchPanelEvent> {
let mut events = Vec::new();
ui.heading("Modo Lote");
ui.label(
egui::RichText::new(
"Adicione múltiplos projetos à fila e processe-os sequencialmente.",
)
.weak()
.italics(),
);
ui.add_space(8.0);
// ── Lista de itens da fila ─────────────────────────────────────────
if items.is_empty() && !self.form_open {
ui.label(
egui::RichText::new(
"Nenhum item na fila. Clique em \"+ Adicionar item\" para começar.",
)
.weak()
.italics(),
);
} else {
// Coleta pedidos de edição para abrir depois (evita borrow conflict com self)
let mut open_edit_for: Option<usize> = None;
for (idx, item) in items.iter().enumerate() {
// Não renderiza o item sendo editado — ele aparece no formulário abaixo
if self.editing_index == Some(idx) {
continue;
}
let source_name = item
.project
.source
.path
.as_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let output_name = item
.project
.output
.path
.as_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
ui.push_id(("batch_item", idx), |ui| {
ui.group(|ui| {
ui.horizontal(|ui| {
let (icon, color) = match &item.state {
ExecutionState::Idle => ("", egui::Color32::GRAY),
ExecutionState::Running => ("", egui::Color32::YELLOW),
ExecutionState::Success => ("", egui::Color32::GREEN),
ExecutionState::Cancelled => {
("", egui::Color32::from_rgb(255, 200, 0))
}
ExecutionState::Error(_) => ("", egui::Color32::RED),
};
ui.colored_label(color, icon);
ui.label(format!("#{}{} >> {}", idx + 1, source_name, output_name));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if !is_processing && matches!(item.state, ExecutionState::Idle)
{
if ui.small_button("🗑 Remover").clicked() {
events.push(BatchPanelEvent::RemoveItem(idx));
}
if ui.small_button("✏ Editar").clicked() {
open_edit_for = Some(idx);
}
}
},
);
});
let track_count = item.project.tracks.len();
let existing_count = item.project.existing_tracks.len();
ui.label(
egui::RichText::new(format!(
" {} faixas existentes · {} faixas adicionadas",
existing_count, track_count
))
.small()
.weak(),
);
if let ExecutionState::Error(msg) = &item.state {
ui.colored_label(egui::Color32::RED, format!(" Erro: {}", msg));
}
if !item.log_lines.is_empty() {
ui.collapsing("Log detalhado", |ui| {
egui::ScrollArea::vertical()
.max_height(120.0)
.show(ui, |ui| {
for line in &item.log_lines {
ui.label(
egui::RichText::new(line).monospace().size(10.0),
);
}
});
});
}
if let Some(cmd) = &item.last_command {
ui.collapsing("🛠 Comando gerado", |ui| {
let mut cmd_str = cmd.as_str();
ui.add(
egui::TextEdit::multiline(&mut cmd_str)
.desired_rows(3)
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace),
);
if ui.small_button("📋 Copiar").clicked() {
ui.output_mut(|o| o.copied_text = cmd.clone());
}
});
} });
}); // push_id
ui.add_space(4.0);
}
// Abre edição após o loop para evitar borrow imutável simultâneo
if let Some(idx) = open_edit_for {
self.open_edit_form(idx, &items[idx]);
}
}
// ── Botão "Adicionar item" / formulário ──────────────────────────────
ui.add_space(4.0);
if !is_processing {
if !self.form_open {
if ui.button("+ Adicionar item").clicked() {
self.reset_form();
self.form_open = true;
}
} else {
self.render_form(ui, &mut events, mkvmerge_available);
}
}
// ── Botões de controle do lote ────────────────────────────────────────
if !items.is_empty() {
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
ui.horizontal(|ui| {
let has_waiting = items
.iter()
.any(|item| matches!(item.state, ExecutionState::Idle));
let all_finished = items.iter().all(|item| {
matches!(
item.state,
ExecutionState::Success
| ExecutionState::Error(_)
| ExecutionState::Cancelled
)
});
if is_processing {
if ui
.add(
egui::Button::new("⏹ Cancelar item atual")
.fill(egui::Color32::DARK_RED),
)
.on_hover_text(
"Interrompe o item em execução; os demais permanecem na fila.",
)
.clicked()
{
events.push(BatchPanelEvent::CancelCurrent);
}
} else if has_waiting {
if ui.button("▶ Processar Tudo").clicked() {
events.push(BatchPanelEvent::ProcessAll);
}
} else if all_finished && !items.is_empty() {
ui.colored_label(egui::Color32::GREEN, "✔ Lote concluído.");
}
});
}
events
}
/// 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>, mkvmerge_available: bool) {
let is_edit = self.editing_index.is_some();
let title = if let Some(idx) = self.editing_index {
format!("Editar item #{}", idx + 1)
} else {
"Novo item".to_string()
};
ui.group(|ui| {
ui.heading(&title);
// ── Seleção de vídeo ──────────────────────────────────────────────
ui.horizontal(|ui| {
ui.label("Vídeo:");
let label = self
.form_video
.as_ref()
.and_then(|p| p.as_path().file_name())
.and_then(|n| n.to_str())
.unwrap_or("Nenhum selecionado");
ui.add(egui::Label::new(label).truncate(true));
if ui.button("Escolher…").clicked() {
self.form_video = FilePickerAdapter::pick_video();
self.form_error = None;
}
});
// ── Seleção de saída ──────────────────────────────────────────────
ui.horizontal(|ui| {
ui.label("Saída:");
let label = self
.form_output
.as_ref()
.and_then(|p| p.as_path().file_name())
.and_then(|n| n.to_str())
.unwrap_or("Nenhum selecionado");
ui.add(egui::Label::new(label).truncate(true));
if ui.button("Escolher…").clicked() {
let stem = self
.form_video
.as_ref()
.and_then(|p| p.as_path().file_stem())
.and_then(|s| s.to_str())
.unwrap_or("output");
self.form_output = FilePickerAdapter::pick_output(stem);
self.form_error = None;
}
});
ui.separator();
// ── Faixas já configuradas (com remoção individual) ───────────────
if !self.form_pending_audio.is_empty() || !self.form_pending_subtitles.is_empty() {
ui.label(egui::RichText::new("Faixas configuradas:").small());
let mut remove_audio: Option<usize> = None;
for (i, audio) in self.form_pending_audio.iter().enumerate() {
let name = audio
.path
.as_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
ui.push_id(("audio", i), |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!(" 🎵 {} [{}]", name, audio.language))
.small(),
);
if ui
.small_button("🗑")
.on_hover_text("Remover faixa")
.clicked()
{
remove_audio = Some(i);
}
});
});
}
if let Some(i) = remove_audio {
self.form_pending_audio.remove(i);
}
let mut remove_sub: Option<usize> = None;
for (i, sub) in self.form_pending_subtitles.iter().enumerate() {
let name = sub
.path
.as_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
ui.push_id(("sub", i), |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!(" 💬 {} [{}]", name, sub.language))
.small(),
);
if ui
.small_button("🗑")
.on_hover_text("Remover faixa")
.clicked()
{
remove_sub = Some(i);
}
});
});
}
if let Some(i) = remove_sub {
self.form_pending_subtitles.remove(i);
}
ui.add_space(4.0);
}
// ── Formulários de faixas (compactos) ─────────────────────────────
let wide = ui.available_width() >= 500.0;
let (audio_req, subtitle_req) = if wide {
let available = ui.available_width();
let col_w = (available - ui.spacing().item_spacing.x) / 2.0;
let ar = ui
.allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| self.form_add_audio.ui(ui, mkvmerge_available),
)
.inner;
let sr = ui
.allocate_ui_with_layout(
egui::Vec2::new(col_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| self.form_add_subtitle.ui(ui, mkvmerge_available),
)
.inner;
(ar, sr)
} else {
let ar = self.form_add_audio.ui(ui, mkvmerge_available);
ui.add_space(2.0);
let sr = self.form_add_subtitle.ui(ui, mkvmerge_available);
(ar, sr)
};
if let Some(req) = audio_req {
self.form_pending_audio.push(PendingAudio {
path: req.path,
offset: req.offset,
language: req.language,
is_default: req.is_default,
title: req.title,
drift_scale: req.drift_scale,
});
}
if let Some(req) = subtitle_req {
self.form_pending_subtitles.push(PendingSubtitle {
path: req.path,
offset: req.offset,
language: req.language,
is_default: req.is_default,
title: req.title,
drift_scale: req.drift_scale,
});
}
// ── Mensagem de erro ──────────────────────────────────────────────
if let Some(ref err) = self.form_error {
ui.colored_label(egui::Color32::RED, err);
}
ui.add_space(4.0);
// ── Botões de confirmação / cancelar ──────────────────────────────
ui.horizontal(|ui| {
if ui.button("Cancelar").clicked() {
self.reset_form();
}
let can_add = self.form_video.is_some() && self.form_output.is_some();
let confirm_label = if is_edit {
"Salvar alterações"
} else {
"Adicionar à fila"
};
if ui
.add_enabled(can_add, egui::Button::new(confirm_label))
.on_disabled_hover_text("Selecione o vídeo e o destino de saída.")
.clicked()
{
let source_path = self.form_video.clone().unwrap();
let output_path = self.form_output.clone().unwrap();
match Project::new(VideoFile::new(source_path), MkvOutput::new(output_path)) {
Ok(mut project) => {
let audio = std::mem::take(&mut self.form_pending_audio);
let subs = std::mem::take(&mut self.form_pending_subtitles);
for t in audio {
let _ = AddAudioTrack::execute(
&mut project,
t.path,
t.offset,
t.language,
t.is_default,
t.title,
t.drift_scale,
);
}
for s in subs {
let _ = AddSubtitle::execute(
&mut project,
s.path,
s.offset,
s.language,
s.is_default,
s.title,
s.drift_scale,
);
}
if let Some(idx) = self.editing_index {
events.push(BatchPanelEvent::EditItem(idx, project));
} else {
events.push(BatchPanelEvent::AddItem(project));
}
self.reset_form();
}
Err(e) => {
self.form_error = Some(e.to_string());
}
}
}
});
});
}
}
+22 -2
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,
} }
} }
@@ -76,13 +80,13 @@ impl ExecutionPanel {
ui.label("Processando..."); ui.label("Processando...");
} }
ExecutionState::Success => { ExecutionState::Success => {
ui.colored_label(egui::Color32::GREEN, " Arquivo gerado com sucesso!"); ui.colored_label(egui::Color32::GREEN, " Arquivo gerado com sucesso!");
} }
ExecutionState::Cancelled => { ExecutionState::Cancelled => {
ui.colored_label(egui::Color32::YELLOW, "⊘ Geração cancelada pelo usuário."); ui.colored_label(egui::Color32::YELLOW, "⊘ Geração cancelada pelo usuário.");
} }
ExecutionState::Error(msg) => { ExecutionState::Error(msg) => {
ui.colored_label(egui::Color32::RED, " Erro durante o processamento:"); ui.colored_label(egui::Color32::RED, " Erro durante o processamento:");
ui.add( ui.add(
egui::TextEdit::multiline(&mut msg.as_str()) egui::TextEdit::multiline(&mut msg.as_str())
.desired_rows(4) .desired_rows(4)
@@ -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)
+162 -12
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,20 +97,109 @@ 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));
field.ui(ui, ""); ui.add_enabled_ui(!track.excluded, |ui| {
if let Some(offset) = field.parse() { field.ui(ui, "");
if offset != track.offset { });
events.push(ExistingTrackEvent::OffsetChanged(track.id, offset)); if !track.excluded {
if let Some(offset) = field.parse() {
if offset != track.offset {
events.push(ExistingTrackEvent::OffsetChanged(track.id, offset));
}
}
}
// Drift field (somente para áudio e legenda quando mkvmerge disponível)
if mkvmerge_available {
if editable {
let suggestion_pct = video_duration_ms
.zip(track.duration_ms)
.filter(|(_, td)| *td > 0)
.map(|(vd, td)| vd as f64 / td as f64 * 100.0);
let drift_raw = self
.drift_fields
.entry(track.id.val())
.or_insert_with(|| format!("{:.5}", track.drift_scale * 100.0));
let mut text_changed = false;
let mut suggestion_applied: Option<f64> = None;
ui.horizontal(|ui| {
let resp = ui.add(
egui::TextEdit::singleline(drift_raw).desired_width(72.0),
);
text_changed = resp.changed();
if let Some(pct) = suggestion_pct {
let warn = (pct - 100.0).abs() > 0.5;
let hover = format!(
"Sugerido: {:.3}%{}",
pct,
if warn {
"\n⚠ Diferença >0.5% — verifique se é o mesmo conteúdo"
} else {
" — clique para aplicar"
}
);
let color = if warn {
egui::Color32::from_rgb(255, 160, 0)
} else {
egui::Color32::from_rgb(80, 180, 80)
};
if ui
.add(egui::Button::new(
egui::RichText::new("💡").color(color).small(),
))
.on_hover_text(hover)
.clicked()
{
suggestion_applied = Some(pct);
}
}
});
if let Some(pct) = suggestion_applied {
*drift_raw = format!("{:.3}", pct);
events.push(ExistingTrackEvent::DriftChanged(
track.id, pct / 100.0,
));
} else if text_changed {
if let Ok(pct) = drift_raw.trim().parse::<f64>() {
if pct >= 1.0 && pct <= 999.99 {
let scale = pct / 100.0;
if (scale - track.drift_scale).abs() > 1e-9 {
events.push(ExistingTrackEvent::DriftChanged(
track.id, scale,
));
}
}
}
}
} else {
ui.label(""); // célula vazia para vídeo/dados
} }
} }
@@ -101,6 +215,28 @@ impl ExistingTrackList {
} }
} }
// Botão de excluir (somente Áudio e Legenda)
match track.kind {
TrackKind::Audio | TrackKind::Subtitle => {
let label = if track.excluded {
egui::RichText::new("↺ Restaurar").color(egui::Color32::from_rgb(255, 160, 0))
} else {
egui::RichText::new("✖ Excluir").color(egui::Color32::from_rgb(210, 70, 70))
};
let tooltip = if track.excluded {
"Restaurar: faixa será incluída no arquivo de saída"
} else {
"Excluir: faixa não será incluída no arquivo de saída"
};
if ui.add(egui::Button::new(label).small()).on_hover_text(tooltip).clicked() {
events.push(ExistingTrackEvent::ExcludeToggled(track.id));
}
}
_ => {
ui.label(""); // célula vazia para vídeo/dados
}
}
ui.end_row(); 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}")
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod add_audio_track_form; pub mod add_audio_track_form;
pub mod add_subtitle_form; pub mod add_subtitle_form;
pub mod added_track_list; pub mod added_track_list;
pub mod batch_panel;
pub mod execution_panel; pub mod execution_panel;
pub mod existing_track_list; pub mod existing_track_list;
pub mod language_field; pub mod language_field;
+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
}
}
}
} }