# 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; 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`; `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 ```