Files
simple-multimidia-track-aud…/PROGRESS.md
T

282 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Progresso de Implementação
**Data:** 28/02/2026
**Status:** Fases 18 concluídas — compilando, 33 testes passando, zero warnings do projeto, aplicação executável
**Referência:** DEVELOPMENT_PLAN.md v1.3
---
## Resumo Executivo
Todas as 6 fases do plano de desenvolvimento foram implementadas. O projeto compila sem erros,
33 testes unitários passam e a aplicação pode ser executada com `cargo run`.
Todos os 14 warnings de `unused`/`dead_code` foram resolvidos: assignment morto em `command_builder.rs` removido, lifetime explícito em `FilePath::to_string_lossy` corrigido, e APIs arquiteturais não chamadas pela UI suprimidas com `#[allow(dead_code)]`.
Após a conclusão das fases, foram implementadas funcionalidades adicionais:
- Exportação de faixa de áudio ou legenda diretamente da lista de faixas existentes
- Exibição das faixas externas adicionadas com opção de remoção individual
- Layout responsivo com painéis fixos (cabeçalho, rodapé, barra lateral) e área central com scroll
- Progresso em tempo real via `run_ffmpeg_async` (Opção A: `std::sync::mpsc` em toda a cadeia)
- Cancelamento de geração em andamento via botão "Cancelar" — encerra o processo FFmpeg filho imediatamente
- **Fase 8 — Modo Lote via Abas** — barra de abas ("Projeto Único" / "🗂 Lote"); `BatchPanel` com carrinho de projetos e formulário inline de adição; processamento sequencial automático via `start_batch_item` + avanço em `poll_background`; estado individual por item (`⏳ Aguardando | ⟳ Processando | ✓ Concluído | ⊸ Cancelado | ✗ Erro`); cancelamento do item atual com botão "⏹ Cancelar item atual"; carrinho bloqueado durante processamento
---
## Status por Fase
| Fase | Descrição | Status |
| ---- | ------------------------------- | ------------ |
| 1 | Setup do projeto | ✅ Concluída |
| 2 | Domain (núcleo puro) | ✅ Concluída |
| 3 | Application (use cases + ports) | ✅ Concluída |
| 4 | Adapters (FFmpeg + filesystem) | ✅ Concluída |
| 5 | Infrastructure (processo async) | ✅ Concluída |
| 6 | UI (eframe/egui) | ✅ Concluída |
| 7 | Persistência do estado | ✅ Concluída |
| 8 | Modo Lote via Abas | ✅ Concluída |
---
## Estrutura de Arquivos Criados
```
src/
├── main.rs — entrada, configuração da janela eframe
├── domain/
│ ├── mod.rs
│ ├── value_objects/
│ │ ├── mod.rs
│ │ ├── file_path.rs — FilePath (newtype sobre PathBuf)
│ │ ├── track_id.rs — TrackId (newtype opaco sobre u32)
│ │ ├── sync_offset.rs — SyncOffset (i64 ms, nunca f64)
│ │ └── track_language.rs — TrackLanguage (ISO 639-2, validado)
│ └── entities/
│ ├── mod.rs
│ ├── project.rs — Project (entidade raiz / fonte de verdade)
│ ├── video_file.rs — VideoFile
│ ├── audio_track.rs — AudioTrack
│ ├── subtitle_track.rs — SubtitleTrack
│ ├── media_track_info.rs — MediaTrackInfo + TrackKind
│ ├── mkv_output.rs — MkvOutput
│ └── track.rs — Track (enum: Audio | Subtitle)
├── application/
│ ├── mod.rs
│ ├── ports/
│ │ └── mod.rs — MediaInfoPort, MediaProcessorPort, FileSystemPort
│ └── use_cases/
│ ├── mod.rs
│ ├── load_media_info.rs — popula Project::existing_tracks via ffprobe
│ ├── add_audio_track.rs — adiciona AudioTrack ao Project
│ ├── add_subtitle.rs — adiciona SubtitleTrack ao Project
│ ├── adjust_sync.rs — altera SyncOffset de faixa externa
│ ├── edit_existing_track_sync.rs — altera SyncOffset de faixa existente
│ ├── export_track.rs — exporta faixa de áudio ou legenda para arquivo
│ ├── remove_track.rs — remove faixa externa do projeto pelo TrackId
│ ├── set_track_language.rs — altera idioma de faixa externa
│ └── generate_output.rs — constrói comando e delega ao MediaProcessorPort
├── adapters/
│ ├── mod.rs
│ ├── ffmpeg/
│ │ ├── mod.rs
│ │ ├── command_builder.rs — FfmpegCommandBuilder: Project → Vec<String>; build_export()
│ │ ├── ffprobe_gateway.rs — FfprobeGateway impl MediaInfoPort
│ │ └── ffmpeg_gateway.rs — FfmpegGateway impl MediaProcessorPort
│ └── filesystem/
│ ├── mod.rs — RealFileSystem impl FileSystemPort
│ └── file_picker.rs — FilePickerAdapter; save_audio(codec), save_subtitle(codec)
├── infrastructure/
│ ├── mod.rs
│ └── process/
└── mod.rs — run_ffmpeg_async (tokio, cancel via oneshot), validate_dependencies()
└── ui/
├── mod.rs
├── app.rs — eframe::App; Project como única fonte de verdade; cancel_tx para interromper FFmpeg
└── components/
├── mod.rs
├── video_selector.rs — seleção do vídeo base
├── output_selector.rs — seleção do arquivo de saída (.mkv)
├── existing_track_list.rs — lista faixas detectadas + edição de offset│ ├── added_track_list.rs — lista faixas externas adicionadas + botão remover ├── add_audio_track_form.rs — formulário: áudio externo
├── add_subtitle_form.rs — formulário: legenda externa
├── sync_offset_field.rs — campo de atraso em segundos
├── language_field.rs — campo de idioma ISO 639-2
├── execution_panel.rs — botão gerar, botão cancelar, progresso, estados: Idle/Running/Success/Cancelled/Error
└── batch_panel.rs — BatchItem, BatchPanel; carrinho de lote + formulário inline; estado por item
```
---
## Inventário de Mudanças — Compatibilidade com Jellyfin (01/03/2026)
### Contexto
Arquivos M4A produzidos pelo Google (ex: downloads do YouTube Music ou Google Drive) carregam
um metadado `title` na stream de áudio com o valor `"ISO Media file produced by Google Inc."`. Quando
o FFmpeg faz o mux sem sobrescrever esse metadado, ele é copiado literalmente para a faixa do MKV.
O Jellyfin **web** é tolerante e usa a tag `language` para selecionar a faixa preferida. Já o
Jellyfin **mobile** usa o campo `title` da stream para exibir e selecionar faixas — quando não
reconhece o título como idioma, não realiza seleção automática e mantém a faixa original.
Algo semelhante ocorre com `disposition:default`: a faixa original do fonte fica com `default=1`;
a nova faixa fica com `default=0`; o Jellyfin mobile respeitam esse flag e tocam a original.
> **Status:** mudanças implementadas e compilando; investigação em andamento — o problema no
> Jellyfin mobile ainda não foi completamente resolvido.
### Mudanças por camada
| Arquivo | Mudança |
| --- | --- |
| `domain/entities/audio_track.rs` | Adicionado `is_default: bool` e `title: String` a `AudioTrack` |
| `domain/entities/subtitle_track.rs` | Adicionado `is_default: bool` e `title: String` a `SubtitleTrack` |
| `application/use_cases/add_audio_track.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
| `application/use_cases/add_subtitle.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
| `adapters/ffmpeg/command_builder.rs` | Emite `-disposition:a/s:{idx} 0` nas faixas existentes quando uma externa é marcada como padrão; emite `-disposition:a/s:{idx} default` na faixa marcada; emite `-metadata:s:a/s:{idx} title={valor}` para toda faixa externa (string vazia apaga o título herdado) |
| `ui/components/add_audio_track_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
| `ui/components/add_subtitle_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
| `ui/components/batch_panel.rs` | `PendingAudio` e `PendingSubtitle` propagam `is_default` e `title` |
---
## Testes Unitários (33/33 passando)
### Domain — Value Objects
| Teste | Resultado |
| ------------------------------------------------------------------------ | --------- |
| `sync_offset::from_seconds_str_positivo``"1.2"``SyncOffset(1200)` | ✅ |
| `sync_offset::from_seconds_str_negativo``"-0.5"``SyncOffset(-500)` | ✅ |
| `sync_offset::from_seconds_str_com_sufixo_s` | ✅ |
| `sync_offset::from_seconds_str_invalido` | ✅ |
| `sync_offset::from_seconds_str_zero` | ✅ |
| `track_language::idioma_valido``"por"`, `"eng"` | ✅ |
| `track_language::idioma_invalido_curto``"pt"` | ✅ |
| `track_language::idioma_invalido_maiusculo``"POR"` | ✅ |
| `track_language::idioma_invalido_longo``"port"` | ✅ |
### Domain — Entities
| Teste | Resultado |
| ------------------------------------------------------------------------- | --------- |
| `project::projeto_valido` | ✅ |
| `project::projeto_invalido_mesmo_caminho` — output == source retorna erro | ✅ |
| `project::track_id_opaco` — TrackId não expõe indexação interna | ✅ |
### Application — Use Cases (todos com mocks, sem I/O real)
| Teste | Resultado |
| ------------------------------------------------------------------ | --------- |
| `load_media_info::carrega_faixas_no_projeto` | ✅ |
| `add_audio_track::adiciona_audio_no_projeto` | ✅ |
| `add_subtitle::adiciona_legenda_no_projeto` | ✅ |
| `adjust_sync::ajusta_offset_existente` | ✅ |
| `adjust_sync::erro_se_id_inexistente` | ✅ |
| `edit_existing_track_sync::edita_offset_de_faixa_existente` | ✅ |
| `edit_existing_track_sync::erro_se_faixa_existente_nao_encontrada` | ✅ |
| `set_track_language::altera_idioma_da_faixa` | ✅ |
| `generate_output::sempre_inclui_c_copy` | ✅ |
### Adapters — FfmpegCommandBuilder
| Teste | Resultado |
| -------------------------------------------------------- | --------- |
| `sempre_contem_c_copy` — invariante RNF-01 | ✅ |
| `output_e_o_ultimo_argumento` | ✅ |
| `itsoffset_formato_correto``1200ms``"1.200"` | ✅ |
| `mapa_faixa_externa_de_audio``map 1:a` | ✅ |
| `metadata_idioma_audio``-metadata:s:a:0 language=por` | ✅ |
### Application — ExportTrack
| Teste | Resultado |
| ----------------------------------------------------------------------------------------- | --------- |
| `export_track::exporta_audio_com_c_a_copy``-c:a copy` presente, sem `-c copy` genérico | ✅ |
| `export_track::exporta_audio_mapeia_stream_correto``0:stream_index` correto | ✅ |
| `export_track::exporta_legenda_sem_c_copy` — nenhuma forma de `-c` para legendas | ✅ |
| `export_track::exporta_legenda_output_e_ultimo_argumento` | ✅ |
| `export_track::rejeita_faixa_de_video` — retorna erro para `TrackKind::Video` | ✅ |
### Application — RemoveTrack
| Teste | Resultado |
| ---------------------------------------------------------------------- | --------- |
| `remove_track::remove_faixa_existente` — faixa removida do Vec | ✅ |
| `remove_track::erro_se_id_inexistente` — retorna erro se id não existe | ✅ |
---
## Dependências (Cargo.toml)
```toml
eframe = "0.27"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["process", "rt-multi-thread", "macros", "io-util", "sync"] }
rfd = "0.14"
```
---
## Invariantes Garantidas
- **`-c copy` sempre presente** — `FfmpegCommandBuilder::build()` emite incondicionalmente (RNF-01)
- **`SyncOffset` nunca usa `f64`** — armazenado como `i64` ms; conversão para string FFmpeg feita exclusivamente no `FfmpegCommandBuilder`
- **`TrackId` é opaco** — não expõe índice interno; mapeamento para `-map N:tipo` feito apenas no adapter
- **`Project` rejeita `output == source`** — validado no construtor
- **Termos FFmpeg nunca aparecem na UI** — a interface usa linguagem do usuário final
- **Exceção documentada ao RNF-01** — `build_export()` omite `-c copy` exclusivamente para legendas (conversão de container de texto, sem processamento de mídia); comentário inline explica a exceção
- **`title=` sempre emitido** — `FfmpegCommandBuilder` emite `-metadata:s:a/s:{idx} title=` para toda faixa externa, garantindo que títulos herdados do arquivo fonte sejam sobrescritos; string vazia limpa o campo no MKV
---
## O Que Falta (Backlog de Refinamento)
### Funcional
- [x] Exportar faixa de áudio ou legenda existente para arquivo separado
- [x] Exibição de faixas externas adicionadas com opção de remover
- [x] Progresso em tempo real via `run_ffmpeg_async``run_ffmpeg_async` usa `std::sync::mpsc::Sender<String>`; `start_generation` cria tokio Runtime + thread encaminhadora; cada linha de stderr do FFmpeg aparece no log antes do término
- [x] Cancelamento de geração — botão "Cancelar" visível durante `Running`; sinal via `tokio::sync::oneshot`; `child.kill().await` no `tokio::select!`; estado `ExecutionState::Cancelled` exibido em amarelo
- [x] **Fase 7 — Persistência do Estado** — novo módulo `infrastructure/persistence` com `save_session`/`load_session`/`session_exists`; JSON em `~/.config/simple-mkv-editor/session.json`; botões "💾 Salvar sessão" / "📂 Carregar sessão" no header com feedback visual (verde/vermelho + botão ✕)
- [x] **Fase 8 — Modo Lote via Abas** — barra de abas "Projeto Único" / "🗂 Lote"; `BatchPanel` com carrinho + formulário inline; `start_batch_item` + avanço automático em `poll_background`; estado individual por item; cancelamento de item atual; carrinho bloqueado durante processamento
### Qualidade
- [x] Corrigir 14 warnings de `unused`/`dead_code` identificados por `cargo build`:
- `next_input_idx += 1` removido do loop externo em `command_builder.rs` (assignment nunca lido)
- Lifetime explícito `Cow<'_, str>` adicionado em `FilePath::to_string_lossy`
- `RealFileSystem`, `FileSystemPort`, `AdjustSync`, `GenerateOutput`, `SetTrackLanguage` suprimidos com `#[allow(dead_code)]` — APIs arquiteturais sem chamador na UI atual
- Métodos `find_track_mut`, `language`, `set_offset`, `set_language`, `from_ms` suprimidos com `#[allow(dead_code)]`
- [ ] Testes de integração com FFmpeg real (requer ffmpeg instalado no CI)
- [ ] Testes de snapshot para o `FfmpegCommandBuilder` com projetos mais complexos
### UI
- [x] Layout responsivo mais refinado
- [ ] Exibição do comando FFmpeg gerado em modo debug (ex: painel colapsável na `ExecutionPanel`)
- [ ] Persistência do estado do projeto em disco (`serde` já disponível)
- [ ] Mensagens de erro mais amigáveis na UI
---
## Como Executar
```bash
# Verificar compilação
cargo check
# Rodar testes
cargo test
# Executar a aplicação (requer ffmpeg e ffprobe no PATH)
cargo run
```