From 1ef47b3a07dd8b14d127b5f48f9e12a205b49c9f Mon Sep 17 00:00:00 2001 From: Felipe Canin Novaes Date: Sun, 1 Mar 2026 12:01:39 -0300 Subject: [PATCH] feat: add AdjustExistingTrackDrift use case to adjust drift scale of existing tracks - Implemented AdjustExistingTrackDrift struct with execute method to modify drift scale of existing tracks in a project. - Added tests for adjusting drift scale, handling non-existent track IDs, and invalid scale values. - Updated MediaTrackInfo and AudioTrack entities to include drift_scale field. - Enhanced Project entity with needs_mkvmerge method to determine if mkvmerge is required based on drift scale. - Integrated drift scale adjustments into existing track list UI, allowing users to modify drift values. - Updated various use cases and UI components to support drift scale functionality, including add audio/subtitle forms and batch processing. - Implemented mkvmerge availability check and integrated it into the application workflow for conditional processing. --- DEVELOPMENT_PLAN.md | 52 ++-- src/adapters/ffmpeg/command_builder.rs | 3 + src/adapters/ffmpeg/ffprobe_gateway.rs | 78 ++++- src/adapters/mkvmerge/command_builder.rs | 281 ++++++++++++++++++ src/adapters/mkvmerge/mkvmerge_gateway.rs | 34 +++ src/adapters/mkvmerge/mod.rs | 5 + src/adapters/mod.rs | 1 + src/application/ports/mod.rs | 7 + src/application/use_cases/add_audio_track.rs | 4 +- src/application/use_cases/add_subtitle.rs | 4 +- src/application/use_cases/adjust_drift.rs | 85 ++++++ .../use_cases/adjust_existing_track_drift.rs | 77 +++++ src/application/use_cases/adjust_sync.rs | 1 + src/application/use_cases/generate_output.rs | 1 + src/application/use_cases/mod.rs | 2 + src/application/use_cases/remove_track.rs | 1 + .../use_cases/set_track_language.rs | 1 + src/domain/entities/audio_track.rs | 5 + src/domain/entities/media_track_info.rs | 7 + src/domain/entities/project.rs | 72 ++++- src/domain/entities/subtitle_track.rs | 5 + src/domain/entities/track.rs | 16 + src/domain/value_objects/mod.rs | 2 + src/domain/value_objects/sync_transform.rs | 96 ++++++ src/infrastructure/process/mod.rs | 73 +++++ src/ui/app.rs | 57 +++- src/ui/components/add_audio_track_form.rs | 8 +- src/ui/components/add_subtitle_form.rs | 8 +- src/ui/components/batch_panel.rs | 21 +- src/ui/components/existing_track_list.rs | 70 ++++- src/ui/components/sync_offset_field.rs | 66 +++- 31 files changed, 1081 insertions(+), 62 deletions(-) create mode 100644 src/adapters/mkvmerge/command_builder.rs create mode 100644 src/adapters/mkvmerge/mkvmerge_gateway.rs create mode 100644 src/adapters/mkvmerge/mod.rs create mode 100644 src/application/use_cases/adjust_drift.rs create mode 100644 src/application/use_cases/adjust_existing_track_drift.rs create mode 100644 src/domain/value_objects/sync_transform.rs diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index 911244a..182b991 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -669,47 +669,47 @@ Drift: [__100.00_%] ← novo; só visível quando mkvmerge disponível **Domain:** -- [ ] Criar `src/domain/value_objects/sync_transform.rs` com `SyncTransform` -- [ ] Atualizar `src/domain/value_objects/mod.rs` para expor `SyncTransform` -- [ ] Adicionar `drift_scale: f64` em `AudioTrack` e `SubtitleTrack` (preservando construtores atuais com `drift_scale = 1.0` como default no callsite) -- [ ] Adicionar `drift_scale: f64` em `MediaTrackInfo` -- [ ] Adicionar `drift_scale()` e `set_drift_scale()` em `Track` -- [ ] Adicionar `Project::needs_mkvmerge()` com testes unitários +- [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:** -- [ ] Criar `adjust_drift.rs` com testes -- [ ] Criar `adjust_existing_track_drift.rs` com testes -- [ ] Atualizar `add_audio_track.rs` para aceitar `drift_scale` -- [ ] Atualizar `add_subtitle.rs` para aceitar `drift_scale` -- [ ] Adicionar `ContainerMuxPort` em `ports/mod.rs` -- [ ] Atualizar `mod.rs` dos use cases +- [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:** -- [ ] Criar `src/adapters/mkvmerge/command_builder.rs` com `MkvmergeCommandBuilder::build()` e `scale_to_rational()` -- [ ] Criar testes unitários para `MkvmergeCommandBuilder` (ver seção 9.4) -- [ ] Criar `src/adapters/mkvmerge/mkvmerge_gateway.rs` implementando `ContainerMuxPort` -- [ ] Criar `src/adapters/mkvmerge/mod.rs` -- [ ] Atualizar `src/adapters/mod.rs` +- [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:** -- [ ] Adicionar `run_mkvmerge_async` em `src/infrastructure/process/mod.rs` -- [ ] Adicionar `mkvmerge_available()` em `src/infrastructure/process/mod.rs` +- [x] Adicionar `run_mkvmerge_async` em `src/infrastructure/process/mod.rs` +- [x] Adicionar `mkvmerge_available()` em `src/infrastructure/process/mod.rs` **UI:** -- [ ] Atualizar `SyncOffsetField` com campo de drift (condicionado a `mkvmerge_available`) -- [ ] Atualizar `ExistingTrackList` para expor drift e chamar `AdjustExistingTrackDrift` -- [ ] Atualizar `AddAudioTrackForm` e `AddSubtitleForm` para passar `drift_scale` ao use case -- [ ] Adicionar `mkvmerge_available: bool` em `App` -- [ ] Atualizar `App::generate_output()` com dispatch FFmpeg/mkvmerge -- [ ] Atualizar `App::start_batch_item()` com o mesmo dispatch +- [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:** -- [ ] `cargo test` — todos os testes existentes passam sem modificação +- [x] `cargo test` — todos os testes existentes passam sem modificação - [ ] Verificar manualmente com arquivo de teste que apresenta drift progressivo --- diff --git a/src/adapters/ffmpeg/command_builder.rs b/src/adapters/ffmpeg/command_builder.rs index 46afa6d..b4952d2 100644 --- a/src/adapters/ffmpeg/command_builder.rs +++ b/src/adapters/ffmpeg/command_builder.rs @@ -310,6 +310,7 @@ mod tests { TrackLanguage::new("por").unwrap(), false, String::new(), + 1.0, ) .unwrap(); @@ -329,6 +330,7 @@ mod tests { TrackLanguage::new("por").unwrap(), false, String::new(), + 1.0, ) .unwrap(); @@ -350,6 +352,7 @@ mod tests { TrackLanguage::new("por").unwrap(), false, String::new(), + 1.0, ) .unwrap(); diff --git a/src/adapters/ffmpeg/ffprobe_gateway.rs b/src/adapters/ffmpeg/ffprobe_gateway.rs index dc99c41..b6adf50 100644 --- a/src/adapters/ffmpeg/ffprobe_gateway.rs +++ b/src/adapters/ffmpeg/ffprobe_gateway.rs @@ -20,12 +20,17 @@ struct FfprobeStream { index: u32, codec_type: Option, codec_name: Option, + duration: Option, tags: Option, } #[derive(Deserialize)] struct FfprobeTags { language: Option, + /// Presente em containers MKV/MP4 quando `duration` não está no nível do stream. + /// Formato: "HH:MM:SS.NNNNNNNNN" ou "HH:MM:SS.mmm". + #[serde(rename = "DURATION")] + duration_tag: Option, } impl MediaInfoPort for FfprobeGateway { @@ -61,15 +66,78 @@ impl MediaInfoPort for FfprobeGateway { _ => return None, }; let codec = s.codec_name.unwrap_or_else(|| "unknown".to_string()); - let language = s - .tags - .and_then(|t| t.language) - .and_then(|l| TrackLanguage::new(l).ok()); + let (language, duration_from_tag) = match s.tags { + Some(t) => { + let lang = t.language.and_then(|l| TrackLanguage::new(l).ok()); + let dur = t.duration_tag.and_then(|d| parse_duration_tag(&d)); + (lang, dur) + } + None => (None, None), + }; let id = TrackId::new(s.index); - Some(MediaTrackInfo::new(id, kind, codec, language, s.index)) + let duration_ms = s + .duration + .and_then(|d| d.trim().parse::().ok()) + .map(|secs| (secs * 1000.0).round() as u64) + .or(duration_from_tag); + let mut info = MediaTrackInfo::new(id, kind, codec, language, s.index); + info.duration_ms = duration_ms; + Some(info) }) .collect(); Ok(tracks) } } + +/// Converte string no formato "HH:MM:SS.NNNNNNNNN" (tag DURATION do ffprobe) para milissegundos. +fn parse_duration_tag(s: &str) -> Option { + // Suporta "1:23:45.678901234" e "23:45.678" + let s = s.trim(); + let (time_part, frac_part) = match s.split_once('.') { + Some((t, f)) => (t, Some(f)), + None => (s, None), + }; + let parts: Vec<&str> = time_part.split(':').collect(); + let (h, m, sec) = match parts.as_slice() { + [h, m, sec] => (h.parse::().ok()?, m.parse::().ok()?, sec.parse::().ok()?), + [m, sec] => (0, m.parse::().ok()?, sec.parse::().ok()?), + _ => return None, + }; + let total_ms = (h * 3600 + m * 60 + sec) * 1000; + let frac_ms = frac_part + .and_then(|f| { + // Normaliza para 3 dígitos (ms), seja nano ou ms + let padded = format!("{:0<9}", f); // pad à direita para 9 dígitos + padded[..9].parse::().ok().map(|ns| ns / 1_000_000) + }) + .unwrap_or(0); + Some(total_ms + frac_ms) +} + +#[cfg(test)] +mod tests { + use super::parse_duration_tag; + + #[test] + fn parse_hh_mm_ss_nanoseconds() { + // Formato típico de container MKV: "01:23:45.678901234" + let ms = parse_duration_tag("01:23:45.678901234").unwrap(); + assert_eq!(ms, (1 * 3600 + 23 * 60 + 45) * 1000 + 678); + } + + #[test] + fn parse_mm_ss_milliseconds() { + assert_eq!(parse_duration_tag("23:40.000000000").unwrap(), (23 * 60 + 40) * 1000); + } + + #[test] + fn parse_sem_fracao() { + assert_eq!(parse_duration_tag("1:00:00").unwrap(), 3_600_000); + } + + #[test] + fn parse_invalido_retorna_none() { + assert!(parse_duration_tag("abc").is_none()); + } +} diff --git a/src/adapters/mkvmerge/command_builder.rs b/src/adapters/mkvmerge/command_builder.rs new file mode 100644 index 0000000..5dcb1dd --- /dev/null +++ b/src/adapters/mkvmerge/command_builder.rs @@ -0,0 +1,281 @@ +use crate::domain::entities::{Project, Track}; +use crate::domain::value_objects::SyncOffset; + +/// Constrói os argumentos de linha de comando para o `mkvmerge`. +/// +/// Responsabilidade única: converter `Project` → `Vec` para mkvmerge. +/// Usado quando `project.needs_mkvmerge() == true`. +pub struct MkvmergeCommandBuilder; + +impl MkvmergeCommandBuilder { + /// Converte um `Project` em argumentos para `mkvmerge`. + /// + /// Ordem de saída: + /// 1. `-o ` + /// 2. Opções de faixas existentes (--sync, --language) antes do arquivo fonte + /// 3. `` + /// 4. Para cada faixa externa: opções (--sync, --language, --track-name, --default-track) + arquivo + pub fn build(project: &Project) -> Vec { + let mut args = Vec::new(); + + // 1. Output (vem primeiro no mkvmerge) + args.push("-o".to_string()); + args.push(project.output.path.to_string()); + + // 2. Opções de faixas existentes (stream_index é o TID no arquivo fonte) + for track in &project.existing_tracks { + let needs_sync = track.offset.as_ms() != 0 || (track.drift_scale - 1.0).abs() > 1e-9; + if needs_sync { + let (num, den) = scale_to_rational(track.drift_scale); + args.push("--sync".to_string()); + args.push(format!("{}:{},{}/{}", track.stream_index, track.offset.as_ms(), num, den)); + } + if let Some(lang) = &track.language { + args.push("--language".to_string()); + args.push(format!("{}:{}", track.stream_index, lang.as_str())); + } + } + + // 3. Arquivo fonte + args.push(project.source.path.to_string()); + + // 4. Faixas externas adicionadas pelo usuário + // Cada arquivo externo é um input separado; usa-se TID 0 (faixa única por arquivo). + for track in &project.tracks { + let (path, offset, drift_scale, language, title, is_default) = match track { + Track::Audio(t) => ( + &t.path, + t.offset, + t.drift_scale, + t.language.as_str(), + t.title.as_str(), + t.is_default, + ), + Track::Subtitle(t) => ( + &t.path, + t.offset, + t.drift_scale, + t.language.as_str(), + t.title.as_str(), + t.is_default, + ), + }; + + // TID 0: arquivos externos têm apenas uma faixa + let needs_sync = offset.as_ms() != 0 || (drift_scale - 1.0).abs() > 1e-9; + if needs_sync { + let (num, den) = scale_to_rational(drift_scale); + args.push("--sync".to_string()); + args.push(format!("0:{},{}/{}", offset.as_ms(), num, den)); + } + + args.push("--language".to_string()); + args.push(format!("0:{}", language)); + + if !title.is_empty() { + args.push("--track-name".to_string()); + args.push(format!("0:{}", title)); + } + + args.push("--default-track".to_string()); + args.push(format!("0:{}", if is_default { "yes" } else { "no" })); + + args.push(path.to_string()); + } + + args + } +} + +/// Converte um fator de escala f64 em fração racional irredutível (numerador, denominador). +/// +/// Precisão de 1 em 1_000_000 — suficiente para todos os casos práticos de drift. +/// Exemplos: +/// - `1.0` → `(1, 1)` +/// - `0.99983` → `(99983, 100000)` +/// - `24/23.976` → fração reduzida válida +pub 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) +} + +fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + let t = b; + b = a % b; + a = t; + } + a +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::use_cases::add_audio_track::AddAudioTrack; + use crate::domain::entities::{MediaTrackInfo, MkvOutput, TrackKind, VideoFile}; + use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; + + fn base_project() -> Project { + Project::new( + VideoFile::new(FilePath::from("source.mkv")), + MkvOutput::new(FilePath::from("output.mkv")), + ) + .unwrap() + } + + // ── scale_to_rational ────────────────────────────────────────────────────── + + #[test] + fn scale_to_rational_identidade() { + assert_eq!(scale_to_rational(1.0), (1, 1)); + } + + #[test] + fn scale_to_rational_0_99983() { + assert_eq!(scale_to_rational(0.99983), (99983, 100000)); + } + + #[test] + fn scale_to_rational_24_sobre_23976() { + // 24 / 23.976 ≈ 1.001001... + let scale = 24.0 / 23.976; + let (n, d) = scale_to_rational(scale); + // Verifica que a fração representa a escala com precisão ≤ 1 em 10^5 + let recovered = n as f64 / d as f64; + assert!((recovered - scale).abs() < 1e-5, "n={}, d={}, scale={}", n, d, scale); + // Verifica que é irredutível (GCD = 1) + assert_eq!(gcd(n, d), 1, "Fração não reduzida: {}/{}", n, d); + } + + // ── output primeiro ─────────────────────────────────────────────────────── + + #[test] + fn output_e_o_primeiro_argumento() { + let project = base_project(); + let args = MkvmergeCommandBuilder::build(&project); + assert_eq!(args[0], "-o"); + assert_eq!(args[1], "output.mkv"); + } + + // ── faixas existentes ───────────────────────────────────────────────────── + + #[test] + fn sync_scale_1_offset_zero_nao_emite_sync() { + let mut project = base_project(); + let mut info = MediaTrackInfo::new( + crate::domain::value_objects::TrackId::new(1), + TrackKind::Audio, + "aac", + None, + 1, + ); + info.drift_scale = 1.0; // identidade + project.existing_tracks.push(info); + let args = MkvmergeCommandBuilder::build(&project); + assert!(!args.contains(&"--sync".to_string()), "não deve emitir --sync para identidade"); + } + + #[test] + fn sync_offset_500_scale_1_emite_sync_correto() { + let mut project = base_project(); + let mut info = MediaTrackInfo::new( + crate::domain::value_objects::TrackId::new(1), + TrackKind::Audio, + "aac", + None, + 1, + ); + info.offset = SyncOffset::from_ms(500); + info.drift_scale = 1.0; + project.existing_tracks.push(info); + + let args = MkvmergeCommandBuilder::build(&project); + let pos = args.iter().position(|a| a == "--sync").expect("missing --sync"); + assert_eq!(args[pos + 1], "1:500,1/1"); + } + + #[test] + fn sync_offset_zero_scale_0_99983_emite_sync_correto() { + let mut project = base_project(); + let mut info = MediaTrackInfo::new( + crate::domain::value_objects::TrackId::new(1), + TrackKind::Audio, + "aac", + None, + 1, + ); + info.offset = SyncOffset::from_ms(0); + info.drift_scale = 0.99983; + project.existing_tracks.push(info); + + let args = MkvmergeCommandBuilder::build(&project); + let pos = args.iter().position(|a| a == "--sync").expect("missing --sync"); + assert_eq!(args[pos + 1], "1:0,99983/100000"); + } + + // ── arquivo fonte ───────────────────────────────────────────────────────── + + #[test] + fn fonte_aparece_apos_opcoes_das_faixas_existentes() { + let mut project = base_project(); + let mut info = MediaTrackInfo::new( + crate::domain::value_objects::TrackId::new(1), + TrackKind::Audio, + "aac", + Some(TrackLanguage::new("por").unwrap()), + 1, + ); + info.offset = SyncOffset::from_ms(500); + project.existing_tracks.push(info); + + let args = MkvmergeCommandBuilder::build(&project); + let sync_pos = args.iter().position(|a| a == "--sync").unwrap(); + let src_pos = args.iter().position(|a| a == "source.mkv").unwrap(); + assert!(src_pos > sync_pos, "source.mkv deve vir depois das opções de sync"); + } + + // ── faixas externas ─────────────────────────────────────────────────────── + + #[test] + fn faixa_externa_sem_drift_emite_language_e_default() { + let mut project = base_project(); + let lang = TrackLanguage::new("por").unwrap(); + AddAudioTrack::execute( + &mut project, + FilePath::from("audio.aac"), + SyncOffset::default(), + lang, + false, + String::new(), + 1.0, + ) + .unwrap(); + + let args = MkvmergeCommandBuilder::build(&project); + assert!(args.contains(&"--language".to_string())); + assert!(args.contains(&"0:por".to_string())); + assert!(args.contains(&"audio.aac".to_string())); + } + + #[test] + fn faixa_externa_com_drift_emite_sync() { + let mut project = base_project(); + let lang = TrackLanguage::new("por").unwrap(); + AddAudioTrack::execute( + &mut project, + FilePath::from("audio.aac"), + SyncOffset::from_ms(1200), + lang, + false, + String::new(), + 0.99983, + ) + .unwrap(); + + let args = MkvmergeCommandBuilder::build(&project); + let pos = args.iter().position(|a| a == "--sync").expect("missing --sync"); + assert_eq!(args[pos + 1], "0:1200,99983/100000"); + } +} diff --git a/src/adapters/mkvmerge/mkvmerge_gateway.rs b/src/adapters/mkvmerge/mkvmerge_gateway.rs new file mode 100644 index 0000000..6f81bc4 --- /dev/null +++ b/src/adapters/mkvmerge/mkvmerge_gateway.rs @@ -0,0 +1,34 @@ +use anyhow::{anyhow, Context, Result}; +use std::process::Command; + +use crate::application::ports::ContainerMuxPort; + +/// Implementa `ContainerMuxPort` executando o binário `mkvmerge` (MKVToolNix). +/// +/// Usado quando o projeto possui correção de drift (`drift_scale ≠ 1.0`). +pub struct MkvmergeGateway; + +impl ContainerMuxPort for MkvmergeGateway { + fn execute(&self, args: Vec) -> Result<()> { + let output = Command::new("mkvmerge") + .args(&args) + .output() + .context("Falha ao executar mkvmerge. Verifique se o MKVToolNix está instalado e no PATH.")?; + + // mkvmerge retorna exit code 0 (sucesso) ou 1 (warnings) — ambos aceitáveis. + // Apenas exit code 2 (erros) é tratado como falha. + let exit_code = output.status.code().unwrap_or(2); + if exit_code >= 2 { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(anyhow!( + "mkvmerge encerrou com erro (código {}):\n{}\n{}", + exit_code, + stdout, + stderr + )); + } + + Ok(()) + } +} diff --git a/src/adapters/mkvmerge/mod.rs b/src/adapters/mkvmerge/mod.rs new file mode 100644 index 0000000..475c758 --- /dev/null +++ b/src/adapters/mkvmerge/mod.rs @@ -0,0 +1,5 @@ +pub mod command_builder; +pub mod mkvmerge_gateway; + +pub use command_builder::MkvmergeCommandBuilder; +pub use mkvmerge_gateway::MkvmergeGateway; diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index e3f9c17..55dda52 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -1,2 +1,3 @@ pub mod ffmpeg; pub mod filesystem; +pub mod mkvmerge; diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 4f3bffa..ffe0656 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -12,6 +12,13 @@ pub trait MediaProcessorPort { fn execute(&self, args: Vec) -> Result<()>; } +/// Port para muxing com suporte a escala temporal (implementado pelo MkvmergeGateway). +/// Estruturalmente idêntico ao `MediaProcessorPort` — separado por semântica, não por interface. +#[allow(dead_code)] +pub trait ContainerMuxPort { + fn execute(&self, args: Vec) -> Result<()>; +} + /// Port para abstrair acesso ao sistema de arquivos. #[allow(dead_code)] pub trait FileSystemPort { diff --git a/src/application/use_cases/add_audio_track.rs b/src/application/use_cases/add_audio_track.rs index 6113c90..974dab2 100644 --- a/src/application/use_cases/add_audio_track.rs +++ b/src/application/use_cases/add_audio_track.rs @@ -13,9 +13,10 @@ impl AddAudioTrack { language: TrackLanguage, is_default: bool, title: String, + drift_scale: f64, ) -> Result<()> { let id = project.next_track_id(); - let track = AudioTrack::new(id, path, offset, language, is_default, title); + let track = AudioTrack::new(id, path, offset, language, is_default, title, drift_scale); project.tracks.push(Track::Audio(track)); Ok(()) } @@ -43,6 +44,7 @@ mod tests { lang, false, String::new(), + 1.0, ) .unwrap(); diff --git a/src/application/use_cases/add_subtitle.rs b/src/application/use_cases/add_subtitle.rs index f209c0e..792ee2c 100644 --- a/src/application/use_cases/add_subtitle.rs +++ b/src/application/use_cases/add_subtitle.rs @@ -13,9 +13,10 @@ impl AddSubtitle { language: TrackLanguage, is_default: bool, title: String, + drift_scale: f64, ) -> Result<()> { let id = project.next_track_id(); - let track = SubtitleTrack::new(id, path, offset, language, is_default, title); + let track = SubtitleTrack::new(id, path, offset, language, is_default, title, drift_scale); project.tracks.push(Track::Subtitle(track)); Ok(()) } @@ -42,6 +43,7 @@ mod tests { lang, false, String::new(), + 1.0, ) .unwrap(); diff --git a/src/application/use_cases/adjust_drift.rs b/src/application/use_cases/adjust_drift.rs new file mode 100644 index 0000000..4af2c83 --- /dev/null +++ b/src/application/use_cases/adjust_drift.rs @@ -0,0 +1,85 @@ +use crate::domain::entities::Project; +use crate::domain::value_objects::TrackId; +use anyhow::{Result, anyhow}; + +/// Ajusta o fator de escala temporal (drift) de uma faixa externa pelo TrackId. +/// +/// Um `scale != 1.0` indica que o projeto precisará usar o pipeline mkvmerge. +#[allow(dead_code)] +pub struct AdjustDrift; + +impl AdjustDrift { + #[allow(dead_code)] + pub fn execute(project: &mut Project, id: TrackId, scale: f64) -> Result<()> { + if scale <= 0.0 { + return Err(anyhow!("O fator de escala deve ser maior que zero: {}", scale)); + } + let track = project + .find_track_mut(id) + .ok_or_else(|| anyhow!("Faixa não encontrada: {:?}", id))?; + track.set_drift_scale(scale); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::use_cases::add_audio_track::AddAudioTrack; + use crate::domain::entities::{MkvOutput, VideoFile}; + use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage}; + + fn make_project() -> Project { + Project::new( + VideoFile::new(FilePath::from("input.mkv")), + MkvOutput::new(FilePath::from("output.mkv")), + ) + .unwrap() + } + + #[test] + fn ajusta_drift_existente() { + let mut p = make_project(); + let lang = TrackLanguage::new("por").unwrap(); + AddAudioTrack::execute( + &mut p, + FilePath::from("audio.aac"), + SyncOffset::default(), + lang, + false, + String::new(), + 1.0, + ) + .unwrap(); + let id = p.tracks[0].id(); + + AdjustDrift::execute(&mut p, id, 0.99983).unwrap(); + assert!((p.tracks[0].drift_scale() - 0.99983).abs() < 1e-9); + } + + #[test] + fn erro_se_id_inexistente() { + let mut p = make_project(); + let result = AdjustDrift::execute(&mut p, TrackId::new(99), 0.99983); + assert!(result.is_err()); + } + + #[test] + fn erro_se_scale_zero() { + let mut p = make_project(); + let lang = TrackLanguage::new("por").unwrap(); + AddAudioTrack::execute( + &mut p, + FilePath::from("audio.aac"), + SyncOffset::default(), + lang, + false, + String::new(), + 1.0, + ) + .unwrap(); + let id = p.tracks[0].id(); + let result = AdjustDrift::execute(&mut p, id, 0.0); + assert!(result.is_err()); + } +} diff --git a/src/application/use_cases/adjust_existing_track_drift.rs b/src/application/use_cases/adjust_existing_track_drift.rs new file mode 100644 index 0000000..7767234 --- /dev/null +++ b/src/application/use_cases/adjust_existing_track_drift.rs @@ -0,0 +1,77 @@ +use crate::domain::entities::Project; +use crate::domain::value_objects::TrackId; +use anyhow::{Result, anyhow}; + +/// Ajusta o fator de escala temporal (drift) de uma faixa já presente no arquivo original. +/// +/// Opera sobre `project.existing_tracks` (faixas lidas via ffprobe). +/// Um `scale != 1.0` indica que o projeto precisará usar o pipeline mkvmerge. +#[allow(dead_code)] +pub struct AdjustExistingTrackDrift; + +impl AdjustExistingTrackDrift { + #[allow(dead_code)] + pub fn execute(project: &mut Project, id: TrackId, scale: f64) -> Result<()> { + if scale <= 0.0 { + return Err(anyhow!("O fator de escala deve ser maior que zero: {}", scale)); + } + let track = project + .find_existing_track_mut(id) + .ok_or_else(|| anyhow!("Faixa existente não encontrada: {:?}", id))?; + track.drift_scale = scale; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::entities::{MediaTrackInfo, MkvOutput, TrackKind, VideoFile}; + use crate::domain::value_objects::{FilePath, TrackId}; + + fn make_project() -> Project { + Project::new( + VideoFile::new(FilePath::from("input.mkv")), + MkvOutput::new(FilePath::from("output.mkv")), + ) + .unwrap() + } + + #[test] + fn ajusta_drift_em_faixa_existente() { + let mut p = make_project(); + let id = TrackId::new(1); + p.existing_tracks.push(MediaTrackInfo::new( + id, + TrackKind::Audio, + "aac", + None, + 0, + )); + + AdjustExistingTrackDrift::execute(&mut p, id, 0.99983).unwrap(); + assert!((p.existing_tracks[0].drift_scale - 0.99983).abs() < 1e-9); + } + + #[test] + fn erro_se_id_inexistente() { + let mut p = make_project(); + let result = AdjustExistingTrackDrift::execute(&mut p, TrackId::new(99), 0.99983); + assert!(result.is_err()); + } + + #[test] + fn erro_se_scale_zero() { + let mut p = make_project(); + let id = TrackId::new(1); + p.existing_tracks.push(MediaTrackInfo::new( + id, + TrackKind::Audio, + "aac", + None, + 0, + )); + let result = AdjustExistingTrackDrift::execute(&mut p, id, 0.0); + assert!(result.is_err()); + } +} diff --git a/src/application/use_cases/adjust_sync.rs b/src/application/use_cases/adjust_sync.rs index 61aed55..074cf8b 100644 --- a/src/application/use_cases/adjust_sync.rs +++ b/src/application/use_cases/adjust_sync.rs @@ -39,6 +39,7 @@ mod tests { lang, false, String::new(), + 1.0, ) .unwrap(); let id = project.tracks[0].id(); diff --git a/src/application/use_cases/generate_output.rs b/src/application/use_cases/generate_output.rs index cb767fd..c0661c2 100644 --- a/src/application/use_cases/generate_output.rs +++ b/src/application/use_cases/generate_output.rs @@ -51,6 +51,7 @@ mod tests { TrackLanguage::new("por").unwrap(), false, String::new(), + 1.0, ) .unwrap(); diff --git a/src/application/use_cases/mod.rs b/src/application/use_cases/mod.rs index 3ba9359..8374534 100644 --- a/src/application/use_cases/mod.rs +++ b/src/application/use_cases/mod.rs @@ -1,5 +1,7 @@ pub mod add_audio_track; pub mod add_subtitle; +pub mod adjust_drift; +pub mod adjust_existing_track_drift; pub mod adjust_sync; pub mod edit_existing_track_sync; pub mod export_track; diff --git a/src/application/use_cases/remove_track.rs b/src/application/use_cases/remove_track.rs index 308dc97..3dbe045 100644 --- a/src/application/use_cases/remove_track.rs +++ b/src/application/use_cases/remove_track.rs @@ -40,6 +40,7 @@ mod tests { TrackLanguage::new("por").unwrap(), false, String::new(), + 1.0, ))); assert_eq!(project.tracks.len(), 1); diff --git a/src/application/use_cases/set_track_language.rs b/src/application/use_cases/set_track_language.rs index 33cd3f6..25f4e0e 100644 --- a/src/application/use_cases/set_track_language.rs +++ b/src/application/use_cases/set_track_language.rs @@ -38,6 +38,7 @@ mod tests { TrackLanguage::new("por").unwrap(), false, String::new(), + 1.0, ) .unwrap(); let id = project.tracks[0].id(); diff --git a/src/domain/entities/audio_track.rs b/src/domain/entities/audio_track.rs index b1bf693..1ca23ac 100644 --- a/src/domain/entities/audio_track.rs +++ b/src/domain/entities/audio_track.rs @@ -15,6 +15,9 @@ pub struct AudioTrack { /// String vazia emite `title=` para apagar o título herdado do arquivo fonte /// (ex: "ISO Media file produced by Google Inc."). pub title: String, + /// Fator de escala temporal para correção de drift. `1.0` = sem correção. + /// Valor diferente de 1.0 força o uso de mkvmerge em vez de FFmpeg. + pub drift_scale: f64, } impl AudioTrack { @@ -25,6 +28,7 @@ impl AudioTrack { language: TrackLanguage, is_default: bool, title: String, + drift_scale: f64, ) -> Self { AudioTrack { id, @@ -33,6 +37,7 @@ impl AudioTrack { language, is_default, title, + drift_scale, } } } diff --git a/src/domain/entities/media_track_info.rs b/src/domain/entities/media_track_info.rs index 78d66b5..54e45a6 100644 --- a/src/domain/entities/media_track_info.rs +++ b/src/domain/entities/media_track_info.rs @@ -32,6 +32,11 @@ pub struct MediaTrackInfo { pub offset: SyncOffset, /// Índice do stream no container original (usado internamente pelo adapter). pub stream_index: u32, + /// Fator de escala temporal para correção de drift. `1.0` = sem correção. + /// Campo mutável diretamente; construtor sempre parte de 1.0. + pub drift_scale: f64, + /// Duração da faixa em milissegundos (lida via ffprobe). `None` se não disponível. + pub duration_ms: Option, } impl MediaTrackInfo { @@ -49,6 +54,8 @@ impl MediaTrackInfo { language, offset: SyncOffset::default(), stream_index, + drift_scale: 1.0, + duration_ms: None, } } } diff --git a/src/domain/entities/project.rs b/src/domain/entities/project.rs index 5a15e60..62749cb 100644 --- a/src/domain/entities/project.rs +++ b/src/domain/entities/project.rs @@ -55,12 +55,24 @@ impl Project { self.tracks.retain(|t| t.id() != id); self.tracks.len() < before } + + /// Retorna `true` se qualquer faixa (externa ou existente) possui `drift_scale != 1.0`, + /// indicando que o projeto deve usar o pipeline mkvmerge em vez do FFmpeg. + 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)) + } } #[cfg(test)] mod tests { use super::*; - use crate::domain::value_objects::FilePath; + use crate::domain::entities::{AudioTrack, Track}; + use crate::domain::value_objects::{FilePath, SyncOffset, SyncTransform, TrackLanguage}; fn make_project(src: &str, out: &str) -> Result { Project::new( @@ -69,6 +81,18 @@ mod tests { ) } + fn audio_track_with_drift(id: u32, drift: f64) -> Track { + Track::Audio(AudioTrack::new( + TrackId::new(id), + FilePath::from("audio.aac"), + SyncOffset::default(), + TrackLanguage::new("por").unwrap(), + false, + String::new(), + drift, + )) + } + #[test] fn projeto_valido() { assert!(make_project("input.mkv", "output.mkv").is_ok()); @@ -86,4 +110,50 @@ mod tests { let id2 = id; assert_eq!(id, id2); } + + #[test] + fn needs_mkvmerge_false_sem_drift() { + let mut p = make_project("input.mkv", "output.mkv").unwrap(); + p.tracks.push(audio_track_with_drift(1, 1.0)); + assert!(!p.needs_mkvmerge()); + } + + #[test] + fn needs_mkvmerge_true_com_drift_na_faixa_externa() { + let mut p = make_project("input.mkv", "output.mkv").unwrap(); + p.tracks.push(audio_track_with_drift(1, 0.99983)); + assert!(p.needs_mkvmerge()); + } + + #[test] + fn needs_mkvmerge_true_com_drift_na_faixa_existente() { + let mut p = make_project("input.mkv", "output.mkv").unwrap(); + let mut info = MediaTrackInfo::new( + TrackId::new(1), + crate::domain::entities::TrackKind::Audio, + "aac", + None, + 0, + ); + info.drift_scale = 0.99983; + p.existing_tracks.push(info); + assert!(p.needs_mkvmerge()); + } + + #[test] + fn sync_transform_default_e_identidade() { + let t = SyncTransform::default(); + assert!(t.is_identity()); + assert!(!t.has_drift()); + } + + #[test] + fn sync_transform_has_drift_false() { + assert!(!SyncTransform::new(0, 1.0).has_drift()); + } + + #[test] + fn sync_transform_has_drift_true() { + assert!(SyncTransform::new(0, 0.99983).has_drift()); + } } diff --git a/src/domain/entities/subtitle_track.rs b/src/domain/entities/subtitle_track.rs index 4bc0eac..1f0dd0a 100644 --- a/src/domain/entities/subtitle_track.rs +++ b/src/domain/entities/subtitle_track.rs @@ -13,6 +13,9 @@ pub struct SubtitleTrack { /// Título exibido na lista de faixas do player (ex: "Português", "Forçada"). /// String vazia emite `title=` para apagar o título herdado do arquivo fonte. pub title: String, + /// Fator de escala temporal para correção de drift. `1.0` = sem correção. + /// Valor diferente de 1.0 força o uso de mkvmerge em vez de FFmpeg. + pub drift_scale: f64, } impl SubtitleTrack { @@ -23,6 +26,7 @@ impl SubtitleTrack { language: TrackLanguage, is_default: bool, title: String, + drift_scale: f64, ) -> Self { SubtitleTrack { id, @@ -31,6 +35,7 @@ impl SubtitleTrack { language, is_default, title, + drift_scale, } } } diff --git a/src/domain/entities/track.rs b/src/domain/entities/track.rs index 5423760..d7832f6 100644 --- a/src/domain/entities/track.rs +++ b/src/domain/entities/track.rs @@ -47,4 +47,20 @@ impl Track { Track::Subtitle(t) => t.language = language, } } + + /// Retorna o fator de escala temporal da faixa. + pub fn drift_scale(&self) -> f64 { + match self { + Track::Audio(t) => t.drift_scale, + Track::Subtitle(t) => t.drift_scale, + } + } + + /// Define o fator de escala temporal da faixa. + pub fn set_drift_scale(&mut self, scale: f64) { + match self { + Track::Audio(t) => t.drift_scale = scale, + Track::Subtitle(t) => t.drift_scale = scale, + } + } } diff --git a/src/domain/value_objects/mod.rs b/src/domain/value_objects/mod.rs index 5416e31..183bd17 100644 --- a/src/domain/value_objects/mod.rs +++ b/src/domain/value_objects/mod.rs @@ -1,9 +1,11 @@ pub mod file_path; pub mod sync_offset; +pub mod sync_transform; pub mod track_id; pub mod track_language; pub use file_path::FilePath; pub use sync_offset::SyncOffset; +pub use sync_transform::SyncTransform; pub use track_id::TrackId; pub use track_language::TrackLanguage; diff --git a/src/domain/value_objects/sync_transform.rs b/src/domain/value_objects/sync_transform.rs new file mode 100644 index 0000000..b0b177c --- /dev/null +++ b/src/domain/value_objects/sync_transform.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; + +use super::SyncOffset; + +/// Transformação temporal completa de uma faixa: deslocamento constante + fator de escala. +/// +/// - `offset_ms`: deslocamento em milissegundos (mesmo semântico de `SyncOffset`). +/// - `scale`: fator de escala temporal. `1.0` = identidade. `0.99983` ≈ correção 25 fps → 24 fps. +/// +/// Quando `scale == 1.0`, o pipeline padrão FFmpeg é utilizado. +/// Quando `scale != 1.0`, o projeto precisa passar pelo pipeline mkvmerge. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct SyncTransform { + pub offset_ms: i64, + pub scale: f64, +} + +impl SyncTransform { + /// Cria uma transformação com deslocamento e escala explícitos. + pub fn new(offset_ms: i64, scale: f64) -> Self { + SyncTransform { offset_ms, scale } + } + + /// Cria uma transformação a partir de um `SyncOffset` sem drift (escala = 1.0). + pub fn from_offset(offset: SyncOffset) -> Self { + SyncTransform { + offset_ms: offset.as_ms(), + scale: 1.0, + } + } + + /// Retorna `true` se o fator de escala difere de 1.0 por mais de 1e-9. + pub fn has_drift(&self) -> bool { + (self.scale - 1.0).abs() > 1e-9 + } + + /// Retorna `true` se a transformação é identidade (offset == 0 e sem drift). + pub fn is_identity(&self) -> bool { + self.offset_ms == 0 && !self.has_drift() + } + + /// Converte para `SyncOffset` descartando a escala (compatibilidade com FFmpeg path). + pub fn to_sync_offset(&self) -> SyncOffset { + SyncOffset::from_ms(self.offset_ms) + } +} + +impl Default for SyncTransform { + /// Identidade: sem deslocamento, sem drift. + fn default() -> Self { + SyncTransform { + offset_ms: 0, + scale: 1.0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_e_identidade() { + let t = SyncTransform::default(); + assert!(t.is_identity()); + assert!(!t.has_drift()); + assert_eq!(t.offset_ms, 0); + assert_eq!(t.scale, 1.0); + } + + #[test] + fn has_drift_false_scale_1() { + let t = SyncTransform::new(500, 1.0); + assert!(!t.has_drift()); + } + + #[test] + fn has_drift_true_scale_0_99983() { + let t = SyncTransform::new(0, 0.99983); + assert!(t.has_drift()); + } + + #[test] + fn from_offset_sem_drift() { + let offset = SyncOffset::from_ms(1200); + let t = SyncTransform::from_offset(offset); + assert_eq!(t.offset_ms, 1200); + assert!(!t.has_drift()); + } + + #[test] + fn to_sync_offset_descarta_escala() { + let t = SyncTransform::new(-500, 0.99983); + assert_eq!(t.to_sync_offset().as_ms(), -500); + } +} diff --git a/src/infrastructure/process/mod.rs b/src/infrastructure/process/mod.rs index e0d2846..b0be766 100644 --- a/src/infrastructure/process/mod.rs +++ b/src/infrastructure/process/mod.rs @@ -71,3 +71,76 @@ pub fn validate_dependencies() -> Result<()> { check_binary_available("ffprobe")?; Ok(()) } + +/// Verifica se `mkvmerge` está disponível no PATH (verificação soft — não bloqueia o app). +pub fn mkvmerge_available() -> bool { + std::process::Command::new("mkvmerge") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Executa o mkvmerge de forma assíncrona, capturando stdout + stderr em tempo real. +/// +/// Diferença em relação ao FFmpeg: o mkvmerge escreve progresso em **stdout**, +/// por isso ambos são capturados e encaminhados ao `progress_tx`. +/// +/// Códigos de saída: 0 = sucesso, 1 = avisos (aceitável), 2 = erro (falha). +pub async fn run_mkvmerge_async( + args: Vec, + progress_tx: Sender, + cancel_rx: tokio::sync::oneshot::Receiver<()>, +) -> Result<()> { + let mut child = Command::new("mkvmerge") + .args(&args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("Falha ao iniciar mkvmerge. Verifique se o MKVToolNix está instalado.")?; + + // Captura stdout (progresso) + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + let tx = progress_tx.clone(); + tokio::spawn(async move { + while let Ok(Some(line)) = lines.next_line().await { + let _ = tx.send(line); + } + }); + } + + // Captura stderr (erros e avisos) + if let Some(stderr) = child.stderr.take() { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + let tx = progress_tx.clone(); + tokio::spawn(async move { + while let Ok(Some(line)) = lines.next_line().await { + let _ = tx.send(line); + } + }); + } + + tokio::select! { + result = child.wait() => { + let status = result.context("Erro ao aguardar mkvmerge")?; + let code = status.code().unwrap_or(2); + if code >= 2 { + return Err(anyhow!( + "mkvmerge encerrou com código de saída: {} (erro)", + code + )); + } + Ok(()) + } + _ = cancel_rx => { + let _ = child.kill().await; + Err(anyhow!("Cancelado pelo usuário")) + } + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 7506631..0b8ddbd 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,13 +1,15 @@ use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway}; +use crate::adapters::mkvmerge::MkvmergeCommandBuilder; use crate::adapters::filesystem::file_picker::FilePickerAdapter; use crate::application::use_cases::{ add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle, + adjust_existing_track_drift::AdjustExistingTrackDrift, edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack, load_media_info::LoadMediaInfo, remove_track::RemoveTrack, }; use crate::domain::entities::{MkvOutput, Project, VideoFile}; use crate::domain::value_objects::FilePath; -use crate::infrastructure::process::run_ffmpeg_async; +use crate::infrastructure::process::{run_ffmpeg_async, run_mkvmerge_async}; use crate::ui::components::{ add_audio_track_form::AddAudioTrackForm, add_subtitle_form::AddSubtitleForm, @@ -70,6 +72,8 @@ pub struct App { batch_panel: BatchPanel, // Índice do item sendo processado no lote (None = nenhum) batch_processing_index: Option, + // mkvmerge disponível no PATH (verificação soft na inicialização) + mkvmerge_available: bool, } impl App { @@ -109,6 +113,7 @@ impl App { batch_items: Vec::new(), batch_panel: BatchPanel::new(), batch_processing_index: None, + mkvmerge_available: crate::infrastructure::process::mkvmerge_available(), } } @@ -221,13 +226,19 @@ impl App { } /// Inicia geração do MKV em background thread com progresso em tempo real. + /// Despacha para FFmpeg ou mkvmerge dependendo de `project.needs_mkvmerge()`. fn start_generation(&mut self, ctx: egui::Context) { let project = match &self.project { Some(p) => p.clone(), None => return, }; - let args = FfmpegCommandBuilder::build(&project); + let uses_mkvmerge = project.needs_mkvmerge(); + let args = if uses_mkvmerge { + MkvmergeCommandBuilder::build(&project) + } else { + FfmpegCommandBuilder::build(&project) + }; let (tx, rx) = mpsc::channel::(); self.bg_rx = Some(rx); self.execution_panel.state = ExecutionState::Running; @@ -237,7 +248,7 @@ impl App { self.cancel_tx = Some(cancel_tx); std::thread::spawn(move || { - // Canal std para receber linhas de log do run_ffmpeg_async + // Canal std para receber linhas de log let (log_tx, log_rx) = std::sync::mpsc::channel::(); // Thread auxiliar: encaminha cada linha de log para o canal da UI @@ -251,10 +262,12 @@ impl App { }); let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio"); - let result = rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx)); + let result = if uses_mkvmerge { + rt.block_on(run_mkvmerge_async(args, log_tx, cancel_rx)) + } else { + rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx)) + }; - // Aguarda o encaminhador consumir todas as linhas pendentes - // antes de enviar Done/Error, garantindo ordem correta no log let _ = fwd_handle.join(); match result { @@ -279,13 +292,19 @@ impl App { } /// Inicia o processamento de um item específico do carrinho de lote. + /// Despacha para FFmpeg ou mkvmerge dependendo de `project.needs_mkvmerge()`. fn start_batch_item(&mut self, index: usize, ctx: egui::Context) { let project = self.batch_items[index].project.clone(); self.batch_items[index].state = ExecutionState::Running; self.batch_items[index].log_lines.clear(); self.batch_processing_index = Some(index); - let args = FfmpegCommandBuilder::build(&project); + let uses_mkvmerge = project.needs_mkvmerge(); + let args = if uses_mkvmerge { + MkvmergeCommandBuilder::build(&project) + } else { + FfmpegCommandBuilder::build(&project) + }; let (tx, rx) = mpsc::channel::(); self.bg_rx = Some(rx); @@ -305,7 +324,11 @@ impl App { }); let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio"); - let result = rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx)); + let result = if uses_mkvmerge { + rt.block_on(run_mkvmerge_async(args, log_tx, cancel_rx)) + } else { + rt.block_on(run_ffmpeg_async(args, log_tx, cancel_rx)) + }; let _ = fwd_handle.join(); match result { @@ -577,7 +600,7 @@ impl eframe::App for App { .auto_shrink([false, false]) .show(ui, |ui| { let is_processing = self.batch_processing_index.is_some(); - let events = self.batch_panel.ui(ui, &self.batch_items, is_processing); + let events = self.batch_panel.ui(ui, &self.batch_items, is_processing, self.mkvmerge_available); for event in events { match event { BatchPanelEvent::AddItem(mut project) => { @@ -621,7 +644,8 @@ impl eframe::App for App { .show(ui, |ui| { if let Some(project) = &mut self.project { // ── Faixas existentes ───────────────────────────────── - let events = self.existing_track_list.ui(ui, &project.existing_tracks); + let mkvmerge_available = self.mkvmerge_available; + let events = self.existing_track_list.ui(ui, &project.existing_tracks, mkvmerge_available); for event in events { use crate::domain::entities::TrackKind; use crate::ui::components::existing_track_list::ExistingTrackEvent; @@ -629,6 +653,9 @@ impl eframe::App for App { ExistingTrackEvent::OffsetChanged(id, offset) => { let _ = EditExistingTrackSync::execute(project, id, offset); } + ExistingTrackEvent::DriftChanged(id, scale) => { + let _ = AdjustExistingTrackDrift::execute(project, id, scale); + } ExistingTrackEvent::ExportRequested(id) => { if let Some(track) = project.existing_tracks.iter().find(|t| t.id == id).cloned() @@ -697,7 +724,7 @@ impl eframe::App for App { .allocate_ui_with_layout( egui::Vec2::new(col_w, 0.0), egui::Layout::top_down(egui::Align::Min), - |ui| self.add_audio_form.ui(ui), + |ui| self.add_audio_form.ui(ui, mkvmerge_available), ) .inner; @@ -705,15 +732,15 @@ impl eframe::App for App { .allocate_ui_with_layout( egui::Vec2::new(col_w, 0.0), egui::Layout::top_down(egui::Align::Min), - |ui| self.add_subtitle_form.ui(ui), + |ui| self.add_subtitle_form.ui(ui, mkvmerge_available), ) .inner; (audio_req, subtitle_req) } else { - let ar = self.add_audio_form.ui(ui); + let ar = self.add_audio_form.ui(ui, mkvmerge_available); ui.add_space(4.0); - let sr = self.add_subtitle_form.ui(ui); + let sr = self.add_subtitle_form.ui(ui, mkvmerge_available); (ar, sr) }; @@ -725,6 +752,7 @@ impl eframe::App for App { req.language, req.is_default, req.title, + req.drift_scale, ); } if let Some(req) = subtitle_req { @@ -735,6 +763,7 @@ impl eframe::App for App { req.language, req.is_default, req.title, + req.drift_scale, ); } diff --git a/src/ui/components/add_audio_track_form.rs b/src/ui/components/add_audio_track_form.rs index 75ebc5c..bcfc288 100644 --- a/src/ui/components/add_audio_track_form.rs +++ b/src/ui/components/add_audio_track_form.rs @@ -10,6 +10,8 @@ pub struct AddAudioTrackRequest { pub language: TrackLanguage, pub is_default: bool, pub title: String, + /// Fator de escala temporal (correção de drift). 1.0 = sem correção. + pub drift_scale: f64, } /// Formulário para adicionar uma faixa de áudio externa. @@ -33,7 +35,7 @@ impl AddAudioTrackForm { } /// Renderiza o formulário. Retorna `Some(request)` ao confirmar. - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { + pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option { let mut result = None; ui.group(|ui| { @@ -51,7 +53,7 @@ impl AddAudioTrackForm { } }); - self.offset_field.ui(ui, "Atraso:"); + self.offset_field.ui_with_drift(ui, "Atraso:", mkvmerge_available); self.language_field.ui(ui); ui.horizontal(|ui| { @@ -82,12 +84,14 @@ impl AddAudioTrackForm { self.offset_field.parse(), self.language_field.parse(), ) { + let drift_scale = self.offset_field.parse_drift(); result = Some(AddAudioTrackRequest { path, offset, language, is_default: self.is_default, title: self.title_input.trim().to_string(), + drift_scale, }); // Reset form self.selected_path = None; diff --git a/src/ui/components/add_subtitle_form.rs b/src/ui/components/add_subtitle_form.rs index fbc64e0..9ead213 100644 --- a/src/ui/components/add_subtitle_form.rs +++ b/src/ui/components/add_subtitle_form.rs @@ -10,6 +10,8 @@ pub struct AddSubtitleRequest { pub language: TrackLanguage, pub is_default: bool, pub title: String, + /// Fator de escala temporal (correção de drift). 1.0 = sem correção. + pub drift_scale: f64, } /// Formulário para adicionar uma faixa de legenda externa. @@ -33,7 +35,7 @@ impl AddSubtitleForm { } /// Renderiza o formulário. Retorna `Some(request)` ao confirmar. - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { + pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option { let mut result = None; ui.group(|ui| { @@ -51,7 +53,7 @@ impl AddSubtitleForm { } }); - self.offset_field.ui(ui, "Atraso:"); + self.offset_field.ui_with_drift(ui, "Atraso:", mkvmerge_available); self.language_field.ui(ui); ui.horizontal(|ui| { @@ -81,12 +83,14 @@ impl AddSubtitleForm { self.offset_field.parse(), self.language_field.parse(), ) { + let drift_scale = self.offset_field.parse_drift(); result = Some(AddSubtitleRequest { path, offset, language, is_default: self.is_default, title: self.title_input.trim().to_string(), + drift_scale, }); self.selected_path = None; self.offset_field = SyncOffsetField::new(SyncOffset::default()); diff --git a/src/ui/components/batch_panel.rs b/src/ui/components/batch_panel.rs index f594e5a..e923a9f 100644 --- a/src/ui/components/batch_panel.rs +++ b/src/ui/components/batch_panel.rs @@ -51,6 +51,7 @@ struct PendingAudio { language: TrackLanguage, is_default: bool, title: String, + drift_scale: f64, } struct PendingSubtitle { @@ -59,6 +60,7 @@ struct PendingSubtitle { language: TrackLanguage, is_default: bool, title: String, + drift_scale: f64, } // ── BatchPanel ───────────────────────────────────────────────────────────────── @@ -123,6 +125,7 @@ impl BatchPanel { language: t.language.clone(), is_default: t.is_default, title: t.title.clone(), + drift_scale: t.drift_scale, }), Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle { path: t.path.clone(), @@ -130,6 +133,7 @@ impl BatchPanel { language: t.language.clone(), is_default: t.is_default, title: t.title.clone(), + drift_scale: t.drift_scale, }), } } @@ -141,6 +145,7 @@ impl BatchPanel { ui: &mut egui::Ui, items: &[BatchItem], is_processing: bool, + mkvmerge_available: bool, ) -> Vec { let mut events = Vec::new(); @@ -269,7 +274,7 @@ impl BatchPanel { self.form_open = true; } } else { - self.render_form(ui, &mut events); + self.render_form(ui, &mut events, mkvmerge_available); } } @@ -318,7 +323,7 @@ impl BatchPanel { } /// Renderiza o formulário inline de adição ou edição de item. - fn render_form(&mut self, ui: &mut egui::Ui, events: &mut Vec) { + fn render_form(&mut self, ui: &mut egui::Ui, events: &mut Vec, mkvmerge_available: bool) { let is_edit = self.editing_index.is_some(); let title = if let Some(idx) = self.editing_index { format!("Editar item #{}", idx + 1) @@ -442,7 +447,7 @@ impl BatchPanel { .allocate_ui_with_layout( egui::Vec2::new(col_w, 0.0), egui::Layout::top_down(egui::Align::Min), - |ui| self.form_add_audio.ui(ui), + |ui| self.form_add_audio.ui(ui, mkvmerge_available), ) .inner; @@ -450,15 +455,15 @@ impl BatchPanel { .allocate_ui_with_layout( egui::Vec2::new(col_w, 0.0), egui::Layout::top_down(egui::Align::Min), - |ui| self.form_add_subtitle.ui(ui), + |ui| self.form_add_subtitle.ui(ui, mkvmerge_available), ) .inner; (ar, sr) } else { - let ar = self.form_add_audio.ui(ui); + let ar = self.form_add_audio.ui(ui, mkvmerge_available); ui.add_space(2.0); - let sr = self.form_add_subtitle.ui(ui); + let sr = self.form_add_subtitle.ui(ui, mkvmerge_available); (ar, sr) }; @@ -469,6 +474,7 @@ impl BatchPanel { language: req.language, is_default: req.is_default, title: req.title, + drift_scale: req.drift_scale, }); } if let Some(req) = subtitle_req { @@ -478,6 +484,7 @@ impl BatchPanel { language: req.language, is_default: req.is_default, title: req.title, + drift_scale: req.drift_scale, }); } @@ -521,6 +528,7 @@ impl BatchPanel { t.language, t.is_default, t.title, + t.drift_scale, ); } for s in subs { @@ -531,6 +539,7 @@ impl BatchPanel { s.language, s.is_default, s.title, + s.drift_scale, ); } if let Some(idx) = self.editing_index { diff --git a/src/ui/components/existing_track_list.rs b/src/ui/components/existing_track_list.rs index a484bcc..fb8fafe 100644 --- a/src/ui/components/existing_track_list.rs +++ b/src/ui/components/existing_track_list.rs @@ -7,19 +7,24 @@ use std::collections::HashMap; /// Evento emitido por interações com a lista de faixas existentes. pub enum ExistingTrackEvent { OffsetChanged(TrackId, SyncOffset), + /// O fator de escala temporal de uma faixa existente foi alterado. + DriftChanged(TrackId, f64), /// O usuário solicitou exportar a faixa identificada por `TrackId`. ExportRequested(TrackId), } -/// Lista as faixas detectadas no arquivo de vídeo e permite editar o offset de cada uma. +/// Lista as faixas detectadas no arquivo de vídeo e permite editar o offset e o drift de cada uma. pub struct ExistingTrackList { offset_fields: HashMap, + /// Campos de drift raw por track id (percentual digitado pelo usuário). + drift_fields: HashMap, } impl ExistingTrackList { pub fn new() -> Self { ExistingTrackList { offset_fields: HashMap::new(), + drift_fields: HashMap::new(), } } @@ -29,14 +34,20 @@ impl ExistingTrackList { self.offset_fields .entry(track.id.val()) .or_insert_with(|| SyncOffsetField::new(track.offset)); + self.drift_fields + .entry(track.id.val()) + .or_insert_with(|| format!("{:.5}", track.drift_scale * 100.0)); } } - /// Renderiza a lista. Retorna eventos de alteração de offset. + /// Renderiza a lista. Retorna eventos de alteração de offset e drift. + /// + /// `mkvmerge_available`: quando `true`, exibe a coluna de velocidade original (%). pub fn ui( &mut self, ui: &mut egui::Ui, tracks: &[MediaTrackInfo], + mkvmerge_available: bool, ) -> Vec { let mut events = Vec::new(); @@ -48,18 +59,24 @@ impl ExistingTrackList { return; } + let num_cols = if mkvmerge_available { 7 } else { 6 }; egui::Grid::new("existing_tracks_grid") - .num_columns(5) + .num_columns(num_cols) .striped(true) .show(ui, |ui| { ui.strong("#"); ui.strong("Tipo"); ui.strong("Codec"); + ui.strong("Duração"); ui.strong("Atraso"); + if mkvmerge_available { + ui.strong("Velocidade (%)"); + } ui.strong(""); ui.end_row(); for track in tracks { + let editable = matches!(track.kind, TrackKind::Audio | TrackKind::Subtitle); let kind_label = match track.kind { TrackKind::Video => "Vídeo", TrackKind::Audio => "Áudio", @@ -76,6 +93,12 @@ impl ExistingTrackList { ui.label(format!("{}{}", kind_label, lang)); ui.label(&track.codec); + // Duração + match track.duration_ms { + Some(ms) => ui.label(format_duration(ms)), + None => ui.label("-"), + }; + // Offset field let field = self .offset_fields @@ -89,6 +112,33 @@ impl ExistingTrackList { } } + // Drift field (somente para áudio e legenda quando mkvmerge disponível) + if mkvmerge_available { + if editable { + let drift_raw = self + .drift_fields + .entry(track.id.val()) + .or_insert_with(|| format!("{:.5}", track.drift_scale * 100.0)); + let resp = ui.add( + egui::TextEdit::singleline(drift_raw).desired_width(80.0), + ); + if resp.changed() { + if let Ok(pct) = drift_raw.trim().parse::() { + if pct >= 1.0 && pct <= 999.99 { + let scale = pct / 100.0; + if (scale - track.drift_scale).abs() > 1e-9 { + events.push(ExistingTrackEvent::DriftChanged( + track.id, scale, + )); + } + } + } + } + } else { + ui.label(""); // célula vazia para vídeo/dados + } + } + // Botão de exportar (somente Áudio e Legenda) match track.kind { TrackKind::Audio | TrackKind::Subtitle => { @@ -109,3 +159,17 @@ impl ExistingTrackList { events } } + +/// Formata duração em ms para exibição: "3:45" ou "1:23:45". +fn format_duration(ms: u64) -> String { + let total_secs = ms / 1000; + let h = total_secs / 3600; + let m = (total_secs % 3600) / 60; + let s = total_secs % 60; + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") + } +} + diff --git a/src/ui/components/sync_offset_field.rs b/src/ui/components/sync_offset_field.rs index 7af737b..6f7dcb3 100644 --- a/src/ui/components/sync_offset_field.rs +++ b/src/ui/components/sync_offset_field.rs @@ -3,9 +3,13 @@ use crate::domain::value_objects::SyncOffset; /// Campo de entrada para offset de sincronização em segundos (ex: "-1.2"). /// Converte para SyncOffset(ms) internamente ao confirmar. +/// Também suporta campo opcional de velocidade original (%) para correção de drift via mkvmerge. pub struct SyncOffsetField { pub raw: String, pub error: Option, + /// Percentual de velocidade original (ex: "99.983" → scale 0.99983). Padrão: "100.00". + pub drift_raw: String, + drift_error: Option, } impl SyncOffsetField { @@ -14,10 +18,12 @@ impl SyncOffsetField { SyncOffsetField { raw: format!("{:.1}", seconds), error: None, + drift_raw: "100.00".to_string(), + drift_error: None, } } - /// Renderiza o campo e retorna true se o valor mudou. + /// Renderiza o campo de offset (sem o campo de drift). pub fn ui(&mut self, ui: &mut egui::Ui, label: &str) -> bool { let mut changed = false; ui.horizontal(|ui| { @@ -35,6 +41,42 @@ impl SyncOffsetField { changed } + /// Renderiza offset + campo de velocidade original (drift) quando `mkvmerge_available`. + /// + /// - Quando `mkvmerge_available = false`: campo de velocidade é exibido desabilitado + /// com tooltip pedindo instalação do MKVToolNix. + /// - Quando `scale ≠ 1.0`: exibe aviso discreto em laranja. + pub fn ui_with_drift(&mut self, ui: &mut egui::Ui, label: &str, mkvmerge_available: bool) -> bool { + let offset_changed = self.ui(ui, label); + + ui.horizontal(|ui| { + ui.label("Velocidade original (%):"); + let resp = ui.add_enabled( + mkvmerge_available, + egui::TextEdit::singleline(&mut self.drift_raw).desired_width(70.0), + ); + if !mkvmerge_available { + resp.on_hover_text( + "Instale MKVToolNix para usar correção de drift de velocidade.", + ); + } + if let Ok(pct) = self.drift_raw.trim().parse::() { + if (pct - 100.0).abs() > 0.001 { + ui.colored_label( + egui::Color32::from_rgb(255, 160, 0), + "⚠ requer mkvmerge", + ); + } + } + }); + + if let Some(ref err) = self.drift_error { + ui.colored_label(egui::Color32::RED, err); + } + + offset_changed + } + /// Tenta converter o valor atual para SyncOffset. pub fn parse(&mut self) -> Option { match SyncOffset::from_seconds_str(&self.raw) { @@ -48,4 +90,26 @@ impl SyncOffsetField { } } } + + /// Converte o percentual de velocidade para fator de escala (f64). + /// "100.00" → 1.0, "99.983" → 0.99983. + /// Retorna `1.0` em caso de erro (fallback seguro). + pub fn parse_drift(&mut self) -> f64 { + let trimmed = self.drift_raw.trim().trim_end_matches('%'); + match trimmed.parse::() { + Ok(pct) if pct >= 1.0 && pct <= 999.99 => { + self.drift_error = None; + pct / 100.0 + } + Ok(_) => { + self.drift_error = + Some("Velocidade deve estar entre 1.0% e 999.99%".to_string()); + 1.0 + } + Err(_) => { + self.drift_error = Some("Valor inválido".to_string()); + 1.0 + } + } + } }