Compare commits

..
2 Commits
4 changed files with 128 additions and 17 deletions
+85
View File
@@ -0,0 +1,85 @@
# Copilot Instructions — Simple Multimedia Track Audio Editor
## Visão do Produto
Editor gráfico (egui/eframe) que combina vídeo, áudio e legendas em um único arquivo MKV **sem reencoding** — apenas mux e ajuste de timestamps via FFmpeg. Pense no MKV como um banco de dados: as operações são `INSERT` de faixas e `UPDATE` de timestamps; nenhum byte de mídia é reprocessado.
## Arquitetura — Clean Architecture (de dentro para fora)
```
domain/ ← núcleo puro; zero I/O, zero frameworks
application/ ← casos de uso + ports (traits); testável com mocks
adapters/ ← implementações concretas dos ports (FFmpeg, filesystem)
infrastructure/ ← execução assíncrona de processo real (tokio)
ui/ ← egui/eframe; única camada que lida com estado visual
```
- **`Project`** (`src/domain/entities/project.rs`) é a **única fonte de verdade** do estado em memória. Toda mutação passa por métodos do `Project` ou pelos casos de uso.
- **`App`** (`src/ui/app.rs`) detém um `Option<Project>` e orquestra os componentes de UI.
- Nenhuma camada interna (`domain`, `application`) deve importar `eframe`, `tokio`, ou qualquer crate de I/O.
## Regras de Negócio Críticas
1. **`-c copy` é invariante** — `FfmpegCommandBuilder` sempre emite `-c copy`. Nunca remova nem torne opcional.
2. **`SyncOffset` é sempre `i64` em milissegundos** — nunca use `f64` para representar offset internamente. A conversão para segundos ocorre **apenas** em `FfmpegCommandBuilder::build()` e na exibição (`Display`).
3. **`output.path != source.path`** — `Project::new()` retorna `Err` se forem iguais.
4. Faixas externas adicionadas pelo usuário ficam em `project.tracks: Vec<Track>`; faixas detectadas via ffprobe ficam em `project.existing_tracks: Vec<MediaTrackInfo>` — nunca misture os dois vetores.
## Convenções de Código
- **Newtypes** para todos os value objects: `FilePath(PathBuf)`, `TrackId(u32)`, `SyncOffset(i64)`, `TrackLanguage(String)`. Não use os tipos primitivos diretamente nos casos de uso ou entidades.
- **Ports** são traits em `src/application/ports/mod.rs`. Toda comunicação cross-layer usa `Result<T>` de `anyhow`.
- **Casos de uso** em `src/application/use_cases/` recebem ports por referência (`&impl Port`) e o `Project` por `&mut` — nunca acessam FFmpeg diretamente.
- **Componentes de UI** (`src/ui/components/`) têm estado próprio (formulários, campos) mas **não detêm o `Project`** — recebem referências ou emitem valores de volta para `App`.
## Comunicação Assíncrona (UI ↔ Background Thread)
A geração do MKV roda em thread separada (tokio). O padrão é:
```
UI → spawn(tokio) → run_ffmpeg_async(args, progress_tx, cancel_rx)
↓ BackgroundMsg (LogLine | Done | Error)
bg_rx (mpsc::Receiver) lido no loop update() do egui
```
- `App::bg_rx` recebe mensagens; `App::cancel_tx` é um `oneshot::Sender` para cancelamento.
- O loop `update()` do egui drena `bg_rx` a cada frame para atualizar `ExecutionPanel`.
## Fluxo de Dados Principal
```
Usuário seleciona vídeo
→ FilePickerAdapter → FilePath
→ LoadMediaInfo (use case) → FfprobeGateway → Vec<MediaTrackInfo>
→ App::try_build_project() → Project
Usuário adiciona faixa
→ AddAudioTrack / AddSubtitle (use case) → Project.tracks.push(Track)
Usuário clica "Gerar MKV"
→ FfmpegCommandBuilder::build(&project) → Vec<String>
→ run_ffmpeg_async(args, tx, cancel_rx) (infrastructure)
→ FfmpegGateway::execute() (adapter, modo síncrono — apenas em testes/uso direto)
```
## Comandos de Desenvolvimento
```bash
cargo check # verificar compilação sem produzir binário
cargo test # rodar todos os testes (33 testes, todos devem passar)
cargo run # executar a aplicação (requer ffmpeg e ffprobe no PATH)
```
Dependências externas necessárias em runtime: `ffmpeg` e `ffprobe` disponíveis no `PATH`. A inicialização do `App` chama `validate_dependencies()` e exibe erro global se ausentes.
## Arquivos-Chave
| Arquivo | Papel |
| ----------------------------------------- | ----------------------------------------------------------------------------------- |
| `src/domain/entities/project.rs` | Entidade raiz; todas as mutações de estado |
| `src/domain/value_objects/sync_offset.rs` | Conversão ms ↔ segundos; testes unitários de parsing |
| `src/adapters/ffmpeg/command_builder.rs` | Tradução `Project` → argumentos FFmpeg; única responsável por `-itsoffset` e `-map` |
| `src/adapters/ffmpeg/ffprobe_gateway.rs` | Parser JSON do ffprobe → `Vec<MediaTrackInfo>` |
| `src/infrastructure/process/mod.rs` | `run_ffmpeg_async` com streaming de stderr e cancelamento |
| `src/ui/app.rs` | Orquestrador da UI; gerencia `Project`, canal de background e componentes |
| `src/application/ports/mod.rs` | Traits `MediaInfoPort`, `MediaProcessorPort`, `FileSystemPort` |
+3 -1
View File
@@ -29,6 +29,7 @@ impl AddedTrackList {
egui::Grid::new("added_tracks_grid")
.num_columns(4)
.max_col_width(200.0)
.striped(true)
.show(ui, |ui| {
ui.strong("Tipo");
@@ -62,7 +63,8 @@ impl AddedTrackList {
};
ui.label(kind_label);
ui.label(&file_name).on_hover_text(track_full_path(track));
ui.add(egui::Label::new(&file_name).truncate(true))
.on_hover_text(track_full_path(track));
ui.label(language);
if ui.small_button("🗑 Remover").clicked() {
+20 -8
View File
@@ -22,14 +22,6 @@ impl OutputSelector {
ui.group(|ui| {
ui.heading("Arquivo de saída (.mkv)");
ui.horizontal(|ui| {
let label = self
.selected_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "Nenhum destino selecionado".to_string());
ui.label(&label);
if ui.button("Salvar como…").clicked() {
let stem = source_stem.unwrap_or("output");
if let Some(path) = FilePickerAdapter::pick_output(stem) {
@@ -37,6 +29,26 @@ impl OutputSelector {
selected = Some(path);
}
}
let full_path = self
.selected_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let display = self
.selected_path
.as_ref()
.map(|p| {
p.as_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string()
})
.unwrap_or_else(|| "Nenhum destino selecionado".to_string());
ui.add(egui::Label::new(&display).truncate(true))
.on_hover_text(if full_path.is_empty() { display } else { full_path });
});
});
+20 -8
View File
@@ -20,20 +20,32 @@ impl VideoSelector {
ui.group(|ui| {
ui.heading("Arquivo de vídeo");
ui.horizontal(|ui| {
let label = self
.selected_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "Nenhum arquivo selecionado".to_string());
ui.label(&label);
if ui.button("Selecionar…").clicked() {
if let Some(path) = FilePickerAdapter::pick_video() {
self.selected_path = Some(path.clone());
selected = Some(path);
}
}
let full_path = self
.selected_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let display = self
.selected_path
.as_ref()
.map(|p| {
p.as_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string()
})
.unwrap_or_else(|| "Nenhum arquivo selecionado".to_string());
ui.add(egui::Label::new(&display).truncate(true))
.on_hover_text(if full_path.is_empty() { display } else { full_path });
});
});