Compare commits
3
Commits
285f22e2d1
...
8b031f07ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b031f07ec | ||
|
|
e70612f5ea | ||
|
|
1ef47b3a07 |
+26
-26
@@ -669,47 +669,47 @@ Drift: [__100.00_%] ← novo; só visível quando mkvmerge disponível
|
|||||||
|
|
||||||
**Domain:**
|
**Domain:**
|
||||||
|
|
||||||
- [ ] Criar `src/domain/value_objects/sync_transform.rs` com `SyncTransform`
|
- [x] Criar `src/domain/value_objects/sync_transform.rs` com `SyncTransform`
|
||||||
- [ ] Atualizar `src/domain/value_objects/mod.rs` para expor `SyncTransform`
|
- [x] 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)
|
- [x] 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`
|
- [x] Adicionar `drift_scale: f64` em `MediaTrackInfo`
|
||||||
- [ ] Adicionar `drift_scale()` e `set_drift_scale()` em `Track`
|
- [x] Adicionar `drift_scale()` e `set_drift_scale()` em `Track`
|
||||||
- [ ] Adicionar `Project::needs_mkvmerge()` com testes unitários
|
- [x] Adicionar `Project::needs_mkvmerge()` com testes unitários
|
||||||
|
|
||||||
**Application:**
|
**Application:**
|
||||||
|
|
||||||
- [ ] Criar `adjust_drift.rs` com testes
|
- [x] Criar `adjust_drift.rs` com testes
|
||||||
- [ ] Criar `adjust_existing_track_drift.rs` com testes
|
- [x] Criar `adjust_existing_track_drift.rs` com testes
|
||||||
- [ ] Atualizar `add_audio_track.rs` para aceitar `drift_scale`
|
- [x] Atualizar `add_audio_track.rs` para aceitar `drift_scale`
|
||||||
- [ ] Atualizar `add_subtitle.rs` para aceitar `drift_scale`
|
- [x] Atualizar `add_subtitle.rs` para aceitar `drift_scale`
|
||||||
- [ ] Adicionar `ContainerMuxPort` em `ports/mod.rs`
|
- [x] Adicionar `ContainerMuxPort` em `ports/mod.rs`
|
||||||
- [ ] Atualizar `mod.rs` dos use cases
|
- [x] Atualizar `mod.rs` dos use cases
|
||||||
|
|
||||||
**Adapters:**
|
**Adapters:**
|
||||||
|
|
||||||
- [ ] Criar `src/adapters/mkvmerge/command_builder.rs` com `MkvmergeCommandBuilder::build()` e `scale_to_rational()`
|
- [x] 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)
|
- [x] Criar testes unitários para `MkvmergeCommandBuilder` (ver seção 9.4)
|
||||||
- [ ] Criar `src/adapters/mkvmerge/mkvmerge_gateway.rs` implementando `ContainerMuxPort`
|
- [x] Criar `src/adapters/mkvmerge/mkvmerge_gateway.rs` implementando `ContainerMuxPort`
|
||||||
- [ ] Criar `src/adapters/mkvmerge/mod.rs`
|
- [x] Criar `src/adapters/mkvmerge/mod.rs`
|
||||||
- [ ] Atualizar `src/adapters/mod.rs`
|
- [x] Atualizar `src/adapters/mod.rs`
|
||||||
|
|
||||||
**Infrastructure:**
|
**Infrastructure:**
|
||||||
|
|
||||||
- [ ] Adicionar `run_mkvmerge_async` em `src/infrastructure/process/mod.rs`
|
- [x] Adicionar `run_mkvmerge_async` em `src/infrastructure/process/mod.rs`
|
||||||
- [ ] Adicionar `mkvmerge_available()` em `src/infrastructure/process/mod.rs`
|
- [x] Adicionar `mkvmerge_available()` em `src/infrastructure/process/mod.rs`
|
||||||
|
|
||||||
**UI:**
|
**UI:**
|
||||||
|
|
||||||
- [ ] Atualizar `SyncOffsetField` com campo de drift (condicionado a `mkvmerge_available`)
|
- [x] Atualizar `SyncOffsetField` com campo de drift (condicionado a `mkvmerge_available`)
|
||||||
- [ ] Atualizar `ExistingTrackList` para expor drift e chamar `AdjustExistingTrackDrift`
|
- [x] Atualizar `ExistingTrackList` para expor drift e chamar `AdjustExistingTrackDrift`
|
||||||
- [ ] Atualizar `AddAudioTrackForm` e `AddSubtitleForm` para passar `drift_scale` ao use case
|
- [x] Atualizar `AddAudioTrackForm` e `AddSubtitleForm` para passar `drift_scale` ao use case
|
||||||
- [ ] Adicionar `mkvmerge_available: bool` em `App`
|
- [x] Adicionar `mkvmerge_available: bool` em `App`
|
||||||
- [ ] Atualizar `App::generate_output()` com dispatch FFmpeg/mkvmerge
|
- [x] Atualizar `App::generate_output()` com dispatch FFmpeg/mkvmerge
|
||||||
- [ ] Atualizar `App::start_batch_item()` com o mesmo dispatch
|
- [x] Atualizar `App::start_batch_item()` com o mesmo dispatch
|
||||||
|
|
||||||
**Validação final:**
|
**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
|
- [ ] Verificar manualmente com arquivo de teste que apresenta drift progressivo
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ impl FfmpegCommandBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Maps ────────────────────────────────────────────────────────────────
|
// ── Maps ────────────────────────────────────────────────────────────────
|
||||||
// Faixas existentes do source
|
// Faixas existentes do source (excluídas pelo usuário são omitidas)
|
||||||
for track in &project.existing_tracks {
|
for track in project.existing_tracks.iter().filter(|t| !t.excluded) {
|
||||||
args.push("-map".to_string());
|
args.push("-map".to_string());
|
||||||
if track.offset.is_zero() {
|
if track.offset.is_zero() {
|
||||||
args.push(format!("0:{}", track.stream_index));
|
args.push(format!("0:{}", track.stream_index));
|
||||||
@@ -74,7 +74,9 @@ impl FfmpegCommandBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se não há faixas existentes mapeadas, mapeia tudo do source
|
// Se não há faixas existentes conhecidas (ffprobe não foi executado), mapeia tudo do source.
|
||||||
|
// Quando existing_tracks está populado mas todas foram excluídas, o usuário optou
|
||||||
|
// conscientemente por não incluir nenhuma faixa original — não emitimos -map 0.
|
||||||
if project.existing_tracks.is_empty() {
|
if project.existing_tracks.is_empty() {
|
||||||
args.push("-map".to_string());
|
args.push("-map".to_string());
|
||||||
args.push("0".to_string());
|
args.push("0".to_string());
|
||||||
@@ -310,6 +312,7 @@ mod tests {
|
|||||||
TrackLanguage::new("por").unwrap(),
|
TrackLanguage::new("por").unwrap(),
|
||||||
false,
|
false,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
1.0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -329,6 +332,7 @@ mod tests {
|
|||||||
TrackLanguage::new("por").unwrap(),
|
TrackLanguage::new("por").unwrap(),
|
||||||
false,
|
false,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
1.0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -350,6 +354,7 @@ mod tests {
|
|||||||
TrackLanguage::new("por").unwrap(),
|
TrackLanguage::new("por").unwrap(),
|
||||||
false,
|
false,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
1.0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -361,4 +366,49 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(args.contains(&"language=por".to_string()));
|
assert!(args.contains(&"language=por".to_string()));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
|
fn faixa_existente_excluida_nao_aparece_em_map() {
|
||||||
|
use crate::domain::entities::{MediaTrackInfo, TrackKind};
|
||||||
|
use crate::domain::value_objects::TrackId;
|
||||||
|
let mut project = base_project();
|
||||||
|
let mut audio = MediaTrackInfo::new(
|
||||||
|
TrackId::new(1),
|
||||||
|
TrackKind::Audio,
|
||||||
|
"aac",
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
audio.excluded = true;
|
||||||
|
project.existing_tracks.push(audio);
|
||||||
|
|
||||||
|
let args = FfmpegCommandBuilder::build(&project);
|
||||||
|
// -map 0:1 não deve aparecer pois a faixa está excluída
|
||||||
|
assert!(
|
||||||
|
!args.contains(&"0:1".to_string()),
|
||||||
|
"faixa excluída não deve aparecer em -map — args: {:?}",
|
||||||
|
args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn faixa_existente_nao_excluida_aparece_em_map() {
|
||||||
|
use crate::domain::entities::{MediaTrackInfo, TrackKind};
|
||||||
|
use crate::domain::value_objects::TrackId;
|
||||||
|
let mut project = base_project();
|
||||||
|
project.existing_tracks.push(MediaTrackInfo::new(
|
||||||
|
TrackId::new(1),
|
||||||
|
TrackKind::Audio,
|
||||||
|
"aac",
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
|
||||||
|
let args = FfmpegCommandBuilder::build(&project);
|
||||||
|
assert!(
|
||||||
|
args.contains(&"0:1".to_string()),
|
||||||
|
"faixa não excluída deve aparecer em -map — args: {:?}",
|
||||||
|
args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,12 +20,17 @@ struct FfprobeStream {
|
|||||||
index: u32,
|
index: u32,
|
||||||
codec_type: Option<String>,
|
codec_type: Option<String>,
|
||||||
codec_name: Option<String>,
|
codec_name: Option<String>,
|
||||||
|
duration: Option<String>,
|
||||||
tags: Option<FfprobeTags>,
|
tags: Option<FfprobeTags>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct FfprobeTags {
|
struct FfprobeTags {
|
||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaInfoPort for FfprobeGateway {
|
impl MediaInfoPort for FfprobeGateway {
|
||||||
@@ -61,15 +66,78 @@ impl MediaInfoPort for FfprobeGateway {
|
|||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let codec = s.codec_name.unwrap_or_else(|| "unknown".to_string());
|
let codec = s.codec_name.unwrap_or_else(|| "unknown".to_string());
|
||||||
let language = s
|
let (language, duration_from_tag) = match s.tags {
|
||||||
.tags
|
Some(t) => {
|
||||||
.and_then(|t| t.language)
|
let lang = t.language.and_then(|l| TrackLanguage::new(l).ok());
|
||||||
.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);
|
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::<f64>().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();
|
.collect();
|
||||||
|
|
||||||
Ok(tracks)
|
Ok(tracks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converte string no formato "HH:MM:SS.NNNNNNNNN" (tag DURATION do ffprobe) para milissegundos.
|
||||||
|
fn parse_duration_tag(s: &str) -> Option<u64> {
|
||||||
|
// 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::<u64>().ok()?, m.parse::<u64>().ok()?, sec.parse::<u64>().ok()?),
|
||||||
|
[m, sec] => (0, m.parse::<u64>().ok()?, sec.parse::<u64>().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::<u64>().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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
use crate::domain::entities::{Project, Track, TrackKind};
|
||||||
|
use crate::domain::value_objects::SyncOffset;
|
||||||
|
|
||||||
|
/// Constrói os argumentos de linha de comando para o `mkvmerge`.
|
||||||
|
///
|
||||||
|
/// Responsabilidade única: converter `Project` → `Vec<String>` 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 <output>`
|
||||||
|
/// 2. Opções de faixas existentes (--sync, --language) antes do arquivo fonte
|
||||||
|
/// 3. `<source.path>`
|
||||||
|
/// 4. Para cada faixa externa: opções (--sync, --language, --track-name, --default-track) + arquivo
|
||||||
|
pub fn build(project: &Project) -> Vec<String> {
|
||||||
|
let mut args = Vec::new();
|
||||||
|
|
||||||
|
// 1. Output (vem primeiro no mkvmerge)
|
||||||
|
args.push("-o".to_string());
|
||||||
|
args.push(project.output.path.to_string());
|
||||||
|
|
||||||
|
// 2. Seleção de faixas existentes (exclusão pelo usuário)
|
||||||
|
// Coleta TIDs de áudio e legenda não excluídos para --audio-tracks / --subtitle-tracks.
|
||||||
|
// Se todos de um tipo foram excluídos, emite --no-audio ou --no-subtitles.
|
||||||
|
let audio_included: Vec<u32> = project
|
||||||
|
.existing_tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| matches!(t.kind, TrackKind::Audio) && !t.excluded)
|
||||||
|
.map(|t| t.stream_index)
|
||||||
|
.collect();
|
||||||
|
let audio_total = project
|
||||||
|
.existing_tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| matches!(t.kind, TrackKind::Audio))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if audio_total > 0 {
|
||||||
|
if audio_included.is_empty() {
|
||||||
|
args.push("--no-audio".to_string());
|
||||||
|
} else if audio_included.len() < audio_total {
|
||||||
|
let tids = audio_included
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
args.push("--audio-tracks".to_string());
|
||||||
|
args.push(tids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sub_included: Vec<u32> = project
|
||||||
|
.existing_tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| matches!(t.kind, TrackKind::Subtitle) && !t.excluded)
|
||||||
|
.map(|t| t.stream_index)
|
||||||
|
.collect();
|
||||||
|
let sub_total = project
|
||||||
|
.existing_tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| matches!(t.kind, TrackKind::Subtitle))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if sub_total > 0 {
|
||||||
|
if sub_included.is_empty() {
|
||||||
|
args.push("--no-subtitles".to_string());
|
||||||
|
} else if sub_included.len() < sub_total {
|
||||||
|
let tids = sub_included
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
args.push("--subtitle-tracks".to_string());
|
||||||
|
args.push(tids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opções de faixas existentes não excluídas (--sync, --language)
|
||||||
|
for track in project.existing_tracks.iter().filter(|t| !t.excluded) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>) -> 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod command_builder;
|
||||||
|
pub mod mkvmerge_gateway;
|
||||||
|
|
||||||
|
pub use command_builder::MkvmergeCommandBuilder;
|
||||||
|
pub use mkvmerge_gateway::MkvmergeGateway;
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod ffmpeg;
|
pub mod ffmpeg;
|
||||||
pub mod filesystem;
|
pub mod filesystem;
|
||||||
|
pub mod mkvmerge;
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ pub trait MediaProcessorPort {
|
|||||||
fn execute(&self, args: Vec<String>) -> Result<()>;
|
fn execute(&self, args: Vec<String>) -> 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<String>) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Port para abstrair acesso ao sistema de arquivos.
|
/// Port para abstrair acesso ao sistema de arquivos.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub trait FileSystemPort {
|
pub trait FileSystemPort {
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ impl AddAudioTrack {
|
|||||||
language: TrackLanguage,
|
language: TrackLanguage,
|
||||||
is_default: bool,
|
is_default: bool,
|
||||||
title: String,
|
title: String,
|
||||||
|
drift_scale: f64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let id = project.next_track_id();
|
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));
|
project.tracks.push(Track::Audio(track));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,7 @@ mod tests {
|
|||||||
lang,
|
lang,
|
||||||
false,
|
false,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
1.0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ impl AddSubtitle {
|
|||||||
language: TrackLanguage,
|
language: TrackLanguage,
|
||||||
is_default: bool,
|
is_default: bool,
|
||||||
title: String,
|
title: String,
|
||||||
|
drift_scale: f64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let id = project.next_track_id();
|
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));
|
project.tracks.push(Track::Subtitle(track));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,7 @@ mod tests {
|
|||||||
lang,
|
lang,
|
||||||
false,
|
false,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
1.0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ mod tests {
|
|||||||
lang,
|
lang,
|
||||||
false,
|
false,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
1.0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let id = project.tracks[0].id();
|
let id = project.tracks[0].id();
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ mod tests {
|
|||||||
TrackLanguage::new("por").unwrap(),
|
TrackLanguage::new("por").unwrap(),
|
||||||
false,
|
false,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
1.0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod add_audio_track;
|
pub mod add_audio_track;
|
||||||
pub mod add_subtitle;
|
pub mod add_subtitle;
|
||||||
|
pub mod adjust_drift;
|
||||||
|
pub mod adjust_existing_track_drift;
|
||||||
pub mod adjust_sync;
|
pub mod adjust_sync;
|
||||||
pub mod edit_existing_track_sync;
|
pub mod edit_existing_track_sync;
|
||||||
pub mod export_track;
|
pub mod export_track;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ mod tests {
|
|||||||
TrackLanguage::new("por").unwrap(),
|
TrackLanguage::new("por").unwrap(),
|
||||||
false,
|
false,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
1.0,
|
||||||
)));
|
)));
|
||||||
assert_eq!(project.tracks.len(), 1);
|
assert_eq!(project.tracks.len(), 1);
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ mod tests {
|
|||||||
TrackLanguage::new("por").unwrap(),
|
TrackLanguage::new("por").unwrap(),
|
||||||
false,
|
false,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
1.0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let id = project.tracks[0].id();
|
let id = project.tracks[0].id();
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ pub struct AudioTrack {
|
|||||||
/// String vazia emite `title=` para apagar o título herdado do arquivo fonte
|
/// String vazia emite `title=` para apagar o título herdado do arquivo fonte
|
||||||
/// (ex: "ISO Media file produced by Google Inc.").
|
/// (ex: "ISO Media file produced by Google Inc.").
|
||||||
pub title: String,
|
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 {
|
impl AudioTrack {
|
||||||
@@ -25,6 +28,7 @@ impl AudioTrack {
|
|||||||
language: TrackLanguage,
|
language: TrackLanguage,
|
||||||
is_default: bool,
|
is_default: bool,
|
||||||
title: String,
|
title: String,
|
||||||
|
drift_scale: f64,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
AudioTrack {
|
AudioTrack {
|
||||||
id,
|
id,
|
||||||
@@ -33,6 +37,7 @@ impl AudioTrack {
|
|||||||
language,
|
language,
|
||||||
is_default,
|
is_default,
|
||||||
title,
|
title,
|
||||||
|
drift_scale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ pub struct MediaTrackInfo {
|
|||||||
pub offset: SyncOffset,
|
pub offset: SyncOffset,
|
||||||
/// Índice do stream no container original (usado internamente pelo adapter).
|
/// Índice do stream no container original (usado internamente pelo adapter).
|
||||||
pub stream_index: u32,
|
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<u64>,
|
||||||
|
/// Quando `true`, a faixa é omitida do arquivo de saída (não é mapeada).
|
||||||
|
/// A faixa permanece visível na UI para que o usuário possa reverter a decisão.
|
||||||
|
#[serde(default)]
|
||||||
|
pub excluded: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaTrackInfo {
|
impl MediaTrackInfo {
|
||||||
@@ -49,6 +58,9 @@ impl MediaTrackInfo {
|
|||||||
language,
|
language,
|
||||||
offset: SyncOffset::default(),
|
offset: SyncOffset::default(),
|
||||||
stream_index,
|
stream_index,
|
||||||
|
drift_scale: 1.0,
|
||||||
|
duration_ms: None,
|
||||||
|
excluded: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,18 +49,43 @@ impl Project {
|
|||||||
self.existing_tracks.iter_mut().find(|t| t.id == id)
|
self.existing_tracks.iter_mut().find(|t| t.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alterna o estado `excluded` de uma faixa existente pelo TrackId.
|
||||||
|
/// Retorna `true` se a faixa foi encontrada. Faixas de vídeo são ignoradas.
|
||||||
|
pub fn toggle_existing_track_excluded(&mut self, id: TrackId) -> bool {
|
||||||
|
use crate::domain::entities::TrackKind;
|
||||||
|
match self.find_existing_track_mut(id) {
|
||||||
|
Some(t) if !matches!(t.kind, TrackKind::Video) => {
|
||||||
|
t.excluded = !t.excluded;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove uma faixa externa pelo TrackId. Retorna true se a faixa foi encontrada e removida.
|
/// Remove uma faixa externa pelo TrackId. Retorna true se a faixa foi encontrada e removida.
|
||||||
pub fn remove_track(&mut self, id: TrackId) -> bool {
|
pub fn remove_track(&mut self, id: TrackId) -> bool {
|
||||||
let before = self.tracks.len();
|
let before = self.tracks.len();
|
||||||
self.tracks.retain(|t| t.id() != id);
|
self.tracks.retain(|t| t.id() != id);
|
||||||
self.tracks.len() < before
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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> {
|
fn make_project(src: &str, out: &str) -> Result<Project> {
|
||||||
Project::new(
|
Project::new(
|
||||||
@@ -69,6 +94,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]
|
#[test]
|
||||||
fn projeto_valido() {
|
fn projeto_valido() {
|
||||||
assert!(make_project("input.mkv", "output.mkv").is_ok());
|
assert!(make_project("input.mkv", "output.mkv").is_ok());
|
||||||
@@ -86,4 +123,76 @@ mod tests {
|
|||||||
let id2 = id;
|
let id2 = id;
|
||||||
assert_eq!(id, id2);
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_excluded_alterna_estado() {
|
||||||
|
use crate::domain::entities::TrackKind;
|
||||||
|
let mut p = make_project("input.mkv", "output.mkv").unwrap();
|
||||||
|
let id = TrackId::new(10);
|
||||||
|
p.existing_tracks.push(MediaTrackInfo::new(id, TrackKind::Audio, "aac", None, 1));
|
||||||
|
|
||||||
|
assert!(!p.existing_tracks[0].excluded);
|
||||||
|
assert!(p.toggle_existing_track_excluded(id));
|
||||||
|
assert!(p.existing_tracks[0].excluded);
|
||||||
|
assert!(p.toggle_existing_track_excluded(id));
|
||||||
|
assert!(!p.existing_tracks[0].excluded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_excluded_ignora_faixa_de_video() {
|
||||||
|
use crate::domain::entities::TrackKind;
|
||||||
|
let mut p = make_project("input.mkv", "output.mkv").unwrap();
|
||||||
|
let id = TrackId::new(20);
|
||||||
|
p.existing_tracks.push(MediaTrackInfo::new(id, TrackKind::Video, "h264", None, 0));
|
||||||
|
|
||||||
|
// retorna false: faixa de vídeo não pode ser excluída
|
||||||
|
assert!(!p.toggle_existing_track_excluded(id));
|
||||||
|
assert!(!p.existing_tracks[0].excluded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ pub struct SubtitleTrack {
|
|||||||
/// Título exibido na lista de faixas do player (ex: "Português", "Forçada").
|
/// 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.
|
/// String vazia emite `title=` para apagar o título herdado do arquivo fonte.
|
||||||
pub title: String,
|
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 {
|
impl SubtitleTrack {
|
||||||
@@ -23,6 +26,7 @@ impl SubtitleTrack {
|
|||||||
language: TrackLanguage,
|
language: TrackLanguage,
|
||||||
is_default: bool,
|
is_default: bool,
|
||||||
title: String,
|
title: String,
|
||||||
|
drift_scale: f64,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
SubtitleTrack {
|
SubtitleTrack {
|
||||||
id,
|
id,
|
||||||
@@ -31,6 +35,7 @@ impl SubtitleTrack {
|
|||||||
language,
|
language,
|
||||||
is_default,
|
is_default,
|
||||||
title,
|
title,
|
||||||
|
drift_scale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,20 @@ impl Track {
|
|||||||
Track::Subtitle(t) => t.language = language,
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
pub mod file_path;
|
pub mod file_path;
|
||||||
pub mod sync_offset;
|
pub mod sync_offset;
|
||||||
|
pub mod sync_transform;
|
||||||
pub mod track_id;
|
pub mod track_id;
|
||||||
pub mod track_language;
|
pub mod track_language;
|
||||||
|
|
||||||
pub use file_path::FilePath;
|
pub use file_path::FilePath;
|
||||||
pub use sync_offset::SyncOffset;
|
pub use sync_offset::SyncOffset;
|
||||||
|
pub use sync_transform::SyncTransform;
|
||||||
pub use track_id::TrackId;
|
pub use track_id::TrackId;
|
||||||
pub use track_language::TrackLanguage;
|
pub use track_language::TrackLanguage;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,3 +71,76 @@ pub fn validate_dependencies() -> Result<()> {
|
|||||||
check_binary_available("ffprobe")?;
|
check_binary_available("ffprobe")?;
|
||||||
Ok(())
|
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<String>,
|
||||||
|
progress_tx: Sender<String>,
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+53
-15
@@ -1,13 +1,15 @@
|
|||||||
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
|
use crate::adapters::ffmpeg::{FfmpegCommandBuilder, FfmpegGateway, FfprobeGateway};
|
||||||
|
use crate::adapters::mkvmerge::MkvmergeCommandBuilder;
|
||||||
use crate::adapters::filesystem::file_picker::FilePickerAdapter;
|
use crate::adapters::filesystem::file_picker::FilePickerAdapter;
|
||||||
use crate::application::use_cases::{
|
use crate::application::use_cases::{
|
||||||
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
|
add_audio_track::AddAudioTrack, add_subtitle::AddSubtitle,
|
||||||
|
adjust_existing_track_drift::AdjustExistingTrackDrift,
|
||||||
edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack,
|
edit_existing_track_sync::EditExistingTrackSync, export_track::ExportTrack,
|
||||||
load_media_info::LoadMediaInfo, remove_track::RemoveTrack,
|
load_media_info::LoadMediaInfo, remove_track::RemoveTrack,
|
||||||
};
|
};
|
||||||
use crate::domain::entities::{MkvOutput, Project, VideoFile};
|
use crate::domain::entities::{MkvOutput, Project, TrackKind, VideoFile};
|
||||||
use crate::domain::value_objects::FilePath;
|
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::{
|
use crate::ui::components::{
|
||||||
add_audio_track_form::AddAudioTrackForm,
|
add_audio_track_form::AddAudioTrackForm,
|
||||||
add_subtitle_form::AddSubtitleForm,
|
add_subtitle_form::AddSubtitleForm,
|
||||||
@@ -70,6 +72,8 @@ pub struct App {
|
|||||||
batch_panel: BatchPanel,
|
batch_panel: BatchPanel,
|
||||||
// Índice do item sendo processado no lote (None = nenhum)
|
// Índice do item sendo processado no lote (None = nenhum)
|
||||||
batch_processing_index: Option<usize>,
|
batch_processing_index: Option<usize>,
|
||||||
|
// mkvmerge disponível no PATH (verificação soft na inicialização)
|
||||||
|
mkvmerge_available: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -109,6 +113,7 @@ impl App {
|
|||||||
batch_items: Vec::new(),
|
batch_items: Vec::new(),
|
||||||
batch_panel: BatchPanel::new(),
|
batch_panel: BatchPanel::new(),
|
||||||
batch_processing_index: None,
|
batch_processing_index: None,
|
||||||
|
mkvmerge_available: crate::infrastructure::process::mkvmerge_available(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +135,12 @@ impl App {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.existing_track_list
|
self.existing_track_list
|
||||||
.sync_tracks(&project.existing_tracks);
|
.sync_tracks(&project.existing_tracks);
|
||||||
|
// Propaga duração do vídeo para os formulários (sugestão de velocidade)
|
||||||
|
let video_dur = project.existing_tracks.iter()
|
||||||
|
.find(|t| matches!(t.kind, TrackKind::Video))
|
||||||
|
.and_then(|t| t.duration_ms);
|
||||||
|
self.add_audio_form.set_video_duration(video_dur);
|
||||||
|
self.add_subtitle_form.set_video_duration(video_dur);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.execution_panel.state = ExecutionState::Error(e.to_string());
|
self.execution_panel.state = ExecutionState::Error(e.to_string());
|
||||||
@@ -221,13 +232,19 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Inicia geração do MKV em background thread com progresso em tempo real.
|
/// 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) {
|
fn start_generation(&mut self, ctx: egui::Context) {
|
||||||
let project = match &self.project {
|
let project = match &self.project {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => return,
|
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::<BackgroundMsg>();
|
let (tx, rx) = mpsc::channel::<BackgroundMsg>();
|
||||||
self.bg_rx = Some(rx);
|
self.bg_rx = Some(rx);
|
||||||
self.execution_panel.state = ExecutionState::Running;
|
self.execution_panel.state = ExecutionState::Running;
|
||||||
@@ -237,7 +254,7 @@ impl App {
|
|||||||
self.cancel_tx = Some(cancel_tx);
|
self.cancel_tx = Some(cancel_tx);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
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::<String>();
|
let (log_tx, log_rx) = std::sync::mpsc::channel::<String>();
|
||||||
|
|
||||||
// Thread auxiliar: encaminha cada linha de log para o canal da UI
|
// Thread auxiliar: encaminha cada linha de log para o canal da UI
|
||||||
@@ -251,10 +268,12 @@ impl App {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio");
|
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();
|
let _ = fwd_handle.join();
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -279,13 +298,19 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Inicia o processamento de um item específico do carrinho de lote.
|
/// 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) {
|
fn start_batch_item(&mut self, index: usize, ctx: egui::Context) {
|
||||||
let project = self.batch_items[index].project.clone();
|
let project = self.batch_items[index].project.clone();
|
||||||
self.batch_items[index].state = ExecutionState::Running;
|
self.batch_items[index].state = ExecutionState::Running;
|
||||||
self.batch_items[index].log_lines.clear();
|
self.batch_items[index].log_lines.clear();
|
||||||
self.batch_processing_index = Some(index);
|
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::<BackgroundMsg>();
|
let (tx, rx) = mpsc::channel::<BackgroundMsg>();
|
||||||
self.bg_rx = Some(rx);
|
self.bg_rx = Some(rx);
|
||||||
|
|
||||||
@@ -305,7 +330,11 @@ impl App {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("Falha ao criar runtime tokio");
|
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();
|
let _ = fwd_handle.join();
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -577,7 +606,7 @@ impl eframe::App for App {
|
|||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
let is_processing = self.batch_processing_index.is_some();
|
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 {
|
for event in events {
|
||||||
match event {
|
match event {
|
||||||
BatchPanelEvent::AddItem(mut project) => {
|
BatchPanelEvent::AddItem(mut project) => {
|
||||||
@@ -621,7 +650,8 @@ impl eframe::App for App {
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
if let Some(project) = &mut self.project {
|
if let Some(project) = &mut self.project {
|
||||||
// ── Faixas existentes ─────────────────────────────────
|
// ── 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 {
|
for event in events {
|
||||||
use crate::domain::entities::TrackKind;
|
use crate::domain::entities::TrackKind;
|
||||||
use crate::ui::components::existing_track_list::ExistingTrackEvent;
|
use crate::ui::components::existing_track_list::ExistingTrackEvent;
|
||||||
@@ -629,6 +659,12 @@ impl eframe::App for App {
|
|||||||
ExistingTrackEvent::OffsetChanged(id, offset) => {
|
ExistingTrackEvent::OffsetChanged(id, offset) => {
|
||||||
let _ = EditExistingTrackSync::execute(project, id, offset);
|
let _ = EditExistingTrackSync::execute(project, id, offset);
|
||||||
}
|
}
|
||||||
|
ExistingTrackEvent::DriftChanged(id, scale) => {
|
||||||
|
let _ = AdjustExistingTrackDrift::execute(project, id, scale);
|
||||||
|
}
|
||||||
|
ExistingTrackEvent::ExcludeToggled(id) => {
|
||||||
|
project.toggle_existing_track_excluded(id);
|
||||||
|
}
|
||||||
ExistingTrackEvent::ExportRequested(id) => {
|
ExistingTrackEvent::ExportRequested(id) => {
|
||||||
if let Some(track) =
|
if let Some(track) =
|
||||||
project.existing_tracks.iter().find(|t| t.id == id).cloned()
|
project.existing_tracks.iter().find(|t| t.id == id).cloned()
|
||||||
@@ -697,7 +733,7 @@ impl eframe::App for App {
|
|||||||
.allocate_ui_with_layout(
|
.allocate_ui_with_layout(
|
||||||
egui::Vec2::new(col_w, 0.0),
|
egui::Vec2::new(col_w, 0.0),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| self.add_audio_form.ui(ui),
|
|ui| self.add_audio_form.ui(ui, mkvmerge_available),
|
||||||
)
|
)
|
||||||
.inner;
|
.inner;
|
||||||
|
|
||||||
@@ -705,15 +741,15 @@ impl eframe::App for App {
|
|||||||
.allocate_ui_with_layout(
|
.allocate_ui_with_layout(
|
||||||
egui::Vec2::new(col_w, 0.0),
|
egui::Vec2::new(col_w, 0.0),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| self.add_subtitle_form.ui(ui),
|
|ui| self.add_subtitle_form.ui(ui, mkvmerge_available),
|
||||||
)
|
)
|
||||||
.inner;
|
.inner;
|
||||||
|
|
||||||
(audio_req, subtitle_req)
|
(audio_req, subtitle_req)
|
||||||
} else {
|
} else {
|
||||||
let ar = self.add_audio_form.ui(ui);
|
let ar = self.add_audio_form.ui(ui, mkvmerge_available);
|
||||||
ui.add_space(4.0);
|
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)
|
(ar, sr)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -725,6 +761,7 @@ impl eframe::App for App {
|
|||||||
req.language,
|
req.language,
|
||||||
req.is_default,
|
req.is_default,
|
||||||
req.title,
|
req.title,
|
||||||
|
req.drift_scale,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(req) = subtitle_req {
|
if let Some(req) = subtitle_req {
|
||||||
@@ -735,6 +772,7 @@ impl eframe::App for App {
|
|||||||
req.language,
|
req.language,
|
||||||
req.is_default,
|
req.is_default,
|
||||||
req.title,
|
req.title,
|
||||||
|
req.drift_scale,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
use crate::adapters::ffmpeg::{FfprobeGateway};
|
||||||
use crate::adapters::filesystem::FilePickerAdapter;
|
use crate::adapters::filesystem::FilePickerAdapter;
|
||||||
|
use crate::application::ports::MediaInfoPort;
|
||||||
|
use crate::domain::entities::TrackKind;
|
||||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||||
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
|
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
@@ -10,6 +13,8 @@ pub struct AddAudioTrackRequest {
|
|||||||
pub language: TrackLanguage,
|
pub language: TrackLanguage,
|
||||||
pub is_default: bool,
|
pub is_default: bool,
|
||||||
pub title: String,
|
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.
|
/// Formulário para adicionar uma faixa de áudio externa.
|
||||||
@@ -19,6 +24,10 @@ pub struct AddAudioTrackForm {
|
|||||||
language_field: LanguageField,
|
language_field: LanguageField,
|
||||||
is_default: bool,
|
is_default: bool,
|
||||||
title_input: String,
|
title_input: String,
|
||||||
|
/// Duração do vídeo base (ms) — definida por App após carregar o projeto.
|
||||||
|
pub video_duration_ms: Option<u64>,
|
||||||
|
/// Duração da faixa de áudio selecionada (ms) — obtida via ffprobe ao escolher o arquivo.
|
||||||
|
selected_duration_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddAudioTrackForm {
|
impl AddAudioTrackForm {
|
||||||
@@ -29,11 +38,27 @@ impl AddAudioTrackForm {
|
|||||||
language_field: LanguageField::new(None),
|
language_field: LanguageField::new(None),
|
||||||
is_default: false,
|
is_default: false,
|
||||||
title_input: String::new(),
|
title_input: String::new(),
|
||||||
|
video_duration_ms: None,
|
||||||
|
selected_duration_ms: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atualiza a duração do vídeo base e recalcula a sugestão de velocidade.
|
||||||
|
pub fn set_video_duration(&mut self, dur: Option<u64>) {
|
||||||
|
self.video_duration_ms = dur;
|
||||||
|
self.update_suggestion();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_suggestion(&mut self) {
|
||||||
|
let suggestion = self.video_duration_ms
|
||||||
|
.zip(self.selected_duration_ms)
|
||||||
|
.filter(|(_, td)| *td > 0)
|
||||||
|
.map(|(vd, td)| vd as f64 / td as f64 * 100.0);
|
||||||
|
self.offset_field.set_suggestion(suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
/// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
|
/// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
|
||||||
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<AddAudioTrackRequest> {
|
pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddAudioTrackRequest> {
|
||||||
let mut result = None;
|
let mut result = None;
|
||||||
|
|
||||||
ui.group(|ui| {
|
ui.group(|ui| {
|
||||||
@@ -48,10 +73,19 @@ impl AddAudioTrackForm {
|
|||||||
ui.label(&label);
|
ui.label(&label);
|
||||||
if ui.button("Selecionar áudio…").clicked() {
|
if ui.button("Selecionar áudio…").clicked() {
|
||||||
self.selected_path = FilePickerAdapter::pick_audio();
|
self.selected_path = FilePickerAdapter::pick_audio();
|
||||||
|
// Proba a duração do arquivo selecionado para sugestão de velocidade
|
||||||
|
self.selected_duration_ms = self.selected_path.as_ref().and_then(|p| {
|
||||||
|
let tracks = FfprobeGateway.probe(p).ok()?;
|
||||||
|
tracks.iter()
|
||||||
|
.find(|t| matches!(t.kind, TrackKind::Audio) && t.duration_ms.is_some())
|
||||||
|
.or_else(|| tracks.iter().find(|t| t.duration_ms.is_some()))
|
||||||
|
.and_then(|t| t.duration_ms)
|
||||||
|
});
|
||||||
|
self.update_suggestion();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
self.offset_field.ui(ui, "Atraso:");
|
self.offset_field.ui_with_drift(ui, "Atraso:", mkvmerge_available);
|
||||||
self.language_field.ui(ui);
|
self.language_field.ui(ui);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -82,16 +116,20 @@ impl AddAudioTrackForm {
|
|||||||
self.offset_field.parse(),
|
self.offset_field.parse(),
|
||||||
self.language_field.parse(),
|
self.language_field.parse(),
|
||||||
) {
|
) {
|
||||||
|
let drift_scale = self.offset_field.parse_drift();
|
||||||
result = Some(AddAudioTrackRequest {
|
result = Some(AddAudioTrackRequest {
|
||||||
path,
|
path,
|
||||||
offset,
|
offset,
|
||||||
language,
|
language,
|
||||||
is_default: self.is_default,
|
is_default: self.is_default,
|
||||||
title: self.title_input.trim().to_string(),
|
title: self.title_input.trim().to_string(),
|
||||||
|
drift_scale,
|
||||||
});
|
});
|
||||||
// Reset form
|
// Reset form
|
||||||
self.selected_path = None;
|
self.selected_path = None;
|
||||||
self.offset_field = SyncOffsetField::new(SyncOffset::default());
|
self.offset_field = SyncOffsetField::new(SyncOffset::default());
|
||||||
|
self.selected_duration_ms = None;
|
||||||
|
self.update_suggestion();
|
||||||
self.language_field = LanguageField::new(None);
|
self.language_field = LanguageField::new(None);
|
||||||
self.is_default = false;
|
self.is_default = false;
|
||||||
self.title_input = String::new();
|
self.title_input = String::new();
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
use crate::adapters::ffmpeg::FfprobeGateway;
|
||||||
use crate::adapters::filesystem::FilePickerAdapter;
|
use crate::adapters::filesystem::FilePickerAdapter;
|
||||||
|
use crate::application::ports::MediaInfoPort;
|
||||||
|
use crate::domain::entities::TrackKind;
|
||||||
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
|
||||||
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
|
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
@@ -10,6 +13,8 @@ pub struct AddSubtitleRequest {
|
|||||||
pub language: TrackLanguage,
|
pub language: TrackLanguage,
|
||||||
pub is_default: bool,
|
pub is_default: bool,
|
||||||
pub title: String,
|
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.
|
/// Formulário para adicionar uma faixa de legenda externa.
|
||||||
@@ -19,6 +24,10 @@ pub struct AddSubtitleForm {
|
|||||||
language_field: LanguageField,
|
language_field: LanguageField,
|
||||||
is_default: bool,
|
is_default: bool,
|
||||||
title_input: String,
|
title_input: String,
|
||||||
|
/// Duração do vídeo base (ms) — definida por App após carregar o projeto.
|
||||||
|
pub video_duration_ms: Option<u64>,
|
||||||
|
/// Duração da legenda selecionada (ms) — obtida via ffprobe ao escolher o arquivo.
|
||||||
|
selected_duration_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddSubtitleForm {
|
impl AddSubtitleForm {
|
||||||
@@ -29,11 +38,27 @@ impl AddSubtitleForm {
|
|||||||
language_field: LanguageField::new(None),
|
language_field: LanguageField::new(None),
|
||||||
is_default: false,
|
is_default: false,
|
||||||
title_input: String::new(),
|
title_input: String::new(),
|
||||||
|
video_duration_ms: None,
|
||||||
|
selected_duration_ms: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atualiza a duração do vídeo base e recalcula a sugestão de velocidade.
|
||||||
|
pub fn set_video_duration(&mut self, dur: Option<u64>) {
|
||||||
|
self.video_duration_ms = dur;
|
||||||
|
self.update_suggestion();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_suggestion(&mut self) {
|
||||||
|
let suggestion = self.video_duration_ms
|
||||||
|
.zip(self.selected_duration_ms)
|
||||||
|
.filter(|(_, td)| *td > 0)
|
||||||
|
.map(|(vd, td)| vd as f64 / td as f64 * 100.0);
|
||||||
|
self.offset_field.set_suggestion(suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
/// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
|
/// Renderiza o formulário. Retorna `Some(request)` ao confirmar.
|
||||||
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<AddSubtitleRequest> {
|
pub fn ui(&mut self, ui: &mut egui::Ui, mkvmerge_available: bool) -> Option<AddSubtitleRequest> {
|
||||||
let mut result = None;
|
let mut result = None;
|
||||||
|
|
||||||
ui.group(|ui| {
|
ui.group(|ui| {
|
||||||
@@ -48,10 +73,19 @@ impl AddSubtitleForm {
|
|||||||
ui.label(&label);
|
ui.label(&label);
|
||||||
if ui.button("Selecionar legenda…").clicked() {
|
if ui.button("Selecionar legenda…").clicked() {
|
||||||
self.selected_path = FilePickerAdapter::pick_subtitle();
|
self.selected_path = FilePickerAdapter::pick_subtitle();
|
||||||
|
// Proba a duração do arquivo selecionado para sugestão de velocidade
|
||||||
|
self.selected_duration_ms = self.selected_path.as_ref().and_then(|p| {
|
||||||
|
let tracks = FfprobeGateway.probe(p).ok()?;
|
||||||
|
tracks.iter()
|
||||||
|
.find(|t| matches!(t.kind, TrackKind::Subtitle) && t.duration_ms.is_some())
|
||||||
|
.or_else(|| tracks.iter().find(|t| t.duration_ms.is_some()))
|
||||||
|
.and_then(|t| t.duration_ms)
|
||||||
|
});
|
||||||
|
self.update_suggestion();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
self.offset_field.ui(ui, "Atraso:");
|
self.offset_field.ui_with_drift(ui, "Atraso:", mkvmerge_available);
|
||||||
self.language_field.ui(ui);
|
self.language_field.ui(ui);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -81,15 +115,19 @@ impl AddSubtitleForm {
|
|||||||
self.offset_field.parse(),
|
self.offset_field.parse(),
|
||||||
self.language_field.parse(),
|
self.language_field.parse(),
|
||||||
) {
|
) {
|
||||||
|
let drift_scale = self.offset_field.parse_drift();
|
||||||
result = Some(AddSubtitleRequest {
|
result = Some(AddSubtitleRequest {
|
||||||
path,
|
path,
|
||||||
offset,
|
offset,
|
||||||
language,
|
language,
|
||||||
is_default: self.is_default,
|
is_default: self.is_default,
|
||||||
title: self.title_input.trim().to_string(),
|
title: self.title_input.trim().to_string(),
|
||||||
|
drift_scale,
|
||||||
});
|
});
|
||||||
self.selected_path = None;
|
self.selected_path = None;
|
||||||
self.offset_field = SyncOffsetField::new(SyncOffset::default());
|
self.offset_field = SyncOffsetField::new(SyncOffset::default());
|
||||||
|
self.selected_duration_ms = None;
|
||||||
|
self.update_suggestion();
|
||||||
self.language_field = LanguageField::new(None);
|
self.language_field = LanguageField::new(None);
|
||||||
self.is_default = false;
|
self.is_default = false;
|
||||||
self.title_input = String::new();
|
self.title_input = String::new();
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ struct PendingAudio {
|
|||||||
language: TrackLanguage,
|
language: TrackLanguage,
|
||||||
is_default: bool,
|
is_default: bool,
|
||||||
title: String,
|
title: String,
|
||||||
|
drift_scale: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PendingSubtitle {
|
struct PendingSubtitle {
|
||||||
@@ -59,6 +60,7 @@ struct PendingSubtitle {
|
|||||||
language: TrackLanguage,
|
language: TrackLanguage,
|
||||||
is_default: bool,
|
is_default: bool,
|
||||||
title: String,
|
title: String,
|
||||||
|
drift_scale: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BatchPanel ─────────────────────────────────────────────────────────────────
|
// ── BatchPanel ─────────────────────────────────────────────────────────────────
|
||||||
@@ -123,6 +125,7 @@ impl BatchPanel {
|
|||||||
language: t.language.clone(),
|
language: t.language.clone(),
|
||||||
is_default: t.is_default,
|
is_default: t.is_default,
|
||||||
title: t.title.clone(),
|
title: t.title.clone(),
|
||||||
|
drift_scale: t.drift_scale,
|
||||||
}),
|
}),
|
||||||
Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle {
|
Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle {
|
||||||
path: t.path.clone(),
|
path: t.path.clone(),
|
||||||
@@ -130,6 +133,7 @@ impl BatchPanel {
|
|||||||
language: t.language.clone(),
|
language: t.language.clone(),
|
||||||
is_default: t.is_default,
|
is_default: t.is_default,
|
||||||
title: t.title.clone(),
|
title: t.title.clone(),
|
||||||
|
drift_scale: t.drift_scale,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +145,7 @@ impl BatchPanel {
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
items: &[BatchItem],
|
items: &[BatchItem],
|
||||||
is_processing: bool,
|
is_processing: bool,
|
||||||
|
mkvmerge_available: bool,
|
||||||
) -> Vec<BatchPanelEvent> {
|
) -> Vec<BatchPanelEvent> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
@@ -269,7 +274,7 @@ impl BatchPanel {
|
|||||||
self.form_open = true;
|
self.form_open = true;
|
||||||
}
|
}
|
||||||
} else {
|
} 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.
|
/// 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<BatchPanelEvent>) {
|
fn render_form(&mut self, ui: &mut egui::Ui, events: &mut Vec<BatchPanelEvent>, mkvmerge_available: bool) {
|
||||||
let is_edit = self.editing_index.is_some();
|
let is_edit = self.editing_index.is_some();
|
||||||
let title = if let Some(idx) = self.editing_index {
|
let title = if let Some(idx) = self.editing_index {
|
||||||
format!("Editar item #{}", idx + 1)
|
format!("Editar item #{}", idx + 1)
|
||||||
@@ -442,7 +447,7 @@ impl BatchPanel {
|
|||||||
.allocate_ui_with_layout(
|
.allocate_ui_with_layout(
|
||||||
egui::Vec2::new(col_w, 0.0),
|
egui::Vec2::new(col_w, 0.0),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| self.form_add_audio.ui(ui),
|
|ui| self.form_add_audio.ui(ui, mkvmerge_available),
|
||||||
)
|
)
|
||||||
.inner;
|
.inner;
|
||||||
|
|
||||||
@@ -450,15 +455,15 @@ impl BatchPanel {
|
|||||||
.allocate_ui_with_layout(
|
.allocate_ui_with_layout(
|
||||||
egui::Vec2::new(col_w, 0.0),
|
egui::Vec2::new(col_w, 0.0),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| self.form_add_subtitle.ui(ui),
|
|ui| self.form_add_subtitle.ui(ui, mkvmerge_available),
|
||||||
)
|
)
|
||||||
.inner;
|
.inner;
|
||||||
|
|
||||||
(ar, sr)
|
(ar, sr)
|
||||||
} else {
|
} else {
|
||||||
let ar = self.form_add_audio.ui(ui);
|
let ar = self.form_add_audio.ui(ui, mkvmerge_available);
|
||||||
ui.add_space(2.0);
|
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)
|
(ar, sr)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -469,6 +474,7 @@ impl BatchPanel {
|
|||||||
language: req.language,
|
language: req.language,
|
||||||
is_default: req.is_default,
|
is_default: req.is_default,
|
||||||
title: req.title,
|
title: req.title,
|
||||||
|
drift_scale: req.drift_scale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(req) = subtitle_req {
|
if let Some(req) = subtitle_req {
|
||||||
@@ -478,6 +484,7 @@ impl BatchPanel {
|
|||||||
language: req.language,
|
language: req.language,
|
||||||
is_default: req.is_default,
|
is_default: req.is_default,
|
||||||
title: req.title,
|
title: req.title,
|
||||||
|
drift_scale: req.drift_scale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +528,7 @@ impl BatchPanel {
|
|||||||
t.language,
|
t.language,
|
||||||
t.is_default,
|
t.is_default,
|
||||||
t.title,
|
t.title,
|
||||||
|
t.drift_scale,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for s in subs {
|
for s in subs {
|
||||||
@@ -531,6 +539,7 @@ impl BatchPanel {
|
|||||||
s.language,
|
s.language,
|
||||||
s.is_default,
|
s.is_default,
|
||||||
s.title,
|
s.title,
|
||||||
|
s.drift_scale,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(idx) = self.editing_index {
|
if let Some(idx) = self.editing_index {
|
||||||
|
|||||||
@@ -7,19 +7,26 @@ use std::collections::HashMap;
|
|||||||
/// Evento emitido por interações com a lista de faixas existentes.
|
/// Evento emitido por interações com a lista de faixas existentes.
|
||||||
pub enum ExistingTrackEvent {
|
pub enum ExistingTrackEvent {
|
||||||
OffsetChanged(TrackId, SyncOffset),
|
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`.
|
/// O usuário solicitou exportar a faixa identificada por `TrackId`.
|
||||||
ExportRequested(TrackId),
|
ExportRequested(TrackId),
|
||||||
|
/// O usuário alternou o estado de exclusão da faixa (não será incluída no arquivo de saída).
|
||||||
|
ExcludeToggled(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 {
|
pub struct ExistingTrackList {
|
||||||
offset_fields: HashMap<u32, SyncOffsetField>,
|
offset_fields: HashMap<u32, SyncOffsetField>,
|
||||||
|
/// Campos de drift raw por track id (percentual digitado pelo usuário).
|
||||||
|
drift_fields: HashMap<u32, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExistingTrackList {
|
impl ExistingTrackList {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
ExistingTrackList {
|
ExistingTrackList {
|
||||||
offset_fields: HashMap::new(),
|
offset_fields: HashMap::new(),
|
||||||
|
drift_fields: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,14 +36,20 @@ impl ExistingTrackList {
|
|||||||
self.offset_fields
|
self.offset_fields
|
||||||
.entry(track.id.val())
|
.entry(track.id.val())
|
||||||
.or_insert_with(|| SyncOffsetField::new(track.offset));
|
.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(
|
pub fn ui(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
tracks: &[MediaTrackInfo],
|
tracks: &[MediaTrackInfo],
|
||||||
|
mkvmerge_available: bool,
|
||||||
) -> Vec<ExistingTrackEvent> {
|
) -> Vec<ExistingTrackEvent> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
@@ -48,18 +61,30 @@ impl ExistingTrackList {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duração do vídeo base para sugestão de velocidade
|
||||||
|
let video_duration_ms = tracks.iter()
|
||||||
|
.find(|t| matches!(t.kind, TrackKind::Video))
|
||||||
|
.and_then(|t| t.duration_ms);
|
||||||
|
|
||||||
|
let num_cols = if mkvmerge_available { 8 } else { 7 };
|
||||||
egui::Grid::new("existing_tracks_grid")
|
egui::Grid::new("existing_tracks_grid")
|
||||||
.num_columns(5)
|
.num_columns(num_cols)
|
||||||
.striped(true)
|
.striped(true)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.strong("#");
|
ui.strong("#");
|
||||||
ui.strong("Tipo");
|
ui.strong("Tipo");
|
||||||
ui.strong("Codec");
|
ui.strong("Codec");
|
||||||
|
ui.strong("Duração");
|
||||||
ui.strong("Atraso");
|
ui.strong("Atraso");
|
||||||
ui.strong("");
|
if mkvmerge_available {
|
||||||
|
ui.strong("Velocidade (%)");
|
||||||
|
}
|
||||||
|
ui.strong(""); // exportar
|
||||||
|
ui.strong(""); // excluir
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
||||||
for track in tracks {
|
for track in tracks {
|
||||||
|
let editable = matches!(track.kind, TrackKind::Audio | TrackKind::Subtitle) && !track.excluded;
|
||||||
let kind_label = match track.kind {
|
let kind_label = match track.kind {
|
||||||
TrackKind::Video => "Vídeo",
|
TrackKind::Video => "Vídeo",
|
||||||
TrackKind::Audio => "Áudio",
|
TrackKind::Audio => "Áudio",
|
||||||
@@ -72,20 +97,109 @@ impl ExistingTrackList {
|
|||||||
.map(|l| format!(" ({})", l))
|
.map(|l| format!(" ({})", l))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
ui.label(format!("{}", track.stream_index));
|
// Cor de texto: esmaecida quando a faixa está excluída do output
|
||||||
ui.label(format!("{}{}", kind_label, lang));
|
let dim_color = egui::Color32::from_gray(120);
|
||||||
ui.label(&track.codec);
|
let cell_text = |s: String| -> egui::RichText {
|
||||||
|
let t = egui::RichText::new(s);
|
||||||
|
if track.excluded { t.color(dim_color).strikethrough() } else { t }
|
||||||
|
};
|
||||||
|
|
||||||
// Offset field
|
ui.label(cell_text(track.stream_index.to_string()));
|
||||||
|
ui.label(cell_text(format!("{}{}", kind_label, lang)));
|
||||||
|
ui.label(cell_text(track.codec.clone()));
|
||||||
|
|
||||||
|
// Duração
|
||||||
|
match track.duration_ms {
|
||||||
|
Some(ms) => ui.label(cell_text(format_duration(ms))),
|
||||||
|
None => ui.label(cell_text("-".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Offset field (desabilitado quando a faixa está excluída)
|
||||||
let field = self
|
let field = self
|
||||||
.offset_fields
|
.offset_fields
|
||||||
.entry(track.id.val())
|
.entry(track.id.val())
|
||||||
.or_insert_with(|| SyncOffsetField::new(track.offset));
|
.or_insert_with(|| SyncOffsetField::new(track.offset));
|
||||||
|
|
||||||
field.ui(ui, "");
|
ui.add_enabled_ui(!track.excluded, |ui| {
|
||||||
if let Some(offset) = field.parse() {
|
field.ui(ui, "");
|
||||||
if offset != track.offset {
|
});
|
||||||
events.push(ExistingTrackEvent::OffsetChanged(track.id, offset));
|
if !track.excluded {
|
||||||
|
if let Some(offset) = field.parse() {
|
||||||
|
if offset != track.offset {
|
||||||
|
events.push(ExistingTrackEvent::OffsetChanged(track.id, offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drift field (somente para áudio e legenda quando mkvmerge disponível)
|
||||||
|
if mkvmerge_available {
|
||||||
|
if editable {
|
||||||
|
let suggestion_pct = video_duration_ms
|
||||||
|
.zip(track.duration_ms)
|
||||||
|
.filter(|(_, td)| *td > 0)
|
||||||
|
.map(|(vd, td)| vd as f64 / td as f64 * 100.0);
|
||||||
|
|
||||||
|
let drift_raw = self
|
||||||
|
.drift_fields
|
||||||
|
.entry(track.id.val())
|
||||||
|
.or_insert_with(|| format!("{:.5}", track.drift_scale * 100.0));
|
||||||
|
|
||||||
|
let mut text_changed = false;
|
||||||
|
let mut suggestion_applied: Option<f64> = None;
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let resp = ui.add(
|
||||||
|
egui::TextEdit::singleline(drift_raw).desired_width(72.0),
|
||||||
|
);
|
||||||
|
text_changed = resp.changed();
|
||||||
|
|
||||||
|
if let Some(pct) = suggestion_pct {
|
||||||
|
let warn = (pct - 100.0).abs() > 0.5;
|
||||||
|
let hover = format!(
|
||||||
|
"Sugerido: {:.3}%{}",
|
||||||
|
pct,
|
||||||
|
if warn {
|
||||||
|
"\n⚠ Diferença >0.5% — verifique se é o mesmo conteúdo"
|
||||||
|
} else {
|
||||||
|
" — clique para aplicar"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let color = if warn {
|
||||||
|
egui::Color32::from_rgb(255, 160, 0)
|
||||||
|
} else {
|
||||||
|
egui::Color32::from_rgb(80, 180, 80)
|
||||||
|
};
|
||||||
|
if ui
|
||||||
|
.add(egui::Button::new(
|
||||||
|
egui::RichText::new("💡").color(color).small(),
|
||||||
|
))
|
||||||
|
.on_hover_text(hover)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
suggestion_applied = Some(pct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(pct) = suggestion_applied {
|
||||||
|
*drift_raw = format!("{:.3}", pct);
|
||||||
|
events.push(ExistingTrackEvent::DriftChanged(
|
||||||
|
track.id, pct / 100.0,
|
||||||
|
));
|
||||||
|
} else if text_changed {
|
||||||
|
if let Ok(pct) = drift_raw.trim().parse::<f64>() {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +215,28 @@ impl ExistingTrackList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Botão de excluir (somente Áudio e Legenda)
|
||||||
|
match track.kind {
|
||||||
|
TrackKind::Audio | TrackKind::Subtitle => {
|
||||||
|
let label = if track.excluded {
|
||||||
|
egui::RichText::new("↺ Restaurar").color(egui::Color32::from_rgb(255, 160, 0))
|
||||||
|
} else {
|
||||||
|
egui::RichText::new("✖ Excluir").color(egui::Color32::from_rgb(210, 70, 70))
|
||||||
|
};
|
||||||
|
let tooltip = if track.excluded {
|
||||||
|
"Restaurar: faixa será incluída no arquivo de saída"
|
||||||
|
} else {
|
||||||
|
"Excluir: faixa não será incluída no arquivo de saída"
|
||||||
|
};
|
||||||
|
if ui.add(egui::Button::new(label).small()).on_hover_text(tooltip).clicked() {
|
||||||
|
events.push(ExistingTrackEvent::ExcludeToggled(track.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
ui.label(""); // célula vazia para vídeo/dados
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -109,3 +245,17 @@ impl ExistingTrackList {
|
|||||||
events
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ use crate::domain::value_objects::SyncOffset;
|
|||||||
|
|
||||||
/// Campo de entrada para offset de sincronização em segundos (ex: "-1.2").
|
/// Campo de entrada para offset de sincronização em segundos (ex: "-1.2").
|
||||||
/// Converte para SyncOffset(ms) internamente ao confirmar.
|
/// 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 struct SyncOffsetField {
|
||||||
pub raw: String,
|
pub raw: String,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
/// Percentual de velocidade original (ex: "99.983" → scale 0.99983). Padrão: "100.00".
|
||||||
|
pub drift_raw: String,
|
||||||
|
drift_error: Option<String>,
|
||||||
|
/// Velocidade sugerida calculada externamente (% inteiro). Exibida como botão 💡.
|
||||||
|
suggestion_pct: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncOffsetField {
|
impl SyncOffsetField {
|
||||||
@@ -14,10 +20,19 @@ impl SyncOffsetField {
|
|||||||
SyncOffsetField {
|
SyncOffsetField {
|
||||||
raw: format!("{:.1}", seconds),
|
raw: format!("{:.1}", seconds),
|
||||||
error: None,
|
error: None,
|
||||||
|
drift_raw: "100.00".to_string(),
|
||||||
|
drift_error: None,
|
||||||
|
suggestion_pct: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renderiza o campo e retorna true se o valor mudou.
|
/// Define a velocidade sugerida (%) calculada a partir das durações do vídeo e da faixa.
|
||||||
|
/// `None` remove o botão de sugestão.
|
||||||
|
pub fn set_suggestion(&mut self, pct: Option<f64>) {
|
||||||
|
self.suggestion_pct = pct;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderiza o campo de offset (sem o campo de drift).
|
||||||
pub fn ui(&mut self, ui: &mut egui::Ui, label: &str) -> bool {
|
pub fn ui(&mut self, ui: &mut egui::Ui, label: &str) -> bool {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -35,6 +50,71 @@ impl SyncOffsetField {
|
|||||||
changed
|
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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Botão de sugestão calculada a partir das durações
|
||||||
|
if mkvmerge_available {
|
||||||
|
if let Some(pct) = self.suggestion_pct {
|
||||||
|
let warn = (pct - 100.0).abs() > 0.5;
|
||||||
|
let hover = format!(
|
||||||
|
"Velocidade sugerida: {:.3}%{}",
|
||||||
|
pct,
|
||||||
|
if warn {
|
||||||
|
"\n⚠ Diferença >0.5% — verifique se é o mesmo conteúdo"
|
||||||
|
} else {
|
||||||
|
" — clique para aplicar"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let color = if warn {
|
||||||
|
egui::Color32::from_rgb(255, 160, 0)
|
||||||
|
} else {
|
||||||
|
egui::Color32::from_rgb(80, 180, 80)
|
||||||
|
};
|
||||||
|
if ui
|
||||||
|
.add(egui::Button::new(
|
||||||
|
egui::RichText::new("💡").color(color).small(),
|
||||||
|
))
|
||||||
|
.on_hover_text(hover)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.drift_raw = format!("{:.3}", pct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(pct) = self.drift_raw.trim().parse::<f64>() {
|
||||||
|
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.
|
/// Tenta converter o valor atual para SyncOffset.
|
||||||
pub fn parse(&mut self) -> Option<SyncOffset> {
|
pub fn parse(&mut self) -> Option<SyncOffset> {
|
||||||
match SyncOffset::from_seconds_str(&self.raw) {
|
match SyncOffset::from_seconds_str(&self.raw) {
|
||||||
@@ -48,4 +128,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::<f64>() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user