16 KiB
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::mpscem 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");
BatchPanelcom carrinho de projetos e formulário inline de adição; processamento sequencial automático viastart_batch_item+ avanço empoll_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)
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 copysempre presente —FfmpegCommandBuilder::build()emite incondicionalmente (RNF-01)SyncOffsetnunca usaf64— armazenado comoi64ms; conversão para string FFmpeg feita exclusivamente noFfmpegCommandBuilderTrackIdé opaco — não expõe índice interno; mapeamento para-map N:tipofeito apenas no adapterProjectrejeitaoutput == 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 copyexclusivamente para legendas (conversão de container de texto, sem processamento de mídia); comentário inline explica a exceção title=sempre emitido —FfmpegCommandBuilderemite-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
- Exportar faixa de áudio ou legenda existente para arquivo separado
- Exibição de faixas externas adicionadas com opção de remover
- Progresso em tempo real via
run_ffmpeg_async—run_ffmpeg_asyncusastd::sync::mpsc::Sender<String>;start_generationcria tokio Runtime + thread encaminhadora; cada linha de stderr do FFmpeg aparece no log antes do término - Cancelamento de geração — botão "Cancelar" visível durante
Running; sinal viatokio::sync::oneshot;child.kill().awaitnotokio::select!; estadoExecutionState::Cancelledexibido em amarelo - Fase 7 — Persistência do Estado — novo módulo
infrastructure/persistencecomsave_session/load_session/session_exists; JSON em~/.config/simple-mkv-editor/session.json; botões "💾 Salvar sessão" / "📂 Carregar sessão" no header com feedback visual (verde/vermelho + botão ✕) - Fase 8 — Modo Lote via Abas — barra de abas "Projeto Único" / "🗂 Lote";
BatchPanelcom carrinho + formulário inline;start_batch_item+ avanço automático empoll_background; estado individual por item; cancelamento de item atual; carrinho bloqueado durante processamento
Qualidade
- Corrigir 14 warnings de
unused/dead_codeidentificados porcargo build:next_input_idx += 1removido do loop externo emcommand_builder.rs(assignment nunca lido)- Lifetime explícito
Cow<'_, str>adicionado emFilePath::to_string_lossy RealFileSystem,FileSystemPort,AdjustSync,GenerateOutput,SetTrackLanguagesuprimidos com#[allow(dead_code)]— APIs arquiteturais sem chamador na UI atual- Métodos
find_track_mut,language,set_offset,set_language,from_mssuprimidos com#[allow(dead_code)]
- Testes de integração com FFmpeg real (requer ffmpeg instalado no CI)
- Testes de snapshot para o
FfmpegCommandBuildercom projetos mais complexos
UI
- 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 (
serdejá disponível) - Mensagens de erro mais amigáveis na UI
Como Executar
# Verificar compilação
cargo check
# Rodar testes
cargo test
# Executar a aplicação (requer ffmpeg e ffprobe no PATH)
cargo run