From e347202e9503f323c146ec1e624d4e23e40b479a Mon Sep 17 00:00:00 2001 From: Felipe Canin Novaes Date: Sun, 1 Mar 2026 22:10:13 -0300 Subject: [PATCH] =?UTF-8?q?Atualiza=20PRD=20para=20vers=C3=A3o=201.4,=20ad?= =?UTF-8?q?iciona=20novos=20requisitos=20funcionais=20(RF-12=20a=20RF-15)?= =?UTF-8?q?=20e=20remove=20o=20arquivo=20PROGRESS.md.=20Implementa=20paine?= =?UTF-8?q?l=20de=20debug=20para=20exibir=20o=20comando=20gerado=20pelo=20?= =?UTF-8?q?FFmpeg/mkvmerge=20na=20ExecutionPanel,=20permitindo=20c=C3=B3pi?= =?UTF-8?q?a=20e=20inspe=C3=A7=C3=A3o=20do=20comando.=20Melhora=20a=20inte?= =?UTF-8?q?rface=20e=20a=20persist=C3=AAncia=20de=20estado=20do=20projeto.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT_PLAN.md | 732 ---------------------------------- PRD.md | 176 +++++--- PROGRESS.md | 281 ------------- docs/impl-dt10-debug-panel.md | 162 ++++++++ 4 files changed, 285 insertions(+), 1066 deletions(-) delete mode 100644 DEVELOPMENT_PLAN.md delete mode 100644 PROGRESS.md create mode 100644 docs/impl-dt10-debug-panel.md diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md deleted file mode 100644 index 182b991..0000000 --- a/DEVELOPMENT_PLAN.md +++ /dev/null @@ -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` | -| `MkvOutput` | `path: FilePath` | -| `Project` | Entidade raiz — ver abaixo | - -#### Estrutura de `Project` - -```rust -pub struct Project { - pub source: VideoFile, - pub tracks: Vec, // faixas externas adicionadas pelo usuário - pub existing_tracks: Vec, // 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>; -} - -// MediaProcessorPort: executa o processamento final -pub trait MediaProcessorPort { - fn execute(&self, args: Vec) -> 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` 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` -- [x] Implementar `FfmpegCommandBuilder::build_export(source, track, output) -> Vec` (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 -``` - -e mapeia a saída JSON para `Vec`. - -- [x] Implementar parsing de JSON via `serde_json` -- [x] Mapear `codec_type` para `TrackKind` -- [x] Mapear `tags.language` para `Option` - -#### `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` (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` 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>` -- [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` | -| `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` | -| `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` 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) -> 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` - -Lógica detalhada: - -1. **Output** (vem primeiro em mkvmerge): - - ``` - -o - ``` - -2. **Tracks existentes com `--sync`** (aplica a cada faixa não-vídeo): - - ``` - --sync :,/ - ``` - - - `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 : - ``` - - - Somente se `language.is_some()` - -4. **Arquivo fonte:** - - ``` - - ``` - -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 :,/ (se offset≠0 ou scale≠1.0) - --language : - --track-name : - --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) diff --git a/PRD.md b/PRD.md index 305b804..bef8dbe 100644 --- a/PRD.md +++ b/PRD.md @@ -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.4 +**Data:** 01/03/2026 +**Status:** v1.0 funcional — Fases 1–9 concluídas; 33 testes passando; aplicação executável +**Revisão:** v1.4 — Adicionado RF-15 (Excluir faixa existente do output) --- @@ -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,46 @@ 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) --- @@ -159,9 +196,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 @@ -183,15 +222,17 @@ 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 | -| Container | MKV | Melhor suporte a múltiplas faixas | +| 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` + `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 +240,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,31 +279,35 @@ 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. > > ``` > Project > ├── source: VideoFile -> ├── tracks: Vec<Track> // faixas externas adicionadas +> ├── tracks: Vec<Track> // faixas externas adicionadas > ├── existing_tracks: Vec<MediaTrackInfo> // faixas lidas do arquivo via ffprobe > └── 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) 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` | +| Tipo | Exemplos | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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`. > @@ -278,15 +325,20 @@ 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` | +| Tipo | Exemplos | +| ------------ | ------------------------------------------------------------------------- | +| Gateway | `FfmpegCommandBuilder`, `FfmpegGateway`, `FfprobeGateway` | +| 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 +358,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 +382,18 @@ src/ ## 10. Débitos Técnicos -| ID | Descrição | Impacto | Mitigação | -| ----- | ----------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------ | -| 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 | +| 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 | 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 | Planejado — painel colapsável com o comando gerado na `ExecutionPanel` (modo debug) | --- @@ -341,9 +401,11 @@ 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) +- Painel colapsável com o comando FFmpeg/mkvmerge gerado (modo debug, DT-10) +- 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 +418,19 @@ 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] 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) --- diff --git a/PROGRESS.md b/PROGRESS.md deleted file mode 100644 index c4a25b7..0000000 --- a/PROGRESS.md +++ /dev/null @@ -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 -``` diff --git a/docs/impl-dt10-debug-panel.md b/docs/impl-dt10-debug-panel.md new file mode 100644 index 0000000..974e89a --- /dev/null +++ b/docs/impl-dt10-debug-panel.md @@ -0,0 +1,162 @@ +# Implementação — DT-10: Painel de Debug do Comando Gerado + +**Data:** 01/03/2026 +**Status:** Planejado +**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 + +- [ ] O painel "🛠 Comando gerado" aparece na `ExecutionPanel` após a primeira geração +- [ ] O painel está colapsado por padrão +- [ ] O conteúdo exibe o binário (`ffmpeg` ou `mkvmerge`) seguido de todos os argumentos +- [ ] Argumentos com espaços internos são envolvidos em aspas duplas +- [ ] O botão "📋 Copiar" coloca o texto no clipboard +- [ ] O comando é exibido também para gerações canceladas +- [ ] O painel **não** aparece antes da primeira geração (estado `Idle` inicial) +- [ ] Modo Lote exibe o comando do último item processado +- [ ] `cargo test` — 33 testes passando sem regressão