Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16dcb0b9d4 | ||
|
|
7c7f86b131 |
@@ -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` |
|
||||||
@@ -29,6 +29,7 @@ impl AddedTrackList {
|
|||||||
|
|
||||||
egui::Grid::new("added_tracks_grid")
|
egui::Grid::new("added_tracks_grid")
|
||||||
.num_columns(4)
|
.num_columns(4)
|
||||||
|
.max_col_width(200.0)
|
||||||
.striped(true)
|
.striped(true)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.strong("Tipo");
|
ui.strong("Tipo");
|
||||||
@@ -62,7 +63,8 @@ impl AddedTrackList {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ui.label(kind_label);
|
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);
|
ui.label(language);
|
||||||
|
|
||||||
if ui.small_button("🗑 Remover").clicked() {
|
if ui.small_button("🗑 Remover").clicked() {
|
||||||
|
|||||||
@@ -22,14 +22,6 @@ impl OutputSelector {
|
|||||||
ui.group(|ui| {
|
ui.group(|ui| {
|
||||||
ui.heading("Arquivo de saída (.mkv)");
|
ui.heading("Arquivo de saída (.mkv)");
|
||||||
ui.horizontal(|ui| {
|
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() {
|
if ui.button("Salvar como…").clicked() {
|
||||||
let stem = source_stem.unwrap_or("output");
|
let stem = source_stem.unwrap_or("output");
|
||||||
if let Some(path) = FilePickerAdapter::pick_output(stem) {
|
if let Some(path) = FilePickerAdapter::pick_output(stem) {
|
||||||
@@ -37,6 +29,26 @@ impl OutputSelector {
|
|||||||
selected = Some(path);
|
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,20 +20,32 @@ impl VideoSelector {
|
|||||||
ui.group(|ui| {
|
ui.group(|ui| {
|
||||||
ui.heading("Arquivo de vídeo");
|
ui.heading("Arquivo de vídeo");
|
||||||
ui.horizontal(|ui| {
|
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 ui.button("Selecionar…").clicked() {
|
||||||
if let Some(path) = FilePickerAdapter::pick_video() {
|
if let Some(path) = FilePickerAdapter::pick_video() {
|
||||||
self.selected_path = Some(path.clone());
|
self.selected_path = Some(path.clone());
|
||||||
selected = Some(path);
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user