Compare commits
3
Commits
8b031f07ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e836e86b | ||
|
|
d86135ad6b | ||
|
|
e347202e95 |
@@ -1,732 +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
|
||||
|
||||
- [x] Criar `enum ActiveTab` e barra de abas no `update()` de `App`
|
||||
- [x] Criar `BatchItem` e `batch_items: Vec<BatchItem>` em `App`
|
||||
- [x] Criar componente `BatchPanel` com carrinho e formulário inline (Opção A)
|
||||
- [x] Implementar loop sequencial de execução em `App::start_batch_item()` + avanço automático em `poll_background()`
|
||||
- [x] Exibir estado individual por item (`Aguardando | Processando | Concluído | Erro`)
|
||||
- [x] Bloquear adição/remoção de itens durante processamento
|
||||
|
||||
---
|
||||
|
||||
## Fase 9 — Correção de Drift via mkvmerge
|
||||
|
||||
**Objetivo:** Suportar correção de drift progressivo de sincronização (escala temporal) em faixas de áudio e legenda, sem reencoding e sem violar RNF-01, utilizando `mkvmerge` (MKVToolNix) como segundo motor de processamento.
|
||||
|
||||
**Referência:** `proposta_sync_drift.md`
|
||||
|
||||
**Decisão arquitetural central:** O FFmpeg não é capaz de escalar timestamps com `-c copy` — qualquer escala via `-af atempo` reencoda o áudio, violando RNF-01 diretamente. O `mkvmerge` suporta a opção `--sync TID:DELAY,NUM/DEN` que aplica deslocamento e escala de timestamps **no nível do container**, sem tocar nos bytes de mídia. Esta é a única rota viável que respeita a arquitetura atual.
|
||||
|
||||
**Regra de dispatch:**
|
||||
|
||||
- `scale = 1.0` em todas as faixas → pipeline FFmpeg (comportamento atual, **sem modificação**)
|
||||
- Qualquer faixa com `scale ≠ 1.0` → pipeline mkvmerge inteiro para o projeto
|
||||
|
||||
**Dependência nova:** `mkvmerge` (binário do pacote MKVToolNix). Verificação **soft** (aviso, não erro bloqueador) — a funcionalidade de drift fica visualmente desabilitada se ausente; o restante do produto continua funcional.
|
||||
|
||||
---
|
||||
|
||||
### 9.1 — Domain: novo Value Object `SyncTransform`
|
||||
|
||||
**Arquivo:** `src/domain/value_objects/sync_transform.rs` _(novo)_
|
||||
|
||||
Novo value object que representa a transformação temporal completa de uma faixa: deslocamento constante + fator de escala.
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SyncTransform {
|
||||
pub offset_ms: i64, // deslocamento em ms (mesmo semântico de SyncOffset)
|
||||
pub scale: f64, // fator de escala: 1.0 = identidade; 0.99983 = ~25fps→24fps
|
||||
}
|
||||
|
||||
impl SyncTransform {
|
||||
pub fn new(offset_ms: i64, scale: f64) -> Self
|
||||
pub fn from_offset(offset: SyncOffset) -> Self // scale = 1.0
|
||||
pub fn has_drift(&self) -> bool // (scale - 1.0).abs() > 1e-9
|
||||
pub fn is_identity(&self) -> bool // offset_ms == 0 && !has_drift()
|
||||
pub fn to_sync_offset(&self) -> SyncOffset // para compatibilidade com FFmpeg path
|
||||
}
|
||||
|
||||
impl Default for SyncTransform {
|
||||
// { offset_ms: 0, scale: 1.0 }
|
||||
}
|
||||
```
|
||||
|
||||
**Arquivo:** `src/domain/value_objects/mod.rs` _(modificar)_
|
||||
|
||||
- Adicionar `pub mod sync_transform;` e reexportar `SyncTransform`.
|
||||
|
||||
---
|
||||
|
||||
### 9.2 — Domain: campo `drift_scale` nas entidades de faixa
|
||||
|
||||
Estratégia: **não substituir** `offset: SyncOffset` existente (preserva compilação de todo código atual). Adicionar `drift_scale: f64` com default `1.0` em paralelo.
|
||||
|
||||
**Arquivo:** `src/domain/entities/audio_track.rs` _(modificar)_
|
||||
|
||||
- Adicionar campo `pub drift_scale: f64` com valor padrão `1.0`.
|
||||
- Atualizar `AudioTrack::new()` para aceitar o parâmetro `drift_scale: f64`.
|
||||
|
||||
**Arquivo:** `src/domain/entities/subtitle_track.rs` _(modificar)_
|
||||
|
||||
- Idem: adicionar `pub drift_scale: f64` e atualizar construtor.
|
||||
|
||||
**Arquivo:** `src/domain/entities/media_track_info.rs` _(modificar)_
|
||||
|
||||
- Adicionar campo `pub drift_scale: f64` com valor padrão `1.0`.
|
||||
- `MediaTrackInfo::new()` continua sem o parâmetro (usa default); campo mutável diretamente.
|
||||
|
||||
**Arquivo:** `src/domain/entities/track.rs` _(modificar)_
|
||||
|
||||
- Adicionar método `pub fn drift_scale(&self) -> f64` que retorna o campo de `AudioTrack` ou `SubtitleTrack`.
|
||||
- Adicionar método `pub fn set_drift_scale(&mut self, scale: f64)`.
|
||||
|
||||
**Arquivo:** `src/domain/entities/project.rs` _(modificar)_
|
||||
|
||||
- Adicionar método:
|
||||
```rust
|
||||
pub fn needs_mkvmerge(&self) -> bool {
|
||||
let has_drift = |scale: f64| (scale - 1.0).abs() > 1e-9;
|
||||
self.tracks.iter().any(|t| has_drift(t.drift_scale()))
|
||||
|| self.existing_tracks.iter().any(|t| has_drift(t.drift_scale))
|
||||
}
|
||||
```
|
||||
|
||||
**Testes obrigatórios (`src/domain/entities/project.rs`):**
|
||||
|
||||
- `needs_mkvmerge()` retorna `false` quando todas as faixas têm `drift_scale = 1.0`
|
||||
- `needs_mkvmerge()` retorna `true` quando qualquer faixa tem `drift_scale ≠ 1.0`
|
||||
- `SyncTransform::has_drift()` com `scale = 1.0` retorna `false`
|
||||
- `SyncTransform::has_drift()` com `scale = 0.99983` retorna `true`
|
||||
- `SyncTransform::default()` é identidade
|
||||
|
||||
---
|
||||
|
||||
### 9.3 — Application: novos use cases e port
|
||||
|
||||
**Arquivo:** `src/application/ports/mod.rs` _(modificar)_
|
||||
|
||||
- Adicionar novo port:
|
||||
```rust
|
||||
/// Port para muxing com suporte a escala temporal (implementado pelo MkvmergeGateway).
|
||||
pub trait ContainerMuxPort {
|
||||
fn execute(&self, args: Vec<String>) -> Result<()>;
|
||||
}
|
||||
```
|
||||
_(estruturalmente idêntico ao `MediaProcessorPort` — separado por semântica, não por interface)_
|
||||
|
||||
**Arquivo:** `src/application/use_cases/adjust_drift.rs` _(novo)_
|
||||
|
||||
- `AdjustDrift::execute(project: &mut Project, id: TrackId, scale: f64) -> Result<()>`
|
||||
- Localiza faixa em `project.tracks` pelo `id`
|
||||
- Valida: `scale > 0.0` e `scale < 10.0` (protege contra valores absurdos)
|
||||
- Atualiza `track.set_drift_scale(scale)`
|
||||
- Retorna `Err` se `TrackId` não encontrado
|
||||
|
||||
**Arquivo:** `src/application/use_cases/adjust_existing_track_drift.rs` _(novo)_
|
||||
|
||||
- `AdjustExistingTrackDrift::execute(project: &mut Project, id: TrackId, scale: f64) -> Result<()>`
|
||||
- Localiza faixa em `project.existing_tracks` pelo `id`
|
||||
- Valida `scale > 0.0`
|
||||
- Atualiza `track.drift_scale = scale`
|
||||
- Retorna `Err` se não encontrado
|
||||
|
||||
**Arquivo:** `src/application/use_cases/mod.rs` _(modificar)_
|
||||
|
||||
- Expor `adjust_drift` e `adjust_existing_track_drift`.
|
||||
|
||||
**Arquivo:** `src/application/use_cases/add_audio_track.rs` _(modificar)_
|
||||
|
||||
- `AddAudioTrack::execute(...)` recebe um novo parâmetro `drift_scale: f64` (default 1.0 no callsite da UI)
|
||||
- Passa o valor para `AudioTrack::new()`
|
||||
|
||||
**Arquivo:** `src/application/use_cases/add_subtitle.rs` _(modificar)_
|
||||
|
||||
- Idem para `SubtitleTrack`.
|
||||
|
||||
**Testes obrigatórios:**
|
||||
|
||||
- `AdjustDrift` com `TrackId` inexistente retorna `Err`
|
||||
- `AdjustDrift` com `scale = 0.0` retorna `Err`
|
||||
- `AdjustExistingTrackDrift` atualiza corretamente `MediaTrackInfo.drift_scale`
|
||||
|
||||
---
|
||||
|
||||
### 9.4 — Adapters: novo módulo `mkvmerge`
|
||||
|
||||
**Arquivo:** `src/adapters/mkvmerge/mod.rs` _(novo)_
|
||||
|
||||
```
|
||||
src/adapters/mkvmerge/
|
||||
├── mod.rs
|
||||
├── command_builder.rs
|
||||
└── mkvmerge_gateway.rs
|
||||
```
|
||||
|
||||
**Arquivo:** `src/adapters/mkvmerge/command_builder.rs` _(novo)_
|
||||
|
||||
`MkvmergeCommandBuilder::build(project: &Project) -> Vec<String>`
|
||||
|
||||
Lógica detalhada:
|
||||
|
||||
1. **Output** (vem primeiro em mkvmerge):
|
||||
|
||||
```
|
||||
-o <output.path>
|
||||
```
|
||||
|
||||
2. **Tracks existentes com `--sync`** (aplica a cada faixa não-vídeo):
|
||||
|
||||
```
|
||||
--sync <stream_index>:<offset_ms>,<NUM>/<DEN>
|
||||
```
|
||||
|
||||
- `stream_index` do `MediaTrackInfo` é o TID do mkvmerge para o arquivo fonte
|
||||
- `(NUM, DEN)` = `scale_to_rational(drift_scale)` — ver abaixo
|
||||
- Faixas com `drift_scale = 1.0` e `offset_ms = 0`: sem `--sync` (omitidas)
|
||||
|
||||
3. **Metadados e disposição de faixas existentes:**
|
||||
|
||||
```
|
||||
--language <stream_index>:<lang>
|
||||
```
|
||||
|
||||
- Somente se `language.is_some()`
|
||||
|
||||
4. **Arquivo fonte:**
|
||||
|
||||
```
|
||||
<source.path>
|
||||
```
|
||||
|
||||
5. **Faixas externas adicionadas** (cada arquivo externo é um input separado):
|
||||
- TID do input externo i = `source_track_count + i` (onde `source_track_count = project.existing_tracks.len()`)
|
||||
- Para cada faixa externa:
|
||||
```
|
||||
--sync <tid>:<offset_ms>,<NUM>/<DEN> (se offset≠0 ou scale≠1.0)
|
||||
--language <tid>:<lang>
|
||||
--track-name <tid>:<title>
|
||||
--default-track <tid>:yes|no
|
||||
<path_do_arquivo_externo>
|
||||
```
|
||||
|
||||
6. **Utilitário interno:**
|
||||
```rust
|
||||
fn scale_to_rational(scale: f64) -> (u64, u64) {
|
||||
let denom = 1_000_000u64;
|
||||
let numer = (scale * denom as f64).round() as u64;
|
||||
let g = gcd(numer, denom);
|
||||
(numer / g, denom / g)
|
||||
}
|
||||
```
|
||||
Precisão de 0.0001% — suficiente para todos os casos práticos.
|
||||
|
||||
**Arquivo:** `src/adapters/mkvmerge/mkvmerge_gateway.rs` _(novo)_
|
||||
|
||||
- `struct MkvmergeGateway;`
|
||||
- Implementa `ContainerMuxPort`
|
||||
- Executa `mkvmerge` com os args via `std::process::Command`
|
||||
- Captura stderr para `Result<()>` (mesmo padrão do `FfmpegGateway`)
|
||||
|
||||
**Arquivo:** `src/adapters/mod.rs` _(modificar)_
|
||||
|
||||
- Adicionar `pub mod mkvmerge;`
|
||||
|
||||
**Testes obrigatórios (`command_builder.rs`):**
|
||||
|
||||
- Presença de `-o output.mkv` no início dos args
|
||||
- `--sync 1:500,1/1` para faixa com `offset_ms=500`, `scale=1.0`
|
||||
- `--sync 1:0,99983/100000` para faixa com `offset_ms=0`, `scale=0.99983`
|
||||
- Faixa `scale=1.0` e `offset=0` não produz `--sync`
|
||||
- `scale_to_rational(1.0)` === `(1, 1)`
|
||||
- `scale_to_rational(0.99983)` === `(99983, 100000)`
|
||||
- `scale_to_rational(24.0/23.976)` resulta em fração reduzida válida
|
||||
- Output path é **primeiro** argumento (antes dos inputs)
|
||||
|
||||
---
|
||||
|
||||
### 9.5 — Infrastructure: `run_mkvmerge_async`
|
||||
|
||||
**Arquivo:** `src/infrastructure/process/mod.rs` _(modificar)_
|
||||
|
||||
Adicionar função:
|
||||
|
||||
```rust
|
||||
pub async fn run_mkvmerge_async(
|
||||
args: Vec<String>,
|
||||
progress_tx: Sender<String>,
|
||||
cancel_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) -> Result<()>
|
||||
```
|
||||
|
||||
- Estrutura idêntica a `run_ffmpeg_async`
|
||||
- Substitui `Command::new("ffmpeg")` por `Command::new("mkvmerge")`
|
||||
- `mkvmerge` escreve progresso em **stdout** (não em stderr) — ajustar captura para `stdout: Stdio::piped()` e `stderr: Stdio::piped()`; encaminhar ambos ao `progress_tx`
|
||||
|
||||
Adicionar função:
|
||||
|
||||
```rust
|
||||
pub fn mkvmerge_available() -> bool {
|
||||
check_binary_available("mkvmerge").is_ok()
|
||||
}
|
||||
```
|
||||
|
||||
- Não bloqueia — apenas verifica disponibilidade para uso da UI
|
||||
|
||||
---
|
||||
|
||||
### 9.6 — UI: campo de drift e dispatch de execução
|
||||
|
||||
**Arquivo:** `src/ui/components/sync_offset_field.rs` _(modificar)_
|
||||
|
||||
Adicionar campo opcional de drift:
|
||||
|
||||
```
|
||||
Offset: [___0.0s___]
|
||||
Drift: [__100.00_%] ← novo; só visível quando mkvmerge disponível
|
||||
```
|
||||
|
||||
- Label: **"Velocidade original (%)"** (nunca expõe `scale` ou `mkvmerge` ao usuário)
|
||||
- Valor padrão: `"100.00"` (= scale 1.0)
|
||||
- Parsing: `scale = user_input_percent / 100.0`
|
||||
- Validação: valor entre `1.0` e `999.99` (bloqueado fora dessa faixa)
|
||||
- Quando `scale ≠ 1.0`: exibe ícone/texto discreto `"⚠ requer mkvmerge"` em laranja
|
||||
- Quando `mkvmerge` ausente: campo desabilitado com tooltip `"Instale MKVToolNix para usar correção de drift"`
|
||||
|
||||
**Arquivo:** `src/ui/components/existing_track_list.rs` _(modificar)_
|
||||
|
||||
- Expor campo de drift para faixas existentes de áudio e legenda
|
||||
- Usar o mesmo `SyncOffsetField` com o parâmetro de drift habilitado
|
||||
- Ao confirmar: chamar `AdjustExistingTrackDrift::execute()`
|
||||
|
||||
**Arquivo:** `src/ui/app.rs` _(modificar)_
|
||||
|
||||
1. Adicionar campo `mkvmerge_available: bool` ao struct `App`
|
||||
2. Em `App::new()`:
|
||||
```rust
|
||||
mkvmerge_available: crate::infrastructure::process::mkvmerge_available(),
|
||||
```
|
||||
3. Passar `mkvmerge_available` para os componentes que precisam do campo de drift
|
||||
4. Em `App::generate_output()` (método que inicia a geração):
|
||||
```rust
|
||||
if project.needs_mkvmerge() {
|
||||
let args = MkvmergeCommandBuilder::build(&project);
|
||||
// spawn run_mkvmerge_async
|
||||
} else {
|
||||
let args = FfmpegCommandBuilder::build(&project);
|
||||
// spawn run_ffmpeg_async (comportamento atual)
|
||||
}
|
||||
```
|
||||
5. Idem em `App::start_batch_item()` para o modo lote
|
||||
|
||||
**Arquivo:** `src/ui/components/execution_panel.rs` _(modificar — mínimo)_
|
||||
|
||||
- Nenhuma mudança estrutural necessária; o painel recebe mensagens de log do canal existente — funciona para ambos os motores.
|
||||
|
||||
---
|
||||
|
||||
### 9.7 — Compatibilidade e regressão
|
||||
|
||||
| Cenário | Motor | Alteração necessária |
|
||||
| ------------------------------------ | -------- | ------------------------------------------ |
|
||||
| Projeto sem drift (scale=1.0) | FFmpeg | Nenhuma — caminho atual inalterado |
|
||||
| Projeto com drift em faixa existente | mkvmerge | Nova lógica de dispatch |
|
||||
| Projeto com drift em faixa externa | mkvmerge | Nova lógica de dispatch |
|
||||
| Exportação de faixa individual | FFmpeg | Nenhuma — `build_export` inalterado |
|
||||
| Modo lote sem drift | FFmpeg | Dispatch verifica cada `BatchItem.project` |
|
||||
| Modo lote com drift | mkvmerge | Dispatch verifica cada `BatchItem.project` |
|
||||
|
||||
**Invariante preservada:** `FfmpegCommandBuilder::build()` e `FfmpegCommandBuilder::build_export()` **nunca são modificados** nesta fase. Todos os testes existentes continuam passando sem alteração.
|
||||
|
||||
---
|
||||
|
||||
### 9.8 — Tarefas (em ordem)
|
||||
|
||||
**Domain:**
|
||||
|
||||
- [x] Criar `src/domain/value_objects/sync_transform.rs` com `SyncTransform`
|
||||
- [x] Atualizar `src/domain/value_objects/mod.rs` para expor `SyncTransform`
|
||||
- [x] Adicionar `drift_scale: f64` em `AudioTrack` e `SubtitleTrack` (preservando construtores atuais com `drift_scale = 1.0` como default no callsite)
|
||||
- [x] Adicionar `drift_scale: f64` em `MediaTrackInfo`
|
||||
- [x] Adicionar `drift_scale()` e `set_drift_scale()` em `Track`
|
||||
- [x] Adicionar `Project::needs_mkvmerge()` com testes unitários
|
||||
|
||||
**Application:**
|
||||
|
||||
- [x] Criar `adjust_drift.rs` com testes
|
||||
- [x] Criar `adjust_existing_track_drift.rs` com testes
|
||||
- [x] Atualizar `add_audio_track.rs` para aceitar `drift_scale`
|
||||
- [x] Atualizar `add_subtitle.rs` para aceitar `drift_scale`
|
||||
- [x] Adicionar `ContainerMuxPort` em `ports/mod.rs`
|
||||
- [x] Atualizar `mod.rs` dos use cases
|
||||
|
||||
**Adapters:**
|
||||
|
||||
- [x] Criar `src/adapters/mkvmerge/command_builder.rs` com `MkvmergeCommandBuilder::build()` e `scale_to_rational()`
|
||||
- [x] Criar testes unitários para `MkvmergeCommandBuilder` (ver seção 9.4)
|
||||
- [x] Criar `src/adapters/mkvmerge/mkvmerge_gateway.rs` implementando `ContainerMuxPort`
|
||||
- [x] Criar `src/adapters/mkvmerge/mod.rs`
|
||||
- [x] Atualizar `src/adapters/mod.rs`
|
||||
|
||||
**Infrastructure:**
|
||||
|
||||
- [x] Adicionar `run_mkvmerge_async` em `src/infrastructure/process/mod.rs`
|
||||
- [x] Adicionar `mkvmerge_available()` em `src/infrastructure/process/mod.rs`
|
||||
|
||||
**UI:**
|
||||
|
||||
- [x] Atualizar `SyncOffsetField` com campo de drift (condicionado a `mkvmerge_available`)
|
||||
- [x] Atualizar `ExistingTrackList` para expor drift e chamar `AdjustExistingTrackDrift`
|
||||
- [x] Atualizar `AddAudioTrackForm` e `AddSubtitleForm` para passar `drift_scale` ao use case
|
||||
- [x] Adicionar `mkvmerge_available: bool` em `App`
|
||||
- [x] Atualizar `App::generate_output()` com dispatch FFmpeg/mkvmerge
|
||||
- [x] Atualizar `App::start_batch_item()` com o mesmo dispatch
|
||||
|
||||
**Validação final:**
|
||||
|
||||
- [x] `cargo test` — todos os testes existentes passam sem modificação
|
||||
- [ ] Verificar manualmente com arquivo de teste que apresenta drift progressivo
|
||||
|
||||
---
|
||||
|
||||
## Critérios de Conclusão (v1.0)
|
||||
|
||||
Alinhados com o PRD seção 12:
|
||||
|
||||
- [x] É possível selecionar um arquivo de vídeo base
|
||||
- [x] Ao selecionar o vídeo, as faixas existentes são listadas automaticamente (via `ffprobe`)
|
||||
- [x] É possível adicionar uma ou mais faixas de áudio externas
|
||||
- [x] É possível adicionar uma ou mais faixas de legenda
|
||||
- [x] É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
|
||||
- [x] É possível editar o offset de sincronização de uma faixa já existente no arquivo
|
||||
- [x] É possível atribuir idioma a cada faixa
|
||||
- [x] O arquivo MKV é gerado corretamente ao confirmar
|
||||
- [x] Erros do FFmpeg são exibidos de forma legível
|
||||
- [x] A interface não trava durante o processamento
|
||||
- [x] Nenhum reencoding ocorre (verificável via `ffprobe` no arquivo de saída)
|
||||
- [x] O flag `-c copy` está sempre presente no comando gerado (verificável via testes unitários)
|
||||
@@ -1,9 +1,9 @@
|
||||
# PRD — Simple Multimedia Track Audio Editor
|
||||
|
||||
**Versão:** 1.2
|
||||
**Data:** 28/02/2026
|
||||
**Status:** Em desenvolvimento
|
||||
**Revisão:** v1.2 — Adicionado RF-09 (Modo Lote); batch promovido de backlog para requisito planejado
|
||||
**Versão:** 1.5
|
||||
**Data:** 02/03/2026
|
||||
**Status:** v1.0 funcional — Fases 1–9 concluídas; 72 testes passando; aplicação executável
|
||||
**Revisão:** v1.5 — Adicionado RF-16 (Reordenar faixas externas)
|
||||
|
||||
---
|
||||
|
||||
@@ -42,9 +42,8 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
|
||||
- Reencoding / transcodificação de vídeo ou áudio
|
||||
- Preview de vídeo embutido
|
||||
- Detecção automática de sincronização
|
||||
- Remoção de faixas existentes do arquivo original
|
||||
- Seleção de faixa padrão no container
|
||||
- Edição de corte ou splice de vídeo
|
||||
- Salvamento automático em disco (sem intervenção do usuário)
|
||||
|
||||
---
|
||||
|
||||
@@ -127,8 +126,55 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
|
||||
- A seleção de arquivos usa **diálogos nativos** (igual ao modo Projeto Único) — nenhum path é digitado manualmente
|
||||
- O processamento é **sequencial** — um projeto por vez, sem paralelismo, para evitar sobrecarga de I/O
|
||||
- Durante o processamento, o carrinho é bloqueado: não é possível adicionar nem remover itens
|
||||
- Cada item exibe seu estado individual: `Aguardando | Processando | Concluído | Erro`
|
||||
- Cada item exibe seu estado individual: `⏳ Aguardando | ⟳ Processando | ✓ Concluído | ⊸ Cancelado | ✗ Erro`
|
||||
- É possível cancelar o item em execução; os demais permanecem no carrinho com estado `Aguardando`
|
||||
- O dispatch FFmpeg/mkvmerge por item é automático: se qualquer faixa do item tiver `drift_scale ≠ 1.0`, o motor mkvmerge é usado
|
||||
|
||||
### RF-12 — Correção de drift progressivo de sincronização
|
||||
|
||||
- O usuário pode especificar um fator de velocidade original da faixa em porcentagem (ex: `99.983%`)
|
||||
- Internamente representado como `drift_scale: f64` em cada faixa (`AudioTrack`, `SubtitleTrack`, `MediaTrackInfo`)
|
||||
- Implementado via `mkvmerge --sync TID:DELAY,NUM/DEN` — aplica escala de timestamps no nível do container, sem reencoding
|
||||
- **Dispatch automático:** se qualquer faixa tiver `scale ≠ 1.0`, o projeto inteiro usa o pipeline `mkvmerge` em vez do FFmpeg
|
||||
- Campo exibido na interface como **"Velocidade original (%)"** — jamais expõe termos técnicos como `scale`, `drift` ou `mkvmerge`
|
||||
- O campo é desabilitado com tooltip explicativo quando `mkvmerge` não está disponível no `PATH`
|
||||
- Funciona tanto no modo Projeto Único quanto no modo Lote
|
||||
- Preserva a invariante RNF-01: nenhum byte de mídia é reprocessado
|
||||
|
||||
### RF-13 — Exportar faixa existente
|
||||
|
||||
- A partir da lista de faixas detectadas pelo `ffprobe`, o usuário pode exportar qualquer faixa de áudio ou legenda para um arquivo separado
|
||||
- O formato do arquivo de saída é inferido a partir do codec da faixa (ex: AAC → `.aac`, SRT → `.srt`)
|
||||
- Implementado via `FfmpegCommandBuilder::build_export()` com `-c:a copy` para áudio; legendas omitem `-c` (conversão de container de texto, sem processamento de mídia)
|
||||
- O diálogo de salvamento usa filtro por extensão correspondente ao codec detectado
|
||||
|
||||
### RF-14 — Persistência de sessão
|
||||
|
||||
- O usuário pode salvar o estado completo do projeto em disco com o botão **"💾 Salvar sessão"**
|
||||
- O estado é restaurado com o botão **"📂 Carregar sessão"**
|
||||
- Formato: JSON via `serde_json`; local: `~/.config/simple-mkv-editor/session.json` (Linux) / `AppData` (Windows)
|
||||
- Feedback visual no header: indicador verde (sucesso) ou vermelho (erro) com botão ✕ para dispensar
|
||||
- Erros de desserialização (arquivo corrompido ou versão incompatível) são tratados com mensagem amigável
|
||||
- Salvamento automático não é realizado — apenas sob demanda do usuário
|
||||
|
||||
### RF-15 — Excluir faixa existente do arquivo de saída
|
||||
|
||||
- O usuário pode marcar qualquer faixa de áudio ou legenda **já presente no arquivo de entrada** para ser omitida do arquivo de saída
|
||||
- A operação é reversível: a faixa pode ser restaurada antes da geração do MKV
|
||||
- Faixas marcadas como excluídas são exibidas com texto tachado e cor esmaecida na lista; seus campos de offset e drift ficam desabilitados
|
||||
- Faixas de vídeo e dados **não** podem ser excluídas — somente áudio e legenda
|
||||
- Implementado via campo `excluded: bool` em `MediaTrackInfo`; método `toggle_existing_track_excluded()` em `Project`
|
||||
- Ambos os pipelines (FFmpeg e mkvmerge) respeitam o flag: faixas excluídas são filtradas antes da geração dos argumentos de `-map` / `--audio-tracks` / `--subtitle-tracks`
|
||||
- O estado `excluded` é persistido junto com a sessão (RF-14)
|
||||
|
||||
### RF-16 — Reordenar faixas externas
|
||||
|
||||
- O usuário pode alterar a ordem das faixas externas adicionadas (áudio e legenda) usando os botões **↑** e **↓** na lista de faixas
|
||||
- A ordem determina o índice de stream no container MKV final — relevante para players que selecionam faixas por posição (ex: VLC, Jellyfin)
|
||||
- O botão ↑ fica desabilitado na primeira faixa da lista; o botão ↓, na última
|
||||
- Implementado via método `move_track(id, delta: i8)` em `Project`, usando `Vec::swap` — ambos os pipelines (FFmpeg e mkvmerge) iteram `project.tracks` em ordem e não precisam de alteração
|
||||
- Somente faixas **externas** (`project.tracks`) são reordenáveis; faixas existentes (`project.existing_tracks`, lidas via ffprobe) permanecem na ordem detectada
|
||||
- A nova ordem é persistida automaticamente junto com a sessão (RF-14)
|
||||
|
||||
---
|
||||
|
||||
@@ -159,9 +205,11 @@ A operação de mux deve ser concluída em tempo proporcional ao tamanho do arqu
|
||||
|
||||
O arquivo de saída deve ser compatível com players modernos que suportam MKV (ex: VLC, mpv, Jellyfin).
|
||||
|
||||
### RNF-04 — Dependência externa
|
||||
### RNF-04 — Dependências externas
|
||||
|
||||
FFmpeg deve estar instalado no sistema. A aplicação não o embute por padrão, porém pode ser distribuída junto.
|
||||
FFmpeg e ffprobe devem estar instalados no sistema. A aplicação verifica a presença de ambos na inicialização e exibe erro global se ausentes.
|
||||
|
||||
mkvmerge (MKVToolNix) é uma dependência opcional. Quando ausente, a funcionalidade de correção de drift (RF-12) fica desabilitada visualmente; o restante do produto continua funcional. A verificação é **soft** — não bloqueia a inicialização.
|
||||
|
||||
### RNF-05 — Interface responsiva
|
||||
|
||||
@@ -184,13 +232,15 @@ Arquivo de saída (.mkv)
|
||||
### Stack
|
||||
|
||||
| Camada | Tecnologia | Motivo |
|
||||
| ------------ | ------------------------------ | ------------------------------------ |
|
||||
| ------------- | ------------------------------- | ------------------------------------------- |
|
||||
| Interface | `egui` / `eframe` | Nativo, simples, multiplataforma |
|
||||
| Backend | Rust (`std::process::Command`) | Estável, sem dependências externas |
|
||||
| Erros | `anyhow` | Propagação de erros simplificada |
|
||||
| Configuração | `serde` | Serialização de perfis e histórico |
|
||||
| Async | `tokio` (opcional) | Execução não bloqueante da interface |
|
||||
| Mídia | FFmpeg (externo) | Maduro, estável, amplamente testado |
|
||||
| Configuração | `serde` + `serde_json` | Serialização de sessão em JSON |
|
||||
| Async | `tokio` | Execução não bloqueante da interface |
|
||||
| Diálogos | `rfd` | Diálogos nativos de arquivo multiplataforma |
|
||||
| Mídia (mux) | FFmpeg (externo) | Mux sem reencoding; caminho padrão |
|
||||
| Mídia (drift) | mkvmerge / MKVToolNix (externo) | Correção de drift sem reencoding (opcional) |
|
||||
| Container | MKV | Melhor suporte a múltiplas faixas |
|
||||
|
||||
### Dependências Cargo
|
||||
@@ -199,7 +249,9 @@ Arquivo de saída (.mkv)
|
||||
eframe = "0.27"
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["process"] } # opcional
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "rt-multi-thread", "macros", "io-util", "sync"] }
|
||||
rfd = "0.14"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -236,7 +288,7 @@ Contém as entidades e objetos de valor do negócio. Não depende de nada extern
|
||||
| Tipo | Exemplos |
|
||||
| ------------- | ------------------------------------------------------------------------------------ |
|
||||
| Entities | `Project`, `VideoFile`, `AudioTrack`, `SubtitleTrack`, `MkvOutput`, `MediaTrackInfo` |
|
||||
| Value Objects | `TrackId`, `SyncOffset`, `TrackLanguage`, `FilePath` |
|
||||
| Value Objects | `TrackId`, `SyncOffset`, `SyncTransform`, `TrackLanguage`, `FilePath` |
|
||||
|
||||
> **`Project`** é a entidade central do domínio. Representa a sessão de edição completa do usuário e deve ser a única fonte de verdade do estado em memória.
|
||||
>
|
||||
@@ -248,9 +300,13 @@ Contém as entidades e objetos de valor do negócio. Não depende de nada extern
|
||||
> └── output: MkvOutput
|
||||
> ```
|
||||
>
|
||||
> **`TrackId`** abstrai os índices de stream do FFmpeg. Internamente é um identificador opaco (`u32` ou `String`). Nenhuma lógica de negócio deve depender de índices FFmpeg diretamente — isso é responsabilidade do adapter.
|
||||
> Método relevante: `needs_mkvmerge() -> bool` — retorna `true` se qualquer faixa tiver `drift_scale ≠ 1.0`, sinalizando que o projeto deve usar o pipeline `mkvmerge` em vez do FFmpeg.
|
||||
>
|
||||
> **`TrackId`** abstrai os índices de stream do FFmpeg. Internamente é um identificador opaco (`u32`). Nenhuma lógica de negócio deve depender de índices FFmpeg diretamente — isso é responsabilidade do adapter.
|
||||
>
|
||||
> **`SyncOffset`** armazena o offset de sincronização em **milissegundos como inteiro** (`i64`). Nunca como `f64`. O uso de ponto flutuante acumula erro de precisão; o adapter é responsável por converter para o formato exigido pelo FFmpeg (`-itsoffset`).
|
||||
>
|
||||
> **`SyncTransform`** é o value object que combina deslocamento e escala temporal: `{ offset_ms: i64, scale: f64 }`. Usado internamente para representar a transformação completa de uma faixa no pipeline mkvmerge (RF-12). `scale = 1.0` representa identidade; `scale ≠ 1.0` ativa o correto motor de dispatch.
|
||||
|
||||
#### Application (casos de uso)
|
||||
|
||||
@@ -258,9 +314,9 @@ Contém as regras de negócio da aplicação. Orquestra as entidades do domínio
|
||||
Define traits (ports) que as camadas externas devem implementar.
|
||||
|
||||
| Tipo | Exemplos |
|
||||
| --------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Use Cases | `LoadMediaInfo`, `AddAudioTrack`, `AddSubtitle`, `AdjustSync`, `EditExistingTrackSync`, `SetTrackLanguage`, `GenerateOutput` |
|
||||
| Ports | `MediaProcessorPort`, `MediaInfoPort`, `FileSystemPort` |
|
||||
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Use Cases | `LoadMediaInfo`, `AddAudioTrack`, `AddSubtitle`, `AdjustSync`, `AdjustDrift`, `AdjustExistingTrackDrift`, `EditExistingTrackSync`, `ExportTrack`, `RemoveTrack`, `SetTrackLanguage`, `GenerateOutput` |
|
||||
| Ports | `MediaProcessorPort`, `MediaInfoPort`, `FileSystemPort`, `ContainerMuxPort` |
|
||||
|
||||
> **`MediaInfoPort`** é o port responsável por inspecionar arquivos de mídia existentes. Deve ser definido na camada Application e implementado na camada Adapters via `FfprobeGateway`.
|
||||
>
|
||||
@@ -279,14 +335,19 @@ Define traits (ports) que as camadas externas devem implementar.
|
||||
Implementam os ports definidos na camada de Application. Traduzem dados entre o domínio e o mundo externo.
|
||||
|
||||
| Tipo | Exemplos |
|
||||
| ------------ | --------------------------------------------------------- |
|
||||
| ------------ | ------------------------------------------------------------------------- |
|
||||
| Gateway | `FfmpegCommandBuilder`, `FfmpegGateway`, `FfprobeGateway` |
|
||||
| Presenter | `ErrorPresenter` (formata stderr do FFmpeg) |
|
||||
| File Adapter | `FilePickerAdapter` |
|
||||
| Gateway | `MkvmergeCommandBuilder`, `MkvmergeGateway` (motor de drift — RF-12) |
|
||||
| Presenter | `ErrorPresenter` (formata stderr do FFmpeg / stdout do mkvmerge) |
|
||||
| File Adapter | `FilePickerAdapter` (inclui `save_audio(codec)` e `save_subtitle(codec)`) |
|
||||
|
||||
> **`FfprobeGateway`** implementa `MediaInfoPort`. Executa `ffprobe -v quiet -print_format json -show_streams` e mapeia a saída para `Vec<MediaTrackInfo>`. Não conhece o domínio além das structs que está populando.
|
||||
>
|
||||
> **`FfmpegCommandBuilder`** é responsável por converter `TrackId` para índices `-map 0:a:N` do FFmpeg e `SyncOffset` (ms inteiro) para o formato `-itsoffset 1.200` aceito pelo binário. **Toda conversão de tipos internos para argumentos FFmpeg fica aqui e somente aqui.**
|
||||
>
|
||||
> Além do `-c copy`, sempre emite `-max_interleave_delta 0` (evita descarte silencioso de pacotes em streams com grande delta de timestamps) e `-avoid_negative_ts make_zero` (normaliza timestamps negativos comuns em M4A/AAC de serviços de streaming).
|
||||
>
|
||||
> **`MkvmergeCommandBuilder`** converte `Project` em argumentos para o `mkvmerge`. Usa `--sync TID:DELAY,NUM/DEN` para aplicar deslocamento e escala de timestamps sem reencoding. A escala `f64` é convertida para fração racionl `(NUM, DEN)` com precisão de 6 casas decimais.
|
||||
|
||||
#### Infrastructure / UI
|
||||
|
||||
@@ -306,18 +367,23 @@ Camada mais externa. Contém o framework de UI e a execução real de processos.
|
||||
src/
|
||||
├── domain/
|
||||
│ ├── entities/ # Project, VideoFile, AudioTrack, SubtitleTrack, MkvOutput, MediaTrackInfo
|
||||
│ └── value_objects/ # TrackId, SyncOffset (ms/i64), TrackLanguage, FilePath
|
||||
│ └── value_objects/ # TrackId, SyncOffset (ms/i64), SyncTransform (offset+scale), TrackLanguage, FilePath
|
||||
├── application/
|
||||
│ ├── use_cases/ # LoadMediaInfo, AddAudioTrack, AddSubtitle, AdjustSync, GenerateOutput
|
||||
│ └── ports/ # Traits: MediaProcessorPort, MediaInfoPort, FileSystemPort
|
||||
│ ├── use_cases/ # LoadMediaInfo, AddAudioTrack, AddSubtitle, AdjustSync, AdjustDrift,
|
||||
│ │ # AdjustExistingTrackDrift, EditExistingTrackSync, ExportTrack,
|
||||
│ │ # RemoveTrack, SetTrackLanguage, GenerateOutput
|
||||
│ └── ports/ # Traits: MediaProcessorPort, MediaInfoPort, FileSystemPort, ContainerMuxPort
|
||||
├── adapters/
|
||||
│ ├── ffmpeg/ # FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway
|
||||
│ ├── mkvmerge/ # MkvmergeCommandBuilder, MkvmergeGateway (RF-12 — drift)
|
||||
│ └── filesystem/ # FilePickerAdapter
|
||||
├── infrastructure/
|
||||
│ └── process/ # Execução real do FFmpeg / ffprobe
|
||||
│ ├── process/ # run_ffmpeg_async, run_mkvmerge_async, validate_dependencies, mkvmerge_available
|
||||
│ └── persistence/ # save_session, load_session (RF-14)
|
||||
├── ui/
|
||||
│ ├── app.rs # eframe App (ponto de entrada da interface)
|
||||
│ └── components/ # Componentes egui reutilizáveis
|
||||
│ ├── app.rs # eframe App; ActiveTab (Single/Batch); dispatch FFmpeg/mkvmerge
|
||||
│ └── components/ # VideoSelector, ExistingTrackList, AddAudioTrackForm, AddSubtitleForm,
|
||||
│ # SyncOffsetField (+ campo drift), OutputSelector, ExecutionPanel, BatchPanel
|
||||
└── main.rs
|
||||
```
|
||||
|
||||
@@ -325,15 +391,18 @@ src/
|
||||
|
||||
## 10. Débitos Técnicos
|
||||
|
||||
| ID | Descrição | Impacto | Mitigação |
|
||||
| ----- | ----------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| ID | Descrição | Impacto | Mitigação / Status |
|
||||
| ----- | ------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DT-01 | FFmpeg não embutido | Usuário precisa instalar | Distribuir junto com a aplicação |
|
||||
| DT-02 | Dependência de processo externo | Menor controle interno | Encapsular via módulo de serviço |
|
||||
| DT-03 | Parsing de erros do FFmpeg (stderr) | Necessário tratamento manual | Parsear saída e exibir mensagem amigável |
|
||||
| DT-04 | Compatibilidade de codecs | Alguns codecs podem não ser aceitos | Usar MKV como container padrão |
|
||||
| DT-05 | Performance de processo externo | Pequeno overhead | Aceitável — mux é rápido |
|
||||
| DT-06 | ffprobe como dependência adicional | Parsing de JSON da saída do ffprobe | Validar presença de ffprobe na inicialização; exibir mensagem clara se ausente |
|
||||
| DT-07 | Compatibilidade com Jellyfin mobile | Faixas não selecionadas automaticamente | Investigando; as RFC RF-10 e RF-11 mitigam parcialmente — `title` e `disposition` são emitidos corretamente, porém o Jellyfin mobile pode ter comportamento diferente do web; requer testes com arquivo gerado |
|
||||
| DT-02 | Dependência de processo externo | Menor controle interno | Encapsulado via `infrastructure/process`; `validate_dependencies()` na inicialização |
|
||||
| DT-03 | Parsing de erros do FFmpeg (stderr) | Mensagens brutas exibidas ao usuário | Stderr capturado linha a linha via `run_ffmpeg_async`; exibido no `ExecutionPanel`; parse amigável futuro |
|
||||
| DT-04 | Compatibilidade de codecs | Alguns codecs podem não ser aceitos | MKV como container padrão; `-c copy` garante que não há transcodificação |
|
||||
| DT-05 | Performance de processo externo | Pequeno overhead | Aceitável — mux é rápido; sem impacto perceptível |
|
||||
| DT-06 | ffprobe como dependência adicional | Parsing de JSON da saída do ffprobe | ✅ Resolvido — `validate_dependencies()` verifica ffprobe na inicialização; erro global exibido se ausente |
|
||||
| DT-07 | Compatibilidade com Jellyfin mobile | Faixas não selecionadas automaticamente | Investigando — RF-10 (`title`) e RF-11 (`disposition`) implementados; comportamento no Jellyfin mobile requer testes com arquivo gerado |
|
||||
| DT-08 | mkvmerge não embutido | Usuário precisa instalar MKVToolNix | Dependência opcional; feat. de drift desabilitada visualmente se ausente; verificação soft na inicialização |
|
||||
| DT-09 | Testes de integração com FFmpeg real | Regressões em comandos gerados | Pendente — requer `ffmpeg` no CI; coberto indiretamente pelos testes unitários do `FfmpegCommandBuilder` |
|
||||
| DT-10 | Comando FFmpeg não visível ao usuário | Difícil de debugar manualmente | ✅ Resolvido — painel colapsável "🛠 Comando gerado" na `ExecutionPanel` e em cada item do modo Lote; botão de cópia para clipboard |
|
||||
|
||||
---
|
||||
|
||||
@@ -341,9 +410,10 @@ src/
|
||||
|
||||
- Preview de vídeo embutido na interface
|
||||
- Detecção automática de sincronização entre faixas
|
||||
- Seleção de faixa padrão no container MKV
|
||||
- Remoção de faixas existentes do arquivo original
|
||||
- Suporte a perfis de configuração salvos
|
||||
- Suporte a perfis de configuração reutilizáveis (presets de idioma, offset, drift)
|
||||
- Mensagens de erro mais amigáveis a partir do stderr do FFmpeg
|
||||
- Testes de integração com FFmpeg real no CI (DT-09)
|
||||
- Testes de snapshot para o `FfmpegCommandBuilder` e `MkvmergeCommandBuilder` com projetos complexos
|
||||
|
||||
---
|
||||
|
||||
@@ -356,11 +426,21 @@ src/
|
||||
- [x] É possível definir offset de sincronização por faixa ao adicionar uma nova faixa
|
||||
- [x] É possível editar o offset de sincronização de uma faixa de áudio ou legenda já existente no arquivo
|
||||
- [x] É possível atribuir idioma a cada faixa
|
||||
- [x] É possível definir um título/nome para cada faixa externa (RF-10)
|
||||
- [x] É possível marcar uma faixa externa como faixa padrão do container (RF-11)
|
||||
- [x] É possível exportar uma faixa existente para arquivo separado (RF-13)
|
||||
- [x] É possível salvar e restaurar a sessão em disco (RF-14)
|
||||
- [x] É possível excluir faixas de áudio ou legenda existentes do arquivo de saída, com possibilidade de restauração antes da geração (RF-15)
|
||||
- [x] É possível reordenar as faixas externas adicionadas com botões ↑↓; a ordem reflete o índice no container final (RF-16)
|
||||
- [x] O modo lote permite configurar e processar múltiplos projetos sequencialmente (RF-09)
|
||||
- [x] É possível definir fator de correção de drift por faixa (RF-12; requer mkvmerge)
|
||||
- [x] O arquivo MKV é gerado corretamente ao confirmar
|
||||
- [x] Erros do FFmpeg são exibidos de forma legível
|
||||
- [x] A interface não trava durante o processamento
|
||||
- [x] Nenhum reencoding ocorre (verificável via `ffprobe`)
|
||||
- [x] O flag `-c copy` está sempre presente no comando gerado (verificável via testes unitários e modo debug)
|
||||
- [x] Erros do FFmpeg/mkvmerge são exibidos de forma legível na `ExecutionPanel`
|
||||
- [x] A interface não trava durante o processamento (execução assíncrona via `tokio`)
|
||||
- [x] É possível cancelar a geração em andamento
|
||||
- [x] Nenhum reencoding ocorre (verificável via `ffprobe` no arquivo de saída)
|
||||
- [x] O flag `-c copy` está sempre presente no comando FFmpeg gerado (verificável via testes unitários)
|
||||
- [x] O comando FFmpeg/mkvmerge gerado é exibido em painel colapsável na ExecutionPanel (DT-10)
|
||||
|
||||
---
|
||||
|
||||
|
||||
-281
@@ -1,281 +0,0 @@
|
||||
# Progresso de Implementação
|
||||
|
||||
**Data:** 28/02/2026
|
||||
**Status:** Fases 1–8 concluídas — compilando, 33 testes passando, zero warnings do projeto, aplicação executável
|
||||
**Referência:** DEVELOPMENT_PLAN.md v1.3
|
||||
|
||||
---
|
||||
|
||||
## Resumo Executivo
|
||||
|
||||
Todas as 6 fases do plano de desenvolvimento foram implementadas. O projeto compila sem erros,
|
||||
33 testes unitários passam e a aplicação pode ser executada com `cargo run`.
|
||||
|
||||
Todos os 14 warnings de `unused`/`dead_code` foram resolvidos: assignment morto em `command_builder.rs` removido, lifetime explícito em `FilePath::to_string_lossy` corrigido, e APIs arquiteturais não chamadas pela UI suprimidas com `#[allow(dead_code)]`.
|
||||
|
||||
Após a conclusão das fases, foram implementadas funcionalidades adicionais:
|
||||
|
||||
- Exportação de faixa de áudio ou legenda diretamente da lista de faixas existentes
|
||||
- Exibição das faixas externas adicionadas com opção de remoção individual
|
||||
- Layout responsivo com painéis fixos (cabeçalho, rodapé, barra lateral) e área central com scroll
|
||||
- Progresso em tempo real via `run_ffmpeg_async` (Opção A: `std::sync::mpsc` em toda a cadeia)
|
||||
- Cancelamento de geração em andamento via botão "Cancelar" — encerra o processo FFmpeg filho imediatamente
|
||||
- **Fase 8 — Modo Lote via Abas** — barra de abas ("Projeto Único" / "🗂 Lote"); `BatchPanel` com carrinho de projetos e formulário inline de adição; processamento sequencial automático via `start_batch_item` + avanço em `poll_background`; estado individual por item (`⏳ Aguardando | ⟳ Processando | ✓ Concluído | ⊸ Cancelado | ✗ Erro`); cancelamento do item atual com botão "⏹ Cancelar item atual"; carrinho bloqueado durante processamento
|
||||
|
||||
---
|
||||
|
||||
## Status por Fase
|
||||
|
||||
| Fase | Descrição | Status |
|
||||
| ---- | ------------------------------- | ------------ |
|
||||
| 1 | Setup do projeto | ✅ Concluída |
|
||||
| 2 | Domain (núcleo puro) | ✅ Concluída |
|
||||
| 3 | Application (use cases + ports) | ✅ Concluída |
|
||||
| 4 | Adapters (FFmpeg + filesystem) | ✅ Concluída |
|
||||
| 5 | Infrastructure (processo async) | ✅ Concluída |
|
||||
| 6 | UI (eframe/egui) | ✅ Concluída |
|
||||
| 7 | Persistência do estado | ✅ Concluída |
|
||||
| 8 | Modo Lote via Abas | ✅ Concluída |
|
||||
|
||||
---
|
||||
|
||||
## Estrutura de Arquivos Criados
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs — entrada, configuração da janela eframe
|
||||
│
|
||||
├── domain/
|
||||
│ ├── mod.rs
|
||||
│ ├── value_objects/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── file_path.rs — FilePath (newtype sobre PathBuf)
|
||||
│ │ ├── track_id.rs — TrackId (newtype opaco sobre u32)
|
||||
│ │ ├── sync_offset.rs — SyncOffset (i64 ms, nunca f64)
|
||||
│ │ └── track_language.rs — TrackLanguage (ISO 639-2, validado)
|
||||
│ └── entities/
|
||||
│ ├── mod.rs
|
||||
│ ├── project.rs — Project (entidade raiz / fonte de verdade)
|
||||
│ ├── video_file.rs — VideoFile
|
||||
│ ├── audio_track.rs — AudioTrack
|
||||
│ ├── subtitle_track.rs — SubtitleTrack
|
||||
│ ├── media_track_info.rs — MediaTrackInfo + TrackKind
|
||||
│ ├── mkv_output.rs — MkvOutput
|
||||
│ └── track.rs — Track (enum: Audio | Subtitle)
|
||||
│
|
||||
├── application/
|
||||
│ ├── mod.rs
|
||||
│ ├── ports/
|
||||
│ │ └── mod.rs — MediaInfoPort, MediaProcessorPort, FileSystemPort
|
||||
│ └── use_cases/
|
||||
│ ├── mod.rs
|
||||
│ ├── load_media_info.rs — popula Project::existing_tracks via ffprobe
|
||||
│ ├── add_audio_track.rs — adiciona AudioTrack ao Project
|
||||
│ ├── add_subtitle.rs — adiciona SubtitleTrack ao Project
|
||||
│ ├── adjust_sync.rs — altera SyncOffset de faixa externa
|
||||
│ ├── edit_existing_track_sync.rs — altera SyncOffset de faixa existente
|
||||
│ ├── export_track.rs — exporta faixa de áudio ou legenda para arquivo
|
||||
│ ├── remove_track.rs — remove faixa externa do projeto pelo TrackId
|
||||
│ ├── set_track_language.rs — altera idioma de faixa externa
|
||||
│ └── generate_output.rs — constrói comando e delega ao MediaProcessorPort
|
||||
│
|
||||
├── adapters/
|
||||
│ ├── mod.rs
|
||||
│ ├── ffmpeg/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── command_builder.rs — FfmpegCommandBuilder: Project → Vec<String>; build_export()
|
||||
│ │ ├── ffprobe_gateway.rs — FfprobeGateway impl MediaInfoPort
|
||||
│ │ └── ffmpeg_gateway.rs — FfmpegGateway impl MediaProcessorPort
|
||||
│ └── filesystem/
|
||||
│ ├── mod.rs — RealFileSystem impl FileSystemPort
|
||||
│ └── file_picker.rs — FilePickerAdapter; save_audio(codec), save_subtitle(codec)
|
||||
│
|
||||
├── infrastructure/
|
||||
│ ├── mod.rs
|
||||
│ └── process/
|
||||
└── mod.rs — run_ffmpeg_async (tokio, cancel via oneshot), validate_dependencies()
|
||||
│
|
||||
└── ui/
|
||||
├── mod.rs
|
||||
├── app.rs — eframe::App; Project como única fonte de verdade; cancel_tx para interromper FFmpeg
|
||||
└── components/
|
||||
├── mod.rs
|
||||
├── video_selector.rs — seleção do vídeo base
|
||||
├── output_selector.rs — seleção do arquivo de saída (.mkv)
|
||||
├── existing_track_list.rs — lista faixas detectadas + edição de offset│ ├── added_track_list.rs — lista faixas externas adicionadas + botão remover ├── add_audio_track_form.rs — formulário: áudio externo
|
||||
├── add_subtitle_form.rs — formulário: legenda externa
|
||||
├── sync_offset_field.rs — campo de atraso em segundos
|
||||
├── language_field.rs — campo de idioma ISO 639-2
|
||||
├── execution_panel.rs — botão gerar, botão cancelar, progresso, estados: Idle/Running/Success/Cancelled/Error
|
||||
└── batch_panel.rs — BatchItem, BatchPanel; carrinho de lote + formulário inline; estado por item
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Inventário de Mudanças — Compatibilidade com Jellyfin (01/03/2026)
|
||||
|
||||
### Contexto
|
||||
|
||||
Arquivos M4A produzidos pelo Google (ex: downloads do YouTube Music ou Google Drive) carregam
|
||||
um metadado `title` na stream de áudio com o valor `"ISO Media file produced by Google Inc."`. Quando
|
||||
o FFmpeg faz o mux sem sobrescrever esse metadado, ele é copiado literalmente para a faixa do MKV.
|
||||
|
||||
O Jellyfin **web** é tolerante e usa a tag `language` para selecionar a faixa preferida. Já o
|
||||
Jellyfin **mobile** usa o campo `title` da stream para exibir e selecionar faixas — quando não
|
||||
reconhece o título como idioma, não realiza seleção automática e mantém a faixa original.
|
||||
|
||||
Algo semelhante ocorre com `disposition:default`: a faixa original do fonte fica com `default=1`;
|
||||
a nova faixa fica com `default=0`; o Jellyfin mobile respeitam esse flag e tocam a original.
|
||||
|
||||
> **Status:** mudanças implementadas e compilando; investigação em andamento — o problema no
|
||||
> Jellyfin mobile ainda não foi completamente resolvido.
|
||||
|
||||
### Mudanças por camada
|
||||
|
||||
| Arquivo | Mudança |
|
||||
| --- | --- |
|
||||
| `domain/entities/audio_track.rs` | Adicionado `is_default: bool` e `title: String` a `AudioTrack` |
|
||||
| `domain/entities/subtitle_track.rs` | Adicionado `is_default: bool` e `title: String` a `SubtitleTrack` |
|
||||
| `application/use_cases/add_audio_track.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
|
||||
| `application/use_cases/add_subtitle.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
|
||||
| `adapters/ffmpeg/command_builder.rs` | Emite `-disposition:a/s:{idx} 0` nas faixas existentes quando uma externa é marcada como padrão; emite `-disposition:a/s:{idx} default` na faixa marcada; emite `-metadata:s:a/s:{idx} title={valor}` para toda faixa externa (string vazia apaga o título herdado) |
|
||||
| `ui/components/add_audio_track_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
|
||||
| `ui/components/add_subtitle_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
|
||||
| `ui/components/batch_panel.rs` | `PendingAudio` e `PendingSubtitle` propagam `is_default` e `title` |
|
||||
|
||||
---
|
||||
|
||||
## Testes Unitários (33/33 passando)
|
||||
|
||||
### Domain — Value Objects
|
||||
|
||||
| Teste | Resultado |
|
||||
| ------------------------------------------------------------------------ | --------- |
|
||||
| `sync_offset::from_seconds_str_positivo` — `"1.2"` → `SyncOffset(1200)` | ✅ |
|
||||
| `sync_offset::from_seconds_str_negativo` — `"-0.5"` → `SyncOffset(-500)` | ✅ |
|
||||
| `sync_offset::from_seconds_str_com_sufixo_s` | ✅ |
|
||||
| `sync_offset::from_seconds_str_invalido` | ✅ |
|
||||
| `sync_offset::from_seconds_str_zero` | ✅ |
|
||||
| `track_language::idioma_valido` — `"por"`, `"eng"` | ✅ |
|
||||
| `track_language::idioma_invalido_curto` — `"pt"` | ✅ |
|
||||
| `track_language::idioma_invalido_maiusculo` — `"POR"` | ✅ |
|
||||
| `track_language::idioma_invalido_longo` — `"port"` | ✅ |
|
||||
|
||||
### Domain — Entities
|
||||
|
||||
| Teste | Resultado |
|
||||
| ------------------------------------------------------------------------- | --------- |
|
||||
| `project::projeto_valido` | ✅ |
|
||||
| `project::projeto_invalido_mesmo_caminho` — output == source retorna erro | ✅ |
|
||||
| `project::track_id_opaco` — TrackId não expõe indexação interna | ✅ |
|
||||
|
||||
### Application — Use Cases (todos com mocks, sem I/O real)
|
||||
|
||||
| Teste | Resultado |
|
||||
| ------------------------------------------------------------------ | --------- |
|
||||
| `load_media_info::carrega_faixas_no_projeto` | ✅ |
|
||||
| `add_audio_track::adiciona_audio_no_projeto` | ✅ |
|
||||
| `add_subtitle::adiciona_legenda_no_projeto` | ✅ |
|
||||
| `adjust_sync::ajusta_offset_existente` | ✅ |
|
||||
| `adjust_sync::erro_se_id_inexistente` | ✅ |
|
||||
| `edit_existing_track_sync::edita_offset_de_faixa_existente` | ✅ |
|
||||
| `edit_existing_track_sync::erro_se_faixa_existente_nao_encontrada` | ✅ |
|
||||
| `set_track_language::altera_idioma_da_faixa` | ✅ |
|
||||
| `generate_output::sempre_inclui_c_copy` | ✅ |
|
||||
|
||||
### Adapters — FfmpegCommandBuilder
|
||||
|
||||
| Teste | Resultado |
|
||||
| -------------------------------------------------------- | --------- |
|
||||
| `sempre_contem_c_copy` — invariante RNF-01 | ✅ |
|
||||
| `output_e_o_ultimo_argumento` | ✅ |
|
||||
| `itsoffset_formato_correto` — `1200ms` → `"1.200"` | ✅ |
|
||||
| `mapa_faixa_externa_de_audio` — `map 1:a` | ✅ |
|
||||
| `metadata_idioma_audio` — `-metadata:s:a:0 language=por` | ✅ |
|
||||
|
||||
### Application — ExportTrack
|
||||
|
||||
| Teste | Resultado |
|
||||
| ----------------------------------------------------------------------------------------- | --------- |
|
||||
| `export_track::exporta_audio_com_c_a_copy` — `-c:a copy` presente, sem `-c copy` genérico | ✅ |
|
||||
| `export_track::exporta_audio_mapeia_stream_correto` — `0:stream_index` correto | ✅ |
|
||||
| `export_track::exporta_legenda_sem_c_copy` — nenhuma forma de `-c` para legendas | ✅ |
|
||||
| `export_track::exporta_legenda_output_e_ultimo_argumento` | ✅ |
|
||||
| `export_track::rejeita_faixa_de_video` — retorna erro para `TrackKind::Video` | ✅ |
|
||||
|
||||
### Application — RemoveTrack
|
||||
|
||||
| Teste | Resultado |
|
||||
| ---------------------------------------------------------------------- | --------- |
|
||||
| `remove_track::remove_faixa_existente` — faixa removida do Vec | ✅ |
|
||||
| `remove_track::erro_se_id_inexistente` — retorna erro se id não existe | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Dependências (Cargo.toml)
|
||||
|
||||
```toml
|
||||
eframe = "0.27"
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "rt-multi-thread", "macros", "io-util", "sync"] }
|
||||
rfd = "0.14"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Invariantes Garantidas
|
||||
|
||||
- **`-c copy` sempre presente** — `FfmpegCommandBuilder::build()` emite incondicionalmente (RNF-01)
|
||||
- **`SyncOffset` nunca usa `f64`** — armazenado como `i64` ms; conversão para string FFmpeg feita exclusivamente no `FfmpegCommandBuilder`
|
||||
- **`TrackId` é opaco** — não expõe índice interno; mapeamento para `-map N:tipo` feito apenas no adapter
|
||||
- **`Project` rejeita `output == source`** — validado no construtor
|
||||
- **Termos FFmpeg nunca aparecem na UI** — a interface usa linguagem do usuário final
|
||||
- **Exceção documentada ao RNF-01** — `build_export()` omite `-c copy` exclusivamente para legendas (conversão de container de texto, sem processamento de mídia); comentário inline explica a exceção
|
||||
- **`title=` sempre emitido** — `FfmpegCommandBuilder` emite `-metadata:s:a/s:{idx} title=` para toda faixa externa, garantindo que títulos herdados do arquivo fonte sejam sobrescritos; string vazia limpa o campo no MKV
|
||||
|
||||
---
|
||||
|
||||
## O Que Falta (Backlog de Refinamento)
|
||||
|
||||
### Funcional
|
||||
|
||||
- [x] Exportar faixa de áudio ou legenda existente para arquivo separado
|
||||
- [x] Exibição de faixas externas adicionadas com opção de remover
|
||||
- [x] Progresso em tempo real via `run_ffmpeg_async` — `run_ffmpeg_async` usa `std::sync::mpsc::Sender<String>`; `start_generation` cria tokio Runtime + thread encaminhadora; cada linha de stderr do FFmpeg aparece no log antes do término
|
||||
- [x] Cancelamento de geração — botão "Cancelar" visível durante `Running`; sinal via `tokio::sync::oneshot`; `child.kill().await` no `tokio::select!`; estado `ExecutionState::Cancelled` exibido em amarelo
|
||||
- [x] **Fase 7 — Persistência do Estado** — novo módulo `infrastructure/persistence` com `save_session`/`load_session`/`session_exists`; JSON em `~/.config/simple-mkv-editor/session.json`; botões "💾 Salvar sessão" / "📂 Carregar sessão" no header com feedback visual (verde/vermelho + botão ✕)
|
||||
- [x] **Fase 8 — Modo Lote via Abas** — barra de abas "Projeto Único" / "🗂 Lote"; `BatchPanel` com carrinho + formulário inline; `start_batch_item` + avanço automático em `poll_background`; estado individual por item; cancelamento de item atual; carrinho bloqueado durante processamento
|
||||
|
||||
### Qualidade
|
||||
|
||||
- [x] Corrigir 14 warnings de `unused`/`dead_code` identificados por `cargo build`:
|
||||
- `next_input_idx += 1` removido do loop externo em `command_builder.rs` (assignment nunca lido)
|
||||
- Lifetime explícito `Cow<'_, str>` adicionado em `FilePath::to_string_lossy`
|
||||
- `RealFileSystem`, `FileSystemPort`, `AdjustSync`, `GenerateOutput`, `SetTrackLanguage` suprimidos com `#[allow(dead_code)]` — APIs arquiteturais sem chamador na UI atual
|
||||
- Métodos `find_track_mut`, `language`, `set_offset`, `set_language`, `from_ms` suprimidos com `#[allow(dead_code)]`
|
||||
- [ ] Testes de integração com FFmpeg real (requer ffmpeg instalado no CI)
|
||||
- [ ] Testes de snapshot para o `FfmpegCommandBuilder` com projetos mais complexos
|
||||
|
||||
### UI
|
||||
|
||||
- [x] Layout responsivo mais refinado
|
||||
- [ ] Exibição do comando FFmpeg gerado em modo debug (ex: painel colapsável na `ExecutionPanel`)
|
||||
- [ ] Persistência do estado do projeto em disco (`serde` já disponível)
|
||||
- [ ] Mensagens de erro mais amigáveis na UI
|
||||
|
||||
---
|
||||
|
||||
## Como Executar
|
||||
|
||||
```bash
|
||||
# Verificar compilação
|
||||
cargo check
|
||||
|
||||
# Rodar testes
|
||||
cargo test
|
||||
|
||||
# Executar a aplicação (requer ffmpeg e ffprobe no PATH)
|
||||
cargo run
|
||||
```
|
||||
@@ -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)
|
||||
@@ -69,6 +69,20 @@ impl Project {
|
||||
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 {
|
||||
@@ -195,4 +209,54 @@ mod tests {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -245,6 +245,19 @@ impl App {
|
||||
} else {
|
||||
FfmpegCommandBuilder::build(&project)
|
||||
};
|
||||
|
||||
// Formatar comando legível para o painel de debug
|
||||
let binary = if uses_mkvmerge { "mkvmerge" } else { "ffmpeg" };
|
||||
let cmd_str = format!(
|
||||
"{} {}",
|
||||
binary,
|
||||
args.iter()
|
||||
.map(|a| if a.contains(' ') { format!("\"{}\"", a) } else { a.clone() })
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
);
|
||||
self.execution_panel.last_command = Some(cmd_str);
|
||||
|
||||
let (tx, rx) = mpsc::channel::<BackgroundMsg>();
|
||||
self.bg_rx = Some(rx);
|
||||
self.execution_panel.state = ExecutionState::Running;
|
||||
@@ -311,6 +324,19 @@ impl App {
|
||||
} 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);
|
||||
|
||||
@@ -779,13 +805,19 @@ impl eframe::App for App {
|
||||
ui.add_space(8.0);
|
||||
|
||||
// ── Faixas externas adicionadas ───────────────────────
|
||||
let remove_events = self.added_track_list.ui(ui, &project.tracks);
|
||||
for event in remove_events {
|
||||
let track_events = self.added_track_list.ui(ui, &project.tracks);
|
||||
for event in track_events {
|
||||
use crate::ui::components::added_track_list::AddedTrackEvent;
|
||||
match event {
|
||||
AddedTrackEvent::RemoveRequested(id) => {
|
||||
let _ = RemoveTrack::execute(project, id);
|
||||
}
|
||||
AddedTrackEvent::MoveUp(id) => {
|
||||
project.move_track(id, -1);
|
||||
}
|
||||
AddedTrackEvent::MoveDown(id) => {
|
||||
project.move_track(id, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ use eframe::egui;
|
||||
/// Evento emitido por interações com a lista de faixas externas adicionadas.
|
||||
pub enum AddedTrackEvent {
|
||||
RemoveRequested(TrackId),
|
||||
MoveUp(TrackId),
|
||||
MoveDown(TrackId),
|
||||
}
|
||||
|
||||
/// Lista as faixas externas adicionadas pelo usuário e permite removê-las.
|
||||
@@ -15,9 +17,10 @@ impl AddedTrackList {
|
||||
AddedTrackList
|
||||
}
|
||||
|
||||
/// Renderiza a lista. Retorna eventos de remoção.
|
||||
/// Renderiza a lista. Retorna eventos de interação (remoção e reordenação).
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> {
|
||||
let mut events = Vec::new();
|
||||
let total = tracks.len();
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.heading("Faixas adicionadas");
|
||||
@@ -28,17 +31,18 @@ impl AddedTrackList {
|
||||
}
|
||||
|
||||
egui::Grid::new("added_tracks_grid")
|
||||
.num_columns(4)
|
||||
.num_columns(5)
|
||||
.max_col_width(200.0)
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("Tipo");
|
||||
ui.strong("Arquivo");
|
||||
ui.strong("Idioma");
|
||||
ui.strong("Ordem");
|
||||
ui.strong("");
|
||||
ui.end_row();
|
||||
|
||||
for track in tracks {
|
||||
for (idx, track) in tracks.iter().enumerate() {
|
||||
let (kind_label, file_name, language) = match track {
|
||||
Track::Audio(t) => (
|
||||
"Áudio",
|
||||
@@ -67,6 +71,28 @@ impl AddedTrackList {
|
||||
.on_hover_text(track_full_path(track));
|
||||
ui.label(language);
|
||||
|
||||
// ── Botões de reordenação ──────────────────────────
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(idx > 0, |ui| {
|
||||
if ui
|
||||
.small_button("↑")
|
||||
.on_hover_text("Mover para cima")
|
||||
.clicked()
|
||||
{
|
||||
events.push(AddedTrackEvent::MoveUp(track.id()));
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(idx + 1 < total, |ui| {
|
||||
if ui
|
||||
.small_button("↓")
|
||||
.on_hover_text("Mover para baixo")
|
||||
.clicked()
|
||||
{
|
||||
events.push(AddedTrackEvent::MoveDown(track.id()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if ui.small_button("🗑 Remover").clicked() {
|
||||
events.push(AddedTrackEvent::RemoveRequested(track.id()));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct BatchItem {
|
||||
pub project: Project,
|
||||
pub state: ExecutionState,
|
||||
pub log_lines: Vec<String>,
|
||||
/// Comando completo enviado ao FFmpeg/mkvmerge na última geração deste item.
|
||||
pub last_command: Option<String>,
|
||||
}
|
||||
|
||||
impl BatchItem {
|
||||
@@ -25,6 +27,7 @@ impl BatchItem {
|
||||
project,
|
||||
state: ExecutionState::Idle,
|
||||
log_lines: Vec::new(),
|
||||
last_command: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +257,20 @@ impl BatchPanel {
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some(cmd) = &item.last_command {
|
||||
ui.collapsing("🛠 Comando gerado", |ui| {
|
||||
let mut cmd_str = cmd.as_str();
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut cmd_str)
|
||||
.desired_rows(3)
|
||||
.desired_width(f32::INFINITY)
|
||||
.font(egui::TextStyle::Monospace),
|
||||
);
|
||||
if ui.small_button("📋 Copiar").clicked() {
|
||||
ui.output_mut(|o| o.copied_text = cmd.clone());
|
||||
}
|
||||
});
|
||||
} });
|
||||
}); // push_id
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ pub enum ExecutionState {
|
||||
pub struct ExecutionPanel {
|
||||
pub state: ExecutionState,
|
||||
pub log_lines: Vec<String>,
|
||||
/// Comando completo enviado ao FFmpeg/mkvmerge na última geração.
|
||||
/// Exemplo: "ffmpeg -i input.mkv -c copy ... output.mkv"
|
||||
pub last_command: Option<String>,
|
||||
}
|
||||
|
||||
impl ExecutionPanel {
|
||||
@@ -22,6 +25,7 @@ impl ExecutionPanel {
|
||||
ExecutionPanel {
|
||||
state: ExecutionState::Idle,
|
||||
log_lines: Vec::new(),
|
||||
last_command: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +107,22 @@ impl ExecutionPanel {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Painel de debug: comando gerado
|
||||
if let Some(cmd) = &self.last_command {
|
||||
ui.collapsing("🛠 Comando gerado", |ui| {
|
||||
let mut cmd_str = cmd.as_str();
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut cmd_str)
|
||||
.desired_rows(3)
|
||||
.desired_width(f32::INFINITY)
|
||||
.font(egui::TextStyle::Monospace),
|
||||
);
|
||||
if ui.small_button("📋 Copiar").clicked() {
|
||||
ui.output_mut(|o| o.copied_text = cmd.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
(generate_clicked, cancel_clicked)
|
||||
|
||||
Reference in New Issue
Block a user