Compare commits
13
Commits
5d8e92f33f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e836e86b | ||
|
|
d86135ad6b | ||
|
|
e347202e95 | ||
|
|
8b031f07ec | ||
|
|
e70612f5ea | ||
|
|
1ef47b3a07 | ||
|
|
285f22e2d1 | ||
|
|
61b71f03a7 | ||
|
|
1f163ca0fc | ||
|
|
c7031cbdce | ||
|
|
3e38c51e3a | ||
|
|
d69ce92e32 | ||
|
|
121e919bbf |
@@ -1,352 +0,0 @@
|
|||||||
# Plano de Desenvolvimento — Simple Multimedia Track Audio Editor
|
|
||||||
|
|
||||||
**Versão:** 1.3
|
|
||||||
**Data:** 28/02/2026
|
|
||||||
**Status:** Fases 1–6 concluídas; Fase 7 (Persistência) e Fase 8 (Modo Lote) em planejamento
|
|
||||||
**Referência:** PRD v1.2
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estratégia Geral
|
|
||||||
|
|
||||||
O desenvolvimento segue a **Clean Architecture** de dentro para fora: as camadas mais internas (domínio) são implementadas e testadas primeiro, sem qualquer dependência de I/O, frameworks ou processos externos. A UI é sempre a última camada a ser construída.
|
|
||||||
|
|
||||||
```
|
|
||||||
Fase 1: Setup
|
|
||||||
└── Fase 2: Domain (testável, sem I/O)
|
|
||||||
└── Fase 3: Application (testável com mocks)
|
|
||||||
└── Fase 4: Adapters (integração com FFmpeg)
|
|
||||||
└── Fase 5: Infrastructure (processo real)
|
|
||||||
└── Fase 6: UI (montagem final)
|
|
||||||
```
|
|
||||||
|
|
||||||
Cada fase deve estar **compilando e com testes passando** antes de avançar para a próxima.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 1 — Setup do Projeto
|
|
||||||
|
|
||||||
**Objetivo:** Preparar o ambiente antes de escrever qualquer lógica de negócio.
|
|
||||||
|
|
||||||
### Tarefas
|
|
||||||
|
|
||||||
- [x] Atualizar `Cargo.toml` com as dependências (`rfd = "0.14"` adicionado para diálogos nativos)
|
|
||||||
- [x] Criar a estrutura de pastas conforme especificado
|
|
||||||
- [x] Declarar os módulos em `src/main.rs`
|
|
||||||
- [x] Verificar que o projeto compila (`cargo check`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 2 — Domain (núcleo puro)
|
|
||||||
|
|
||||||
**Objetivo:** Modelar o problema de negócio sem nenhum acoplamento a frameworks, I/O ou processos externos.
|
|
||||||
|
|
||||||
> Regra: nenhum `use std::process`, nenhum `use eframe`, nenhuma chamada de rede ou filesystem nessa camada.
|
|
||||||
|
|
||||||
### Value Objects (`src/domain/value_objects/`)
|
|
||||||
|
|
||||||
| Tipo | Implementação |
|
|
||||||
| --------------- | ----------------------------------------------------------------------------------------------------- |
|
|
||||||
| `FilePath` | Newtype sobre `PathBuf`; derivar `Clone`, `Debug`, `Serialize`, `Deserialize` |
|
|
||||||
| `TrackId` | Newtype opaco sobre `u32`; derivar `Clone`, `Copy`, `Debug`, `PartialEq`, `Eq`, `Hash` |
|
|
||||||
| `SyncOffset` | Newtype sobre `i64` (milissegundos, **nunca `f64`**); implementar método `from_seconds_str` e `as_ms` |
|
|
||||||
| `TrackLanguage` | Newtype sobre `String` (ex: `"por"`, `"eng"`); validar formato ISO 639-2 |
|
|
||||||
|
|
||||||
### Entities (`src/domain/entities/`)
|
|
||||||
|
|
||||||
| Tipo | Campos principais |
|
|
||||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `VideoFile` | `path: FilePath` |
|
|
||||||
| `AudioTrack` | `id: TrackId`, `path: FilePath`, `offset: SyncOffset`, `language: TrackLanguage` |
|
|
||||||
| `SubtitleTrack` | `id: TrackId`, `path: FilePath`, `offset: SyncOffset`, `language: TrackLanguage` |
|
|
||||||
| `MediaTrackInfo` | `id: TrackId`, `kind: TrackKind` (enum: Video/Audio/Subtitle), `codec: String`, `language: Option<TrackLanguage>` |
|
|
||||||
| `MkvOutput` | `path: FilePath` |
|
|
||||||
| `Project` | Entidade raiz — ver abaixo |
|
|
||||||
|
|
||||||
#### Estrutura de `Project`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct Project {
|
|
||||||
pub source: VideoFile,
|
|
||||||
pub tracks: Vec<Track>, // faixas externas adicionadas pelo usuário
|
|
||||||
pub existing_tracks: Vec<MediaTrackInfo>, // faixas lidas do arquivo via ffprobe
|
|
||||||
pub output: MkvOutput,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testes obrigatórios
|
|
||||||
|
|
||||||
- [x] `SyncOffset::from_seconds_str("1.2")` → `SyncOffset(1200)`
|
|
||||||
- [x] `SyncOffset::from_seconds_str("-0.5")` → `SyncOffset(-500)`
|
|
||||||
- [x] `Project` não aceita `output.path` igual a `source.path`
|
|
||||||
- [x] `TrackId` é opaco (não expõe indexação interna)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 3 — Application (casos de uso e ports)
|
|
||||||
|
|
||||||
**Objetivo:** Definir o que o sistema faz sem saber como. Ports são traits; use cases orquestram entidades.
|
|
||||||
|
|
||||||
### Ports (`src/application/ports/`)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// MediaInfoPort: inspeciona faixas de um arquivo de mídia
|
|
||||||
pub trait MediaInfoPort {
|
|
||||||
fn probe(&self, path: &FilePath) -> Result<Vec<MediaTrackInfo>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MediaProcessorPort: executa o processamento final
|
|
||||||
pub trait MediaProcessorPort {
|
|
||||||
fn execute(&self, args: Vec<String>) -> Result<()>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileSystemPort: abstrai acesso ao sistema de arquivos
|
|
||||||
pub trait FileSystemPort {
|
|
||||||
fn exists(&self, path: &FilePath) -> bool;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Use Cases (`src/application/use_cases/`)
|
|
||||||
|
|
||||||
Implementar nesta ordem (dependência crescente):
|
|
||||||
|
|
||||||
| # | Use Case | Descrição |
|
|
||||||
| --- | ----------------------- | ----------------------------------------------------------- |
|
|
||||||
| 1 | `LoadMediaInfo` | Usa `MediaInfoPort` para popular `Project::existing_tracks` |
|
|
||||||
| 2 | `AddAudioTrack` | Adiciona `AudioTrack` externo ao `Project::tracks` |
|
|
||||||
| 3 | `AddSubtitle` | Adiciona `SubtitleTrack` externo ao `Project::tracks` |
|
|
||||||
| 4 | `AdjustSync` | Altera `SyncOffset` de uma faixa existente pelo `TrackId` |
|
|
||||||
| 5 | `EditExistingTrackSync` | Ajusta offset de faixa já presente no arquivo original |
|
|
||||||
| 6 | `SetTrackLanguage` | Altera idioma de uma faixa pelo `TrackId` |
|
|
||||||
| 7 | `GenerateOutput` | Constrói o comando final e delega ao `MediaProcessorPort` |
|
|
||||||
|
|
||||||
### Testes obrigatórios
|
|
||||||
|
|
||||||
- [x] Usar mocks dos ports (sem chamar nenhum processo externo)
|
|
||||||
- [x] `GenerateOutput` sempre produz comando com `-c copy` (verificado via mock)
|
|
||||||
- [x] `AdjustSync` com `TrackId` inexistente retorna erro
|
|
||||||
- [x] `AddAudioTrack` retorna erro se `source` e `output` conflitarem (coberto por `Project::new`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 4 — Adapters (implementações concretas)
|
|
||||||
|
|
||||||
**Objetivo:** Conectar o domínio ao FFmpeg e ao filesystem real.
|
|
||||||
|
|
||||||
### FFmpeg (`src/adapters/ffmpeg/`)
|
|
||||||
|
|
||||||
#### `FfmpegCommandBuilder`
|
|
||||||
|
|
||||||
Responsabilidade única: converter `Project` em `Vec<String>` de argumentos para o FFmpeg.
|
|
||||||
|
|
||||||
Regras invariantes:
|
|
||||||
|
|
||||||
- `-c copy` **sempre presente** (RNF-01 — nunca opcional, nunca configurável)
|
|
||||||
- `SyncOffset(i64 ms)` → `-itsoffset 1.200` (conversão feita **somente aqui**)
|
|
||||||
- `TrackId` → `-map 0:a:N` (mapeamento de índice feito **somente aqui**)
|
|
||||||
|
|
||||||
Exemplo de saída esperada:
|
|
||||||
|
|
||||||
```
|
|
||||||
ffmpeg -i input.mkv -itsoffset 1.200 -i audio_pt.aac -map 0:v -map 0:a -map 1:a -c copy -metadata:s:a:1 language=por output.mkv
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] Implementar `FfmpegCommandBuilder::build(project: &Project) -> Vec<String>`
|
|
||||||
- [x] Implementar `FfmpegCommandBuilder::build_export(source, track, output) -> Vec<String>` (exportação de faixas individuais)
|
|
||||||
- [x] Testes: verificar presença de `-c copy`, ordem dos `-map`, formato de `-itsoffset`
|
|
||||||
|
|
||||||
#### `FfprobeGateway`
|
|
||||||
|
|
||||||
Implementa `MediaInfoPort`. Executa:
|
|
||||||
|
|
||||||
```
|
|
||||||
ffprobe -v quiet -print_format json -show_streams <path>
|
|
||||||
```
|
|
||||||
|
|
||||||
e mapeia a saída JSON para `Vec<MediaTrackInfo>`.
|
|
||||||
|
|
||||||
- [x] Implementar parsing de JSON via `serde_json`
|
|
||||||
- [x] Mapear `codec_type` para `TrackKind`
|
|
||||||
- [x] Mapear `tags.language` para `Option<TrackLanguage>`
|
|
||||||
|
|
||||||
#### `FfmpegGateway`
|
|
||||||
|
|
||||||
Implementa `MediaProcessorPort`. Executa o processo real do FFmpeg e captura stderr.
|
|
||||||
|
|
||||||
- [x] Capturar stderr para exibição de erros (RF-07)
|
|
||||||
- [x] Retornar erro com mensagem legível em caso de código de saída não-zero
|
|
||||||
|
|
||||||
### Filesystem (`src/adapters/filesystem/`)
|
|
||||||
|
|
||||||
- [x] `FilePickerAdapter`: abstrai seleção de arquivo via diálogo nativo (crate `rfd`; inclui `save_audio` e `save_subtitle` por codec)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 5 — Infrastructure
|
|
||||||
|
|
||||||
**Objetivo:** Execução real e não-bloqueante de processos externos.
|
|
||||||
|
|
||||||
### Módulo `src/infrastructure/process/`
|
|
||||||
|
|
||||||
- [x] Implementar execução via `tokio::process::Command` (assíncrono, RNF-05)
|
|
||||||
- [x] Capturar stderr em stream via `run_ffmpeg_async` com `std::sync::mpsc::Sender<String>` (progresso em tempo real — RF-07)
|
|
||||||
- [x] Cancelamento de execução via `tokio::sync::oneshot` + `child.kill().await`
|
|
||||||
|
|
||||||
### Validação na inicialização
|
|
||||||
|
|
||||||
- [x] Verificar se `ffmpeg` está disponível no `PATH` (DT-06)
|
|
||||||
- [x] Verificar se `ffprobe` está disponível no `PATH`
|
|
||||||
- [x] Exibir mensagem de erro global (`global_error`) na UI se algum dos dois estiver ausente
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 6 — UI (camada mais externa)
|
|
||||||
|
|
||||||
**Objetivo:** Interface gráfica que conecta o usuário ao `Project` via use cases.
|
|
||||||
|
|
||||||
> Todo estado da aplicação vive no `Project`. A UI apenas lê e dispara use cases.
|
|
||||||
> Termos técnicos do FFmpeg **nunca aparecem na interface** (ver PRD seção 13).
|
|
||||||
|
|
||||||
### Componentes (`src/ui/components/`), em ordem de construção
|
|
||||||
|
|
||||||
| # | Componente | Descrição |
|
|
||||||
| --- | ------------------- | -------------------------------------------------------------------------------------- |
|
|
||||||
| 1 | `VideoSelector` | Seletor de arquivo de vídeo; dispara `LoadMediaInfo` ao confirmar |
|
|
||||||
| 2 | `ExistingTrackList` | Lista faixas detectadas (áudio/legenda); permite editar offset de cada uma |
|
|
||||||
| 3 | `AddAudioTrackForm` | Formulário para adicionar faixa de áudio externa (arquivo, idioma, offset) |
|
|
||||||
| 4 | `AddSubtitleForm` | Formulário para adicionar legenda externa (arquivo, idioma, offset) |
|
|
||||||
| 5 | `SyncOffsetField` | Campo de offset em segundos (ex: `-1.2s`); converte para `SyncOffset(ms)` internamente |
|
|
||||||
| 6 | `LanguageField` | Seletor/input de idioma (ex: `por`, `eng`) |
|
|
||||||
| 7 | `OutputSelector` | Campo de caminho de saída + extensão `.mkv` forçada |
|
|
||||||
| 8 | `ExecutionPanel` | Botão "Gerar MKV", exibição de progresso e erros legíveis |
|
|
||||||
|
|
||||||
### App (`src/ui/app.rs`)
|
|
||||||
|
|
||||||
- [x] Implementar `eframe::App` para `App`
|
|
||||||
- [x] `App` contém `project: Option<Project>` como única fonte de verdade do estado
|
|
||||||
- [x] Despachar eventos de UI para os use cases correspondentes
|
|
||||||
- [x] Integrar execução assíncrona (tokio + `std::thread`) com o loop de UI do eframe
|
|
||||||
- [x] Botão "Cancelar" via `cancel_tx: Option<oneshot::Sender<()>>`
|
|
||||||
- [x] `ExecutionState`: `Idle | Running | Success | Cancelled | Error`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 7 — Persistência do Estado do Projeto
|
|
||||||
|
|
||||||
**Objetivo:** Salvar e carregar o estado do `Project` em disco, permitindo que o usuário retome uma sessão anterior sem precisar reconfigurar tudo.
|
|
||||||
|
|
||||||
**Pré-requisito para a Fase 8:** o modo lote se beneficia diretamente da persistência — um carrinho salvo pode ser retomado após fechar a aplicação.
|
|
||||||
|
|
||||||
**Restrição:** a serialização deve viver exclusivamente na camada de infraestrutura/UI. O `domain/` não deve depender de `serde` diretamente, mas as entidades **já derivam** `Serialize`/`Deserialize` — nenhuma mudança no domínio é necessária.
|
|
||||||
|
|
||||||
### Formato e local do arquivo
|
|
||||||
|
|
||||||
- Formato: **JSON** via `serde_json` (já disponível no `Cargo.toml`)
|
|
||||||
- Local: diretório de configuração do usuário (`~/.config/simple-mkv-editor/session.json` no Linux; `AppData` no Windows)
|
|
||||||
- Um único arquivo de sessão por vez (sobrescreve ao salvar)
|
|
||||||
|
|
||||||
### Quando salvar / carregar
|
|
||||||
|
|
||||||
| Evento | Ação |
|
|
||||||
| ------------------------------- | ---------------------------------------------------------------- |
|
|
||||||
| Usuário clica "Salvar sessão" | Serializa `Project` para disco |
|
|
||||||
| Inicialização da aplicação | Verifica se existe arquivo de sessão; oferece opção de restaurar |
|
|
||||||
| Usuário clica "Carregar sessão" | Desserializa e substitui `App::project` |
|
|
||||||
|
|
||||||
> Salvamento automático fica fora do escopo desta fase para evitar escritas freqüentes em disco.
|
|
||||||
|
|
||||||
### Mudanças necessárias
|
|
||||||
|
|
||||||
| Arquivo | O que muda |
|
|
||||||
| --------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
||||||
| `src/infrastructure/persistence/mod.rs` | Novo módulo: `save_session(project)` e `load_session() -> Result<Project>` |
|
|
||||||
| `src/infrastructure/mod.rs` | Expor `persistence` |
|
|
||||||
| `src/ui/app.rs` | Botões "Salvar sessão" / "Carregar sessão" no header; chamar o módulo de persistência |
|
|
||||||
|
|
||||||
### Tarefas
|
|
||||||
|
|
||||||
- [ ] Criar `src/infrastructure/persistence/mod.rs` com `save_session` e `load_session`
|
|
||||||
- [ ] Resolver caminho do arquivo via `dirs` crate (ou `std::env`)
|
|
||||||
- [ ] Adicionar botões no header da UI
|
|
||||||
- [ ] Tratar erros de desserialização (arquivo corrompido ou versão incompatível) com mensagem amigável
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 8 — Modo Lote via Abas
|
|
||||||
|
|
||||||
**Objetivo:** Permitir que o usuário configure e processe múltiplos projetos sequencialmente, um por vez, sem sobrecarga de I/O.
|
|
||||||
|
|
||||||
**Modelo mental — carrinho:** o usuário adiciona itens livremente, na ordem que quiser, e só inicia o processamento quando clicar "Processar Tudo". Itens podem ser removidos do carrinho a qualquer momento antes de processar.
|
|
||||||
|
|
||||||
**Restrição:** Nenhuma camada abaixo da UI (`domain`, `application`, `adapters`, `infrastructure`) precisa ser alterada — `Project` já é a unidade de trabalho reutilizável.
|
|
||||||
|
|
||||||
### Seleção de arquivos
|
|
||||||
|
|
||||||
Igual ao modo Projeto Único — **diálogos nativos via `FilePickerAdapter` / `rfd`**. Nenhum path é digitado manualmente pelo usuário. O formulário de cada item do lote expõe os mesmos botões "Escolher..." já existentes.
|
|
||||||
|
|
||||||
### Formulário — Opção A (inline na aba Lote)
|
|
||||||
|
|
||||||
O formulário de configuração de um novo item **expande inline** na própria aba Lote, abaixo da lista. Não altera nem reutiliza a aba Projeto Único. Ao confirmar, o item é adicionado ao carrinho e o formulário é limpo.
|
|
||||||
|
|
||||||
```
|
|
||||||
[ + Adicionar item ] ← clique expande o formulário abaixo
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ Vídeo: [ep03.mkv ] [Escolher...] │
|
|
||||||
│ Saída: [ep03_pt.mkv ] [Escolher...] │
|
|
||||||
│ Faixas: [+ Áudio] [+ Legenda] │
|
|
||||||
│ [Cancelar] [Adicionar ✓] │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mudanças na UI
|
|
||||||
|
|
||||||
| Arquivo | O que muda |
|
|
||||||
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `src/ui/app.rs` | Adicionar `enum ActiveTab { Single, Batch }` e `active_tab: ActiveTab`; adicionar `batch_items: Vec<BatchItem>` |
|
|
||||||
| `src/ui/components/batch_panel.rs` | Novo componente: carrinho de itens, formulário inline de adição, botão "Processar Tudo" |
|
|
||||||
| `src/ui/components/mod.rs` | Expor `batch_panel` |
|
|
||||||
|
|
||||||
### Estrutura de `BatchItem`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
struct BatchItem {
|
|
||||||
project: Project,
|
|
||||||
state: ExecutionState, // reutiliza o enum já existente
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fluxo de execução do lote
|
|
||||||
|
|
||||||
1. Usuário adiciona itens ao carrinho (zero ou mais), configura cada um com diálogo nativo
|
|
||||||
2. Itens podem ser removidos do carrinho enquanto nenhum processamento estiver em curso
|
|
||||||
3. Ao clicar "Processar Tudo", o lote é bloqueado (sem mais adições/remoções)
|
|
||||||
4. Para cada item em ordem: `state → Running` → `run_ffmpeg_async` → aguarda `BackgroundMsg::Done | Error` → `state → Success | Error` → próximo item
|
|
||||||
5. "Cancelar" interrompe o item atual via `cancel_tx`; os demais permanecem no carrinho com estado `Idle`
|
|
||||||
|
|
||||||
### Tarefas
|
|
||||||
|
|
||||||
- [ ] Criar `enum ActiveTab` e barra de abas no `update()` de `App`
|
|
||||||
- [ ] Criar `BatchItem` e `batch_items: Vec<BatchItem>` em `App`
|
|
||||||
- [ ] Criar componente `BatchPanel` com carrinho e formulário inline (Opção A)
|
|
||||||
- [ ] Implementar loop sequencial de execução em `App::process_batch()`
|
|
||||||
- [ ] Exibir estado individual por item (`Aguardando | Processando | Concluído | Erro`)
|
|
||||||
- [ ] Bloquear adição/remoção de itens durante processamento
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critérios de Conclusão (v1.0)
|
|
||||||
|
|
||||||
Alinhados com o PRD seção 12:
|
|
||||||
|
|
||||||
- [x] É possível selecionar um arquivo de vídeo base
|
|
||||||
- [x] Ao selecionar o vídeo, as faixas existentes são listadas automaticamente (via `ffprobe`)
|
|
||||||
- [x] É possível adicionar uma ou mais faixas de áudio externas
|
|
||||||
- [x] É possível adicionar uma ou mais faixas de legenda
|
|
||||||
- [x] É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
|
|
||||||
- [x] É possível editar o offset de sincronização de uma faixa já existente no arquivo
|
|
||||||
- [x] É possível atribuir idioma a cada faixa
|
|
||||||
- [x] O arquivo MKV é gerado corretamente ao confirmar
|
|
||||||
- [x] Erros do FFmpeg são exibidos de forma legível
|
|
||||||
- [x] A interface não trava durante o processamento
|
|
||||||
- [x] Nenhum reencoding ocorre (verificável via `ffprobe` no arquivo de saída)
|
|
||||||
- [x] O flag `-c copy` está sempre presente no comando gerado (verificável via testes unitários)
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# PRD — Simple Multimedia Track Audio Editor
|
# PRD — Simple Multimedia Track Audio Editor
|
||||||
|
|
||||||
**Versão:** 1.2
|
**Versão:** 1.5
|
||||||
**Data:** 28/02/2026
|
**Data:** 02/03/2026
|
||||||
**Status:** Em desenvolvimento
|
**Status:** v1.0 funcional — Fases 1–9 concluídas; 72 testes passando; aplicação executável
|
||||||
**Revisão:** v1.2 — Adicionado RF-09 (Modo Lote); batch promovido de backlog para requisito planejado
|
**Revisão:** v1.5 — Adicionado RF-16 (Reordenar faixas externas)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,9 +42,8 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
|
|||||||
- Reencoding / transcodificação de vídeo ou áudio
|
- Reencoding / transcodificação de vídeo ou áudio
|
||||||
- Preview de vídeo embutido
|
- Preview de vídeo embutido
|
||||||
- Detecção automática de sincronização
|
- Detecção automática de sincronização
|
||||||
- Remoção de faixas existentes do arquivo original
|
|
||||||
- Seleção de faixa padrão no container
|
|
||||||
- Edição de corte ou splice de vídeo
|
- Edição de corte ou splice de vídeo
|
||||||
|
- Salvamento automático em disco (sem intervenção do usuário)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,6 +81,20 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
|
|||||||
- O usuário pode atribuir um código de idioma a cada faixa (ex: `por`, `eng`)
|
- O usuário pode atribuir um código de idioma a cada faixa (ex: `por`, `eng`)
|
||||||
- Implementado via `ffmpeg -metadata:s`
|
- Implementado via `ffmpeg -metadata:s`
|
||||||
|
|
||||||
|
### RF-10 — Definir nome da faixa
|
||||||
|
|
||||||
|
- O usuário pode preencher um título livre para cada faixa externa adicionada (ex: `Português`, `Comentários`)
|
||||||
|
- O campo aceita string vazia, que instrui o `FfmpegCommandBuilder` a emitir `title=` limpando qualquer título herdado do arquivo fonte
|
||||||
|
- Objetivo principal: sobrescrever títulos gerados automaticamente por ferramentas externas (ex: `"ISO Media file produced by Google Inc."` em arquivos M4A do Google) que confundem players como Jellyfin mobile
|
||||||
|
- Implementado via `ffmpeg -metadata:s:a:{idx} title=` ou `-metadata:s:s:{idx} title=`; sempre emitido, mesmo quando vazio
|
||||||
|
|
||||||
|
### RF-11 — Definir faixa padrão
|
||||||
|
|
||||||
|
- O usuário pode marcar uma faixa externa como “faixa padrão” do container MKV
|
||||||
|
- Quando marcada, o `FfmpegCommandBuilder` emite `-disposition:a/s:{idx} default` para a faixa selecionada e `-disposition:a/s:{orig_idx} 0` para todas as faixas existentes do mesmo tipo, removendo o flag `default=1` que o arquivo fonte carrega
|
||||||
|
- Objetivo: garantir que players como Jellyfin selecionem automaticamente a faixa preferida sem depender de configuração manual no side do player
|
||||||
|
- Implementado via `ffmpeg -disposition:a:{idx} default`
|
||||||
|
|
||||||
### RF-05 — Selecionar arquivo de vídeo base
|
### RF-05 — Selecionar arquivo de vídeo base
|
||||||
|
|
||||||
- O usuário seleciona o arquivo de vídeo de entrada
|
- O usuário seleciona o arquivo de vídeo de entrada
|
||||||
@@ -113,8 +126,55 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
|
|||||||
- A seleção de arquivos usa **diálogos nativos** (igual ao modo Projeto Único) — nenhum path é digitado manualmente
|
- A seleção de arquivos usa **diálogos nativos** (igual ao modo Projeto Único) — nenhum path é digitado manualmente
|
||||||
- O processamento é **sequencial** — um projeto por vez, sem paralelismo, para evitar sobrecarga de I/O
|
- O processamento é **sequencial** — um projeto por vez, sem paralelismo, para evitar sobrecarga de I/O
|
||||||
- Durante o processamento, o carrinho é bloqueado: não é possível adicionar nem remover itens
|
- Durante o processamento, o carrinho é bloqueado: não é possível adicionar nem remover itens
|
||||||
- Cada item exibe seu estado individual: `Aguardando | Processando | Concluído | Erro`
|
- Cada item exibe seu estado individual: `⏳ Aguardando | ⟳ Processando | ✓ Concluído | ⊸ Cancelado | ✗ Erro`
|
||||||
- É possível cancelar o item em execução; os demais permanecem no carrinho com estado `Aguardando`
|
- É possível cancelar o item em execução; os demais permanecem no carrinho com estado `Aguardando`
|
||||||
|
- O dispatch FFmpeg/mkvmerge por item é automático: se qualquer faixa do item tiver `drift_scale ≠ 1.0`, o motor mkvmerge é usado
|
||||||
|
|
||||||
|
### RF-12 — Correção de drift progressivo de sincronização
|
||||||
|
|
||||||
|
- O usuário pode especificar um fator de velocidade original da faixa em porcentagem (ex: `99.983%`)
|
||||||
|
- Internamente representado como `drift_scale: f64` em cada faixa (`AudioTrack`, `SubtitleTrack`, `MediaTrackInfo`)
|
||||||
|
- Implementado via `mkvmerge --sync TID:DELAY,NUM/DEN` — aplica escala de timestamps no nível do container, sem reencoding
|
||||||
|
- **Dispatch automático:** se qualquer faixa tiver `scale ≠ 1.0`, o projeto inteiro usa o pipeline `mkvmerge` em vez do FFmpeg
|
||||||
|
- Campo exibido na interface como **"Velocidade original (%)"** — jamais expõe termos técnicos como `scale`, `drift` ou `mkvmerge`
|
||||||
|
- O campo é desabilitado com tooltip explicativo quando `mkvmerge` não está disponível no `PATH`
|
||||||
|
- Funciona tanto no modo Projeto Único quanto no modo Lote
|
||||||
|
- Preserva a invariante RNF-01: nenhum byte de mídia é reprocessado
|
||||||
|
|
||||||
|
### RF-13 — Exportar faixa existente
|
||||||
|
|
||||||
|
- A partir da lista de faixas detectadas pelo `ffprobe`, o usuário pode exportar qualquer faixa de áudio ou legenda para um arquivo separado
|
||||||
|
- O formato do arquivo de saída é inferido a partir do codec da faixa (ex: AAC → `.aac`, SRT → `.srt`)
|
||||||
|
- Implementado via `FfmpegCommandBuilder::build_export()` com `-c:a copy` para áudio; legendas omitem `-c` (conversão de container de texto, sem processamento de mídia)
|
||||||
|
- O diálogo de salvamento usa filtro por extensão correspondente ao codec detectado
|
||||||
|
|
||||||
|
### RF-14 — Persistência de sessão
|
||||||
|
|
||||||
|
- O usuário pode salvar o estado completo do projeto em disco com o botão **"💾 Salvar sessão"**
|
||||||
|
- O estado é restaurado com o botão **"📂 Carregar sessão"**
|
||||||
|
- Formato: JSON via `serde_json`; local: `~/.config/simple-mkv-editor/session.json` (Linux) / `AppData` (Windows)
|
||||||
|
- Feedback visual no header: indicador verde (sucesso) ou vermelho (erro) com botão ✕ para dispensar
|
||||||
|
- Erros de desserialização (arquivo corrompido ou versão incompatível) são tratados com mensagem amigável
|
||||||
|
- Salvamento automático não é realizado — apenas sob demanda do usuário
|
||||||
|
|
||||||
|
### RF-15 — Excluir faixa existente do arquivo de saída
|
||||||
|
|
||||||
|
- O usuário pode marcar qualquer faixa de áudio ou legenda **já presente no arquivo de entrada** para ser omitida do arquivo de saída
|
||||||
|
- A operação é reversível: a faixa pode ser restaurada antes da geração do MKV
|
||||||
|
- Faixas marcadas como excluídas são exibidas com texto tachado e cor esmaecida na lista; seus campos de offset e drift ficam desabilitados
|
||||||
|
- Faixas de vídeo e dados **não** podem ser excluídas — somente áudio e legenda
|
||||||
|
- Implementado via campo `excluded: bool` em `MediaTrackInfo`; método `toggle_existing_track_excluded()` em `Project`
|
||||||
|
- Ambos os pipelines (FFmpeg e mkvmerge) respeitam o flag: faixas excluídas são filtradas antes da geração dos argumentos de `-map` / `--audio-tracks` / `--subtitle-tracks`
|
||||||
|
- O estado `excluded` é persistido junto com a sessão (RF-14)
|
||||||
|
|
||||||
|
### RF-16 — Reordenar faixas externas
|
||||||
|
|
||||||
|
- O usuário pode alterar a ordem das faixas externas adicionadas (áudio e legenda) usando os botões **↑** e **↓** na lista de faixas
|
||||||
|
- A ordem determina o índice de stream no container MKV final — relevante para players que selecionam faixas por posição (ex: VLC, Jellyfin)
|
||||||
|
- O botão ↑ fica desabilitado na primeira faixa da lista; o botão ↓, na última
|
||||||
|
- Implementado via método `move_track(id, delta: i8)` em `Project`, usando `Vec::swap` — ambos os pipelines (FFmpeg e mkvmerge) iteram `project.tracks` em ordem e não precisam de alteração
|
||||||
|
- Somente faixas **externas** (`project.tracks`) são reordenáveis; faixas existentes (`project.existing_tracks`, lidas via ffprobe) permanecem na ordem detectada
|
||||||
|
- A nova ordem é persistida automaticamente junto com a sessão (RF-14)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,9 +205,11 @@ A operação de mux deve ser concluída em tempo proporcional ao tamanho do arqu
|
|||||||
|
|
||||||
O arquivo de saída deve ser compatível com players modernos que suportam MKV (ex: VLC, mpv, Jellyfin).
|
O arquivo de saída deve ser compatível com players modernos que suportam MKV (ex: VLC, mpv, Jellyfin).
|
||||||
|
|
||||||
### RNF-04 — Dependência externa
|
### RNF-04 — Dependências externas
|
||||||
|
|
||||||
FFmpeg deve estar instalado no sistema. A aplicação não o embute por padrão, porém pode ser distribuída junto.
|
FFmpeg e ffprobe devem estar instalados no sistema. A aplicação verifica a presença de ambos na inicialização e exibe erro global se ausentes.
|
||||||
|
|
||||||
|
mkvmerge (MKVToolNix) é uma dependência opcional. Quando ausente, a funcionalidade de correção de drift (RF-12) fica desabilitada visualmente; o restante do produto continua funcional. A verificação é **soft** — não bloqueia a inicialização.
|
||||||
|
|
||||||
### RNF-05 — Interface responsiva
|
### RNF-05 — Interface responsiva
|
||||||
|
|
||||||
@@ -169,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
|
||||||
|
|
||||||
@@ -185,7 +249,9 @@ Arquivo de saída (.mkv)
|
|||||||
eframe = "0.27"
|
eframe = "0.27"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
tokio = { version = "1", features = ["process"] } # opcional
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["process", "rt-multi-thread", "macros", "io-util", "sync"] }
|
||||||
|
rfd = "0.14"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -222,31 +288,35 @@ Contém as entidades e objetos de valor do negócio. Não depende de nada extern
|
|||||||
| Tipo | Exemplos |
|
| Tipo | Exemplos |
|
||||||
| ------------- | ------------------------------------------------------------------------------------ |
|
| ------------- | ------------------------------------------------------------------------------------ |
|
||||||
| Entities | `Project`, `VideoFile`, `AudioTrack`, `SubtitleTrack`, `MkvOutput`, `MediaTrackInfo` |
|
| Entities | `Project`, `VideoFile`, `AudioTrack`, `SubtitleTrack`, `MkvOutput`, `MediaTrackInfo` |
|
||||||
| Value Objects | `TrackId`, `SyncOffset`, `TrackLanguage`, `FilePath` |
|
| Value Objects | `TrackId`, `SyncOffset`, `SyncTransform`, `TrackLanguage`, `FilePath` |
|
||||||
|
|
||||||
> **`Project`** é a entidade central do domínio. Representa a sessão de edição completa do usuário e deve ser a única fonte de verdade do estado em memória.
|
> **`Project`** é a entidade central do domínio. Representa a sessão de edição completa do usuário e deve ser a única fonte de verdade do estado em memória.
|
||||||
>
|
>
|
||||||
> ```
|
> ```
|
||||||
> 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`.
|
||||||
>
|
>
|
||||||
@@ -264,15 +334,20 @@ Define traits (ports) que as camadas externas devem implementar.
|
|||||||
|
|
||||||
Implementam os ports definidos na camada de Application. Traduzem dados entre o domínio e o mundo externo.
|
Implementam os ports definidos na camada de Application. Traduzem dados entre o domínio e o mundo externo.
|
||||||
|
|
||||||
| Tipo | Exemplos |
|
| Tipo | Exemplos |
|
||||||
| ------------ | --------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------- |
|
||||||
| Gateway | `FfmpegCommandBuilder`, `FfmpegGateway`, `FfprobeGateway` |
|
| Gateway | `FfmpegCommandBuilder`, `FfmpegGateway`, `FfprobeGateway` |
|
||||||
| Presenter | `ErrorPresenter` (formata stderr do FFmpeg) |
|
| Gateway | `MkvmergeCommandBuilder`, `MkvmergeGateway` (motor de drift — RF-12) |
|
||||||
| File Adapter | `FilePickerAdapter` |
|
| Presenter | `ErrorPresenter` (formata stderr do FFmpeg / stdout do mkvmerge) |
|
||||||
|
| File Adapter | `FilePickerAdapter` (inclui `save_audio(codec)` e `save_subtitle(codec)`) |
|
||||||
|
|
||||||
> **`FfprobeGateway`** implementa `MediaInfoPort`. Executa `ffprobe -v quiet -print_format json -show_streams` e mapeia a saída para `Vec<MediaTrackInfo>`. Não conhece o domínio além das structs que está populando.
|
> **`FfprobeGateway`** implementa `MediaInfoPort`. Executa `ffprobe -v quiet -print_format json -show_streams` e mapeia a saída para `Vec<MediaTrackInfo>`. Não conhece o domínio além das structs que está populando.
|
||||||
>
|
>
|
||||||
> **`FfmpegCommandBuilder`** é responsável por converter `TrackId` para índices `-map 0:a:N` do FFmpeg e `SyncOffset` (ms inteiro) para o formato `-itsoffset 1.200` aceito pelo binário. **Toda conversão de tipos internos para argumentos FFmpeg fica aqui e somente aqui.**
|
> **`FfmpegCommandBuilder`** é responsável por converter `TrackId` para índices `-map 0:a:N` do FFmpeg e `SyncOffset` (ms inteiro) para o formato `-itsoffset 1.200` aceito pelo binário. **Toda conversão de tipos internos para argumentos FFmpeg fica aqui e somente aqui.**
|
||||||
|
>
|
||||||
|
> Além do `-c copy`, sempre emite `-max_interleave_delta 0` (evita descarte silencioso de pacotes em streams com grande delta de timestamps) e `-avoid_negative_ts make_zero` (normaliza timestamps negativos comuns em M4A/AAC de serviços de streaming).
|
||||||
|
>
|
||||||
|
> **`MkvmergeCommandBuilder`** converte `Project` em argumentos para o `mkvmerge`. Usa `--sync TID:DELAY,NUM/DEN` para aplicar deslocamento e escala de timestamps sem reencoding. A escala `f64` é convertida para fração racionl `(NUM, DEN)` com precisão de 6 casas decimais.
|
||||||
|
|
||||||
#### Infrastructure / UI
|
#### Infrastructure / UI
|
||||||
|
|
||||||
@@ -292,18 +367,23 @@ Camada mais externa. Contém o framework de UI e a execução real de processos.
|
|||||||
src/
|
src/
|
||||||
├── domain/
|
├── domain/
|
||||||
│ ├── entities/ # Project, VideoFile, AudioTrack, SubtitleTrack, MkvOutput, MediaTrackInfo
|
│ ├── entities/ # Project, VideoFile, AudioTrack, SubtitleTrack, MkvOutput, MediaTrackInfo
|
||||||
│ └── value_objects/ # TrackId, SyncOffset (ms/i64), TrackLanguage, FilePath
|
│ └── value_objects/ # TrackId, SyncOffset (ms/i64), SyncTransform (offset+scale), TrackLanguage, FilePath
|
||||||
├── application/
|
├── application/
|
||||||
│ ├── use_cases/ # LoadMediaInfo, AddAudioTrack, AddSubtitle, AdjustSync, GenerateOutput
|
│ ├── use_cases/ # LoadMediaInfo, AddAudioTrack, AddSubtitle, AdjustSync, AdjustDrift,
|
||||||
│ └── ports/ # Traits: MediaProcessorPort, MediaInfoPort, FileSystemPort
|
│ │ # AdjustExistingTrackDrift, EditExistingTrackSync, ExportTrack,
|
||||||
|
│ │ # RemoveTrack, SetTrackLanguage, GenerateOutput
|
||||||
|
│ └── ports/ # Traits: MediaProcessorPort, MediaInfoPort, FileSystemPort, ContainerMuxPort
|
||||||
├── adapters/
|
├── adapters/
|
||||||
│ ├── ffmpeg/ # FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway
|
│ ├── ffmpeg/ # FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway
|
||||||
|
│ ├── mkvmerge/ # MkvmergeCommandBuilder, MkvmergeGateway (RF-12 — drift)
|
||||||
│ └── filesystem/ # FilePickerAdapter
|
│ └── filesystem/ # FilePickerAdapter
|
||||||
├── infrastructure/
|
├── infrastructure/
|
||||||
│ └── process/ # Execução real do FFmpeg / ffprobe
|
│ ├── process/ # run_ffmpeg_async, run_mkvmerge_async, validate_dependencies, mkvmerge_available
|
||||||
|
│ └── persistence/ # save_session, load_session (RF-14)
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── app.rs # eframe App (ponto de entrada da interface)
|
│ ├── app.rs # eframe App; ActiveTab (Single/Batch); dispatch FFmpeg/mkvmerge
|
||||||
│ └── components/ # Componentes egui reutilizáveis
|
│ └── components/ # VideoSelector, ExistingTrackList, AddAudioTrackForm, AddSubtitleForm,
|
||||||
|
│ # SyncOffsetField (+ campo drift), OutputSelector, ExecutionPanel, BatchPanel
|
||||||
└── main.rs
|
└── main.rs
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -311,14 +391,18 @@ src/
|
|||||||
|
|
||||||
## 10. Débitos Técnicos
|
## 10. Débitos Técnicos
|
||||||
|
|
||||||
| ID | Descrição | Impacto | Mitigação |
|
| ID | Descrição | Impacto | Mitigação / Status |
|
||||||
| ----- | ----------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------ |
|
| ----- | ------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| DT-01 | FFmpeg não embutido | Usuário precisa instalar | Distribuir junto com a aplicação |
|
| DT-01 | FFmpeg não embutido | Usuário precisa instalar | Distribuir junto com a aplicação |
|
||||||
| DT-02 | Dependência de processo externo | Menor controle interno | Encapsular via módulo de serviço |
|
| DT-02 | Dependência de processo externo | Menor controle interno | Encapsulado via `infrastructure/process`; `validate_dependencies()` na inicialização |
|
||||||
| DT-03 | Parsing de erros do FFmpeg (stderr) | Necessário tratamento manual | Parsear saída e exibir mensagem amigável |
|
| DT-03 | Parsing de erros do FFmpeg (stderr) | Mensagens brutas exibidas ao usuário | Stderr capturado linha a linha via `run_ffmpeg_async`; exibido no `ExecutionPanel`; parse amigável futuro |
|
||||||
| DT-04 | Compatibilidade de codecs | Alguns codecs podem não ser aceitos | Usar MKV como container padrão |
|
| DT-04 | Compatibilidade de codecs | Alguns codecs podem não ser aceitos | MKV como container padrão; `-c copy` garante que não há transcodificação |
|
||||||
| DT-05 | Performance de processo externo | Pequeno overhead | Aceitável — mux é rápido |
|
| DT-05 | Performance de processo externo | Pequeno overhead | Aceitável — mux é rápido; sem impacto perceptível |
|
||||||
| DT-06 | ffprobe como dependência adicional | Parsing de JSON da saída do ffprobe | Validar presença de ffprobe na inicialização; exibir mensagem clara se ausente |
|
| DT-06 | ffprobe como dependência adicional | Parsing de JSON da saída do ffprobe | ✅ Resolvido — `validate_dependencies()` verifica ffprobe na inicialização; erro global exibido se ausente |
|
||||||
|
| DT-07 | Compatibilidade com Jellyfin mobile | Faixas não selecionadas automaticamente | Investigando — RF-10 (`title`) e RF-11 (`disposition`) implementados; comportamento no Jellyfin mobile requer testes com arquivo gerado |
|
||||||
|
| DT-08 | mkvmerge não embutido | Usuário precisa instalar MKVToolNix | Dependência opcional; feat. de drift desabilitada visualmente se ausente; verificação soft na inicialização |
|
||||||
|
| DT-09 | Testes de integração com FFmpeg real | Regressões em comandos gerados | Pendente — requer `ffmpeg` no CI; coberto indiretamente pelos testes unitários do `FfmpegCommandBuilder` |
|
||||||
|
| DT-10 | Comando FFmpeg não visível ao usuário | Difícil de debugar manualmente | ✅ Resolvido — painel colapsável "🛠 Comando gerado" na `ExecutionPanel` e em cada item do modo Lote; botão de cópia para clipboard |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -326,9 +410,10 @@ src/
|
|||||||
|
|
||||||
- Preview de vídeo embutido na interface
|
- Preview de vídeo embutido na interface
|
||||||
- Detecção automática de sincronização entre faixas
|
- Detecção automática de sincronização entre faixas
|
||||||
- Seleção de faixa padrão no container MKV
|
- Suporte a perfis de configuração reutilizáveis (presets de idioma, offset, drift)
|
||||||
- Remoção de faixas existentes do arquivo original
|
- Mensagens de erro mais amigáveis a partir do stderr do FFmpeg
|
||||||
- Suporte a perfis de configuração salvos
|
- Testes de integração com FFmpeg real no CI (DT-09)
|
||||||
|
- Testes de snapshot para o `FfmpegCommandBuilder` e `MkvmergeCommandBuilder` com projetos complexos
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -341,11 +426,21 @@ src/
|
|||||||
- [x] É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
|
- [x] É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
|
||||||
- [x] É possível editar o offset de sincronização de uma faixa de áudio ou legenda já existente no arquivo
|
- [x] É possível editar o offset de sincronização de uma faixa de áudio ou legenda já existente no arquivo
|
||||||
- [x] É possível atribuir idioma a cada faixa
|
- [x] É possível atribuir idioma a cada faixa
|
||||||
|
- [x] É possível definir um título/nome para cada faixa externa (RF-10)
|
||||||
|
- [x] É possível marcar uma faixa externa como faixa padrão do container (RF-11)
|
||||||
|
- [x] É possível exportar uma faixa existente para arquivo separado (RF-13)
|
||||||
|
- [x] É possível salvar e restaurar a sessão em disco (RF-14)
|
||||||
|
- [x] É possível excluir faixas de áudio ou legenda existentes do arquivo de saída, com possibilidade de restauração antes da geração (RF-15)
|
||||||
|
- [x] É possível reordenar as faixas externas adicionadas com botões ↑↓; a ordem reflete o índice no container final (RF-16)
|
||||||
|
- [x] O modo lote permite configurar e processar múltiplos projetos sequencialmente (RF-09)
|
||||||
|
- [x] É possível definir fator de correção de drift por faixa (RF-12; requer mkvmerge)
|
||||||
- [x] O arquivo MKV é gerado corretamente ao confirmar
|
- [x] O arquivo MKV é gerado corretamente ao confirmar
|
||||||
- [x] Erros do FFmpeg são exibidos de forma legível
|
- [x] Erros do FFmpeg/mkvmerge são exibidos de forma legível na `ExecutionPanel`
|
||||||
- [x] A interface não trava durante o processamento
|
- [x] A interface não trava durante o processamento (execução assíncrona via `tokio`)
|
||||||
- [x] Nenhum reencoding ocorre (verificável via `ffprobe`)
|
- [x] É possível cancelar a geração em andamento
|
||||||
- [x] O flag `-c copy` está sempre presente no comando gerado (verificável via testes unitários e modo debug)
|
- [x] Nenhum reencoding ocorre (verificável via `ffprobe` no arquivo de saída)
|
||||||
|
- [x] O flag `-c copy` está sempre presente no comando FFmpeg gerado (verificável via testes unitários)
|
||||||
|
- [x] O comando FFmpeg/mkvmerge gerado é exibido em painel colapsável na ExecutionPanel (DT-10)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
-244
@@ -1,244 +0,0 @@
|
|||||||
# Progresso de Implementação
|
|
||||||
|
|
||||||
**Data:** 28/02/2026
|
|
||||||
**Status:** Fases 1–7 concluídas — compilando, 33 testes passando, zero warnings do projeto, aplicação executável
|
|
||||||
**Referência:** DEVELOPMENT_PLAN.md v1.3
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resumo Executivo
|
|
||||||
|
|
||||||
Todas as 6 fases do plano de desenvolvimento foram implementadas. O projeto compila sem erros,
|
|
||||||
33 testes unitários passam e a aplicação pode ser executada com `cargo run`.
|
|
||||||
|
|
||||||
Todos os 14 warnings de `unused`/`dead_code` foram resolvidos: assignment morto em `command_builder.rs` removido, lifetime explícito em `FilePath::to_string_lossy` corrigido, e APIs arquiteturais não chamadas pela UI suprimidas com `#[allow(dead_code)]`.
|
|
||||||
|
|
||||||
Após a conclusão das fases, foram implementadas funcionalidades adicionais:
|
|
||||||
|
|
||||||
- Exportação de faixa de áudio ou legenda diretamente da lista de faixas existentes
|
|
||||||
- Exibição das faixas externas adicionadas com opção de remoção individual
|
|
||||||
- Layout responsivo com painéis fixos (cabeçalho, rodapé, barra lateral) e área central com scroll
|
|
||||||
- Progresso em tempo real via `run_ffmpeg_async` (Opção A: `std::sync::mpsc` em toda a cadeia)
|
|
||||||
- Cancelamento de geração em andamento via botão "Cancelar" — encerra o processo FFmpeg filho imediatamente
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status por Fase
|
|
||||||
|
|
||||||
| Fase | Descrição | Status |
|
|
||||||
| ---- | ------------------------------- | ------------ |
|
|
||||||
| 1 | Setup do projeto | ✅ Concluída |
|
|
||||||
| 2 | Domain (núcleo puro) | ✅ Concluída |
|
|
||||||
| 3 | Application (use cases + ports) | ✅ Concluída |
|
|
||||||
| 4 | Adapters (FFmpeg + filesystem) | ✅ Concluída |
|
|
||||||
| 5 | Infrastructure (processo async) | ✅ Concluída |
|
|
||||||
| 6 | UI (eframe/egui) | ✅ Concluída |
|
|
||||||
| 7 | Persistência do estado | ✅ Concluída |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
- [x] **Fase 7 — Persistência do Estado** — novo módulo `infrastructure/persistence` com `save_session`/`load_session`/`session_exists`; JSON em `~/.config/simple-mkv-editor/session.json`; botões "💾 Salvar sessão" / "📂 Carregar sessão" no header com feedback visual (verde/vermelho + botão ✕)
|
|
||||||
- [ ] **Fase 8 — Modo Lote via Abas** — navegação por abas (`Projeto Único` / `Lote`); `Vec<BatchItem>` em `App`; processamento sequencial (um por vez); novo componente `BatchPanel` — ver DEVELOPMENT_PLAN.md Fase 8
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
@@ -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
|
||||||
@@ -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 | ~5–6h |
|
||||||
|
| 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)
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod command_builder;
|
||||||
|
pub mod mkvmerge_gateway;
|
||||||
|
|
||||||
|
pub use command_builder::MkvmergeCommandBuilder;
|
||||||
|
pub use mkvmerge_gateway::MkvmergeGateway;
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod ffmpeg;
|
pub mod ffmpeg;
|
||||||
pub mod filesystem;
|
pub mod filesystem;
|
||||||
|
pub mod mkvmerge;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +1,17 @@
|
|||||||
use crate::domain::entities::Project;
|
use crate::domain::entities::Project;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
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.
|
/// Retorna o caminho do arquivo de sessão de acordo com o SO.
|
||||||
///
|
///
|
||||||
/// - Linux / macOS : `$HOME/.config/simple-mkv-editor/session.json`
|
/// - Linux / macOS : `$HOME/.config/simple-mkv-editor/session.json`
|
||||||
@@ -25,10 +35,10 @@ pub fn session_exists() -> bool {
|
|||||||
session_path().exists()
|
session_path().exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serializa o `Project` para JSON e grava no arquivo de sessão.
|
/// Serializa a `SessionData` para JSON e grava no arquivo de sessão.
|
||||||
///
|
///
|
||||||
/// Cria o diretório pai automaticamente se ele não existir.
|
/// Cria o diretório pai automaticamente se ele não existir.
|
||||||
pub fn save_session(project: &Project) -> Result<()> {
|
pub fn save_session(data: &SessionData) -> Result<()> {
|
||||||
let path = session_path();
|
let path = session_path();
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -41,7 +51,7 @@ pub fn save_session(project: &Project) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let json =
|
let json =
|
||||||
serde_json::to_string_pretty(project).context("Falha ao serializar o estado do projeto")?;
|
serde_json::to_string_pretty(data).context("Falha ao serializar a sessão")?;
|
||||||
|
|
||||||
std::fs::write(&path, json)
|
std::fs::write(&path, json)
|
||||||
.with_context(|| format!("Falha ao gravar sessão em: {}", path.display()))?;
|
.with_context(|| format!("Falha ao gravar sessão em: {}", path.display()))?;
|
||||||
@@ -49,11 +59,11 @@ pub fn save_session(project: &Project) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lê e desserializa o `Project` do arquivo de sessão.
|
/// Lê e desserializa a `SessionData` do arquivo de sessão.
|
||||||
///
|
///
|
||||||
/// Retorna erro amigável se o arquivo estiver ausente, corrompido ou em
|
/// Retorna erro amigável se o arquivo estiver ausente, corrompido ou em
|
||||||
/// versão incompatível com a estrutura atual do domínio.
|
/// versão incompatível com a estrutura atual do domínio.
|
||||||
pub fn load_session() -> Result<Project> {
|
pub fn load_session() -> Result<SessionData> {
|
||||||
let path = session_path();
|
let path = session_path();
|
||||||
|
|
||||||
let json = std::fs::read_to_string(&path).with_context(|| {
|
let json = std::fs::read_to_string(&path).with_context(|| {
|
||||||
@@ -63,8 +73,20 @@ pub fn load_session() -> Result<Project> {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
serde_json::from_str::<Project>(&json).with_context(|| {
|
// Tenta desserializar como SessionData (formato novo).
|
||||||
"Falha ao carregar a sessão. O arquivo pode estar corrompido ou em versão incompatível. \
|
// Se falhar, tenta o formato antigo (Project direto) e migra automaticamente.
|
||||||
Delete o arquivo de sessão e tente novamente."
|
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."
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+430
-124
@@ -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),
|
||||||
@@ -53,6 +63,17 @@ pub struct App {
|
|||||||
|
|
||||||
// Mensagem de feedback de persistência (sucesso ou erro)
|
// Mensagem de feedback de persistência (sucesso ou erro)
|
||||||
session_msg: Option<(String, bool)>, // (texto, é_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 {
|
||||||
@@ -88,6 +109,11 @@ impl App {
|
|||||||
cancel_tx: None,
|
cancel_tx: None,
|
||||||
global_error,
|
global_error,
|
||||||
session_msg,
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,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());
|
||||||
@@ -123,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;
|
||||||
@@ -171,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
|
||||||
@@ -185,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 {
|
||||||
@@ -203,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(());
|
||||||
@@ -211,20 +309,103 @@ 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) ──────────────────────────────────────────
|
||||||
let header_height = 42.0
|
let header_height =
|
||||||
+ if self.global_error.is_some() { 22.0 } else { 0.0 }
|
42.0 + if self.global_error.is_some() {
|
||||||
+ if self.session_msg.is_some() { 22.0 } else { 0.0 };
|
22.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
} + if self.session_msg.is_some() {
|
||||||
|
22.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
egui::TopBottomPanel::top("header_panel")
|
egui::TopBottomPanel::top("header_panel")
|
||||||
.exact_height(header_height)
|
.exact_height(header_height)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
@@ -236,31 +417,37 @@ impl eframe::App for App {
|
|||||||
egui::Layout::right_to_left(egui::Align::Center),
|
egui::Layout::right_to_left(egui::Align::Center),
|
||||||
|ui| {
|
|ui| {
|
||||||
// Botão "Salvar sessão"
|
// Botão "Salvar sessão"
|
||||||
let can_save = self.project.is_some();
|
let can_save =
|
||||||
|
self.project.is_some() || !self.batch_items.is_empty();
|
||||||
if ui
|
if ui
|
||||||
.add_enabled(
|
.add_enabled(
|
||||||
can_save,
|
can_save,
|
||||||
egui::Button::new("💾 Salvar sessão"),
|
egui::Button::new("💾 Salvar sessão"),
|
||||||
)
|
)
|
||||||
.on_disabled_hover_text(
|
.on_disabled_hover_text(
|
||||||
"Abra um projeto para poder salvar a sessão.",
|
"Abra um projeto ou adicione itens ao lote para salvar a sessão.",
|
||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
if let Some(ref project) = self.project {
|
use crate::infrastructure::persistence::SessionData;
|
||||||
match crate::infrastructure::persistence::save_session(
|
let data = SessionData {
|
||||||
project,
|
single_project: self.project.clone(),
|
||||||
) {
|
batch_projects: self
|
||||||
Ok(_) => {
|
.batch_items
|
||||||
self.session_msg = Some((
|
.iter()
|
||||||
"Sessão salva com sucesso.".to_string(),
|
.map(|item| item.project.clone())
|
||||||
false,
|
.collect(),
|
||||||
));
|
};
|
||||||
}
|
match crate::infrastructure::persistence::save_session(&data) {
|
||||||
Err(e) => {
|
Ok(_) => {
|
||||||
self.session_msg =
|
self.session_msg = Some((
|
||||||
Some((format!("Erro ao salvar: {}", e), true));
|
"Sessão salva com sucesso.".to_string(),
|
||||||
}
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.session_msg =
|
||||||
|
Some((format!("Erro ao salvar: {}", e), true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,16 +464,46 @@ impl eframe::App for App {
|
|||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
match crate::infrastructure::persistence::load_session() {
|
match crate::infrastructure::persistence::load_session() {
|
||||||
Ok(project) => {
|
Ok(data) => {
|
||||||
self.pending_source =
|
let probe = FfprobeGateway;
|
||||||
Some(project.source.path.clone());
|
// Restaura projeto único
|
||||||
self.pending_output =
|
if let Some(mut project) = data.single_project {
|
||||||
Some(project.output.path.clone());
|
// Re-executa ffprobe para garantir que existing_tracks
|
||||||
self.existing_track_list
|
// está atualizado independentemente do que foi salvo.
|
||||||
.sync_tracks(&project.existing_tracks);
|
let _ = LoadMediaInfo::execute(&mut project, &probe);
|
||||||
self.project = Some(project);
|
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((
|
self.session_msg = Some((
|
||||||
"Sessão carregada com sucesso.".to_string(),
|
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,
|
false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -324,84 +541,143 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 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;
|
||||||
@@ -409,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()
|
||||||
@@ -422,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,
|
||||||
};
|
};
|
||||||
@@ -473,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;
|
||||||
|
|
||||||
@@ -481,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user