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

16 KiB
Raw Blame History

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_correto1200ms"1.200"
mapa_faixa_externa_de_audiomap 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_correto0: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 copy sempre presenteFfmpegCommandBuilder::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-01build_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 emitidoFfmpegCommandBuilder 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

  • 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_asyncrun_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
  • 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
  • Fase 7 — Persistência do Estado — novo módulo infrastructure/persistence com save_session/load_session/session_exists; JSON em ~/.config/simple-mkv-editor/session.json; botões "💾 Salvar sessão" / "📂 Carregar sessão" no header com feedback visual (verde/vermelho + botão ✕)
  • Fase 8 — Modo Lote via Abas — 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

  • 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

  • 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

# Verificar compilação
cargo check

# Rodar testes
cargo test

# Executar a aplicação (requer ffmpeg e ffprobe no PATH)
cargo run