feat: adiciona suporte para definir título e faixa padrão em faixas de áudio e legenda

This commit is contained in:
2026-03-01 01:55:22 -03:00
parent c7031cbdce
commit 1f163ca0fc
15 changed files with 294 additions and 16 deletions
+98
View File
@@ -91,6 +91,18 @@ impl FfmpegCommandBuilder {
ext_idx += 1;
}
// ── Opções de muxing ─────────────────────────────────────────────────────
// -max_interleave_delta 0: remove o limite do buffer de intercalação (padrão: 10s).
// Sem isso, quando o áudio externo fica à frente do vídeo, o FFmpeg descarta
// pacotes silenciosamente, gerando trechos mudos no output. Com 0, nenhum
// pacote é descartado por excesso de delta — apenas memória RAM é consumida.
// -avoid_negative_ts make_zero: normaliza timestamps negativos comuns em M4A/AAC
// de serviços de streaming (DASH), que causam dessincronização no muxer MKV.
args.push("-max_interleave_delta".to_string());
args.push("0".to_string());
args.push("-avoid_negative_ts".to_string());
args.push("make_zero".to_string());
// ── -c copy (SEMPRE — invariante central do produto) ────────────────────
args.push("-c".to_string());
args.push("copy".to_string());
@@ -115,16 +127,96 @@ impl FfmpegCommandBuilder {
Track::Audio(t) => {
args.push(format!("-metadata:s:a:{}", ext_audio_idx));
args.push(format!("language={}", t.language));
// Sempre emite title (mesmo vazio) para sobrescrever qualquer título
// herdado do arquivo fonte (ex: "ISO Media file produced by Google Inc.").
// Players como Jellyfin mobile usam o title para identificar a faixa.
args.push(format!("-metadata:s:a:{}", ext_audio_idx));
args.push(format!("title={}", t.title));
ext_audio_idx += 1;
}
Track::Subtitle(t) => {
args.push(format!("-metadata:s:s:{}", ext_sub_idx));
args.push(format!("language={}", t.language));
args.push(format!("-metadata:s:s:{}", ext_sub_idx));
args.push(format!("title={}", t.title));
ext_sub_idx += 1;
}
}
}
// ── Disposições (faixa padrão) ────────────────────────────────────────
// Quando o usuário marca uma faixa externa como "padrão", é necessário:
// 1. Remover o flag `default` de todas as faixas existentes do mesmo tipo
// (o arquivo fonte geralmente já tem `disposition:default=1` na sua
// primeira faixa de áudio, e Jellyfin/Kodi respeitam esse flag).
// 2. Emitir `-disposition:a/s:{idx} default` para a faixa externa marcada.
//
// Índices de disposição seguem a ordem de streams do tipo no output:
// - Faixas existentes (existing_tracks) vêm primeiro, na ordem do map.
// - Faixas externas (tracks) vêm depois, na ordem de adição.
let existing_audio_count_disp = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Audio))
.count();
let existing_sub_count_disp = project
.existing_tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Subtitle))
.count();
let any_ext_audio_default = project
.tracks
.iter()
.any(|t| matches!(t, Track::Audio(a) if a.is_default));
let any_ext_sub_default = project
.tracks
.iter()
.any(|t| matches!(t, Track::Subtitle(s) if s.is_default));
// Remove default das faixas existentes quando uma faixa externa é padrão
if any_ext_audio_default {
for i in 0..existing_audio_count_disp {
args.push(format!("-disposition:a:{}", i));
args.push("0".to_string());
}
}
if any_ext_sub_default {
for i in 0..existing_sub_count_disp {
args.push(format!("-disposition:s:{}", i));
args.push("0".to_string());
}
}
// Emite `default` para as faixas externas marcadas
let mut disp_audio_idx = 0usize;
let mut disp_sub_idx = 0usize;
for track in &project.tracks {
match track {
Track::Audio(a) => {
if a.is_default {
args.push(format!(
"-disposition:a:{}",
existing_audio_count_disp + disp_audio_idx
));
args.push("default".to_string());
}
disp_audio_idx += 1;
}
Track::Subtitle(s) => {
if s.is_default {
args.push(format!(
"-disposition:s:{}",
existing_sub_count_disp + disp_sub_idx
));
args.push("default".to_string());
}
disp_sub_idx += 1;
}
}
}
// ── Output ──────────────────────────────────────────────────────────────
args.push(project.output.path.to_string_lossy().to_string());
@@ -216,6 +308,8 @@ mod tests {
FilePath::from("audio.aac"),
SyncOffset::from_ms(1200),
TrackLanguage::new("por").unwrap(),
false,
String::new(),
)
.unwrap();
@@ -233,6 +327,8 @@ mod tests {
FilePath::from("audio.aac"),
SyncOffset::default(),
TrackLanguage::new("por").unwrap(),
false,
String::new(),
)
.unwrap();
@@ -252,6 +348,8 @@ mod tests {
FilePath::from("audio.aac"),
SyncOffset::default(),
TrackLanguage::new("por").unwrap(),
false,
String::new(),
)
.unwrap();
+6 -2
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use crate::domain::entities::{AudioTrack, Project, Track};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use anyhow::Result;
/// Adiciona uma faixa de áudio externa ao projeto.
pub struct AddAudioTrack;
@@ -11,9 +11,11 @@ impl AddAudioTrack {
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
) -> Result<()> {
let id = project.next_track_id();
let track = AudioTrack::new(id, path, offset, language);
let track = AudioTrack::new(id, path, offset, language, is_default, title);
project.tracks.push(Track::Audio(track));
Ok(())
}
@@ -39,6 +41,8 @@ mod tests {
FilePath::from("audio_pt.aac"),
SyncOffset::default(),
lang,
false,
String::new(),
)
.unwrap();
+6 -2
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use crate::domain::entities::{Project, SubtitleTrack, Track};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use anyhow::Result;
/// Adiciona uma faixa de legenda externa ao projeto.
pub struct AddSubtitle;
@@ -11,9 +11,11 @@ impl AddSubtitle {
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
) -> Result<()> {
let id = project.next_track_id();
let track = SubtitleTrack::new(id, path, offset, language);
let track = SubtitleTrack::new(id, path, offset, language, is_default, title);
project.tracks.push(Track::Subtitle(track));
Ok(())
}
@@ -38,6 +40,8 @@ mod tests {
FilePath::from("sub_en.srt"),
SyncOffset::default(),
lang,
false,
String::new(),
)
.unwrap();
+2
View File
@@ -37,6 +37,8 @@ mod tests {
FilePath::from("audio.aac"),
SyncOffset::default(),
lang,
false,
String::new(),
)
.unwrap();
let id = project.tracks[0].id();
@@ -49,6 +49,8 @@ mod tests {
FilePath::from("audio_pt.aac"),
SyncOffset::from_ms(1200),
TrackLanguage::new("por").unwrap(),
false,
String::new(),
)
.unwrap();
@@ -38,6 +38,8 @@ mod tests {
FilePath::from("audio.aac"),
SyncOffset::from_ms(0),
TrackLanguage::new("por").unwrap(),
false,
String::new(),
)));
assert_eq!(project.tracks.len(), 1);
@@ -36,6 +36,8 @@ mod tests {
FilePath::from("audio.aac"),
SyncOffset::default(),
TrackLanguage::new("por").unwrap(),
false,
String::new(),
)
.unwrap();
let id = project.tracks[0].id();
+24 -3
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
use serde::{Deserialize, Serialize};
/// Faixa de áudio externa adicionada pelo usuário.
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -8,10 +8,31 @@ pub struct AudioTrack {
pub path: FilePath,
pub offset: SyncOffset,
pub language: TrackLanguage,
/// Quando verdadeiro, emite `-disposition:a:{idx} default` no FFmpeg,
/// tornando esta faixa a padrão para players como Jellyfin.
pub is_default: bool,
/// Título exibido na lista de faixas do player (ex: "Português", "Comentários").
/// String vazia emite `title=` para apagar o título herdado do arquivo fonte
/// (ex: "ISO Media file produced by Google Inc.").
pub title: String,
}
impl AudioTrack {
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self {
AudioTrack { id, path, offset, language }
pub fn new(
id: TrackId,
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
) -> Self {
AudioTrack {
id,
path,
offset,
language,
is_default,
title,
}
}
}
+22 -3
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::domain::value_objects::{FilePath, SyncOffset, TrackId, TrackLanguage};
use serde::{Deserialize, Serialize};
/// Faixa de legenda externa adicionada pelo usuário.
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -8,10 +8,29 @@ pub struct SubtitleTrack {
pub path: FilePath,
pub offset: SyncOffset,
pub language: TrackLanguage,
/// Quando verdadeiro, emite `-disposition:s:{idx} default` no FFmpeg.
pub is_default: bool,
/// Título exibido na lista de faixas do player (ex: "Português", "Forçada").
/// String vazia emite `title=` para apagar o título herdado do arquivo fonte.
pub title: String,
}
impl SubtitleTrack {
pub fn new(id: TrackId, path: FilePath, offset: SyncOffset, language: TrackLanguage) -> Self {
SubtitleTrack { id, path, offset, language }
pub fn new(
id: TrackId,
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
) -> Self {
SubtitleTrack {
id,
path,
offset,
language,
is_default,
title,
}
}
}
+2 -2
View File
@@ -719,11 +719,11 @@ impl eframe::App for App {
if let Some(req) = audio_req {
let _ =
AddAudioTrack::execute(project, req.path, req.offset, req.language);
AddAudioTrack::execute(project, req.path, req.offset, req.language, req.is_default, req.title);
}
if let Some(req) = subtitle_req {
let _ =
AddSubtitle::execute(project, req.path, req.offset, req.language);
AddSubtitle::execute(project, req.path, req.offset, req.language, req.is_default, req.title);
}
ui.add_space(8.0);
+32 -2
View File
@@ -1,13 +1,15 @@
use eframe::egui;
use crate::adapters::filesystem::FilePickerAdapter;
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui;
/// Evento emitido ao confirmar adição de faixa de áudio.
pub struct AddAudioTrackRequest {
pub path: FilePath,
pub offset: SyncOffset,
pub language: TrackLanguage,
pub is_default: bool,
pub title: String,
}
/// Formulário para adicionar uma faixa de áudio externa.
@@ -15,6 +17,8 @@ pub struct AddAudioTrackForm {
selected_path: Option<FilePath>,
offset_field: SyncOffsetField,
language_field: LanguageField,
is_default: bool,
title_input: String,
}
impl AddAudioTrackForm {
@@ -23,6 +27,8 @@ impl AddAudioTrackForm {
selected_path: None,
offset_field: SyncOffsetField::new(SyncOffset::default()),
language_field: LanguageField::new(None),
is_default: false,
title_input: String::new(),
}
}
@@ -48,6 +54,22 @@ impl AddAudioTrackForm {
self.offset_field.ui(ui, "Atraso:");
self.language_field.ui(ui);
ui.horizontal(|ui| {
ui.label("Nome da faixa:");
ui.text_edit_singleline(&mut self.title_input)
.on_hover_text(
"Nome exibido no player (ex: \"Português\", \"Comentários\").\n\
Deixe vazio para remover o nome original do arquivo fonte.\n\
Recomendado para evitar nomes como \"ISO Media file produced by Google Inc.\"",
);
});
ui.checkbox(&mut self.is_default, "Definir como faixa padrão")
.on_hover_text(
"Marca esta faixa como padrão no container MKV.\n\
Recomendado para que players como Jellyfin selecionem este áudio automaticamente.",
);
ui.add_space(4.0);
let can_add = self.selected_path.is_some();
@@ -60,11 +82,19 @@ impl AddAudioTrackForm {
self.offset_field.parse(),
self.language_field.parse(),
) {
result = Some(AddAudioTrackRequest { path, offset, language });
result = Some(AddAudioTrackRequest {
path,
offset,
language,
is_default: self.is_default,
title: self.title_input.trim().to_string(),
});
// Reset form
self.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.language_field = LanguageField::new(None);
self.is_default = false;
self.title_input = String::new();
}
}
});
+31 -2
View File
@@ -1,13 +1,15 @@
use eframe::egui;
use crate::adapters::filesystem::FilePickerAdapter;
use crate::domain::value_objects::{FilePath, SyncOffset, TrackLanguage};
use crate::ui::components::{language_field::LanguageField, sync_offset_field::SyncOffsetField};
use eframe::egui;
/// Evento emitido ao confirmar adição de legenda.
pub struct AddSubtitleRequest {
pub path: FilePath,
pub offset: SyncOffset,
pub language: TrackLanguage,
pub is_default: bool,
pub title: String,
}
/// Formulário para adicionar uma faixa de legenda externa.
@@ -15,6 +17,8 @@ pub struct AddSubtitleForm {
selected_path: Option<FilePath>,
offset_field: SyncOffsetField,
language_field: LanguageField,
is_default: bool,
title_input: String,
}
impl AddSubtitleForm {
@@ -23,6 +27,8 @@ impl AddSubtitleForm {
selected_path: None,
offset_field: SyncOffsetField::new(SyncOffset::default()),
language_field: LanguageField::new(None),
is_default: false,
title_input: String::new(),
}
}
@@ -48,6 +54,21 @@ impl AddSubtitleForm {
self.offset_field.ui(ui, "Atraso:");
self.language_field.ui(ui);
ui.horizontal(|ui| {
ui.label("Nome da faixa:");
ui.text_edit_singleline(&mut self.title_input)
.on_hover_text(
"Nome exibido no player (ex: \"Português\", \"Forçada\").\n\
Deixe vazio para remover o nome original do arquivo fonte.",
);
});
ui.checkbox(&mut self.is_default, "Definir como faixa padrão")
.on_hover_text(
"Marca esta faixa como padrão no container MKV.\n\
Recomendado para que players como Jellyfin selecionem esta legenda automaticamente.",
);
ui.add_space(4.0);
let can_add = self.selected_path.is_some();
@@ -60,10 +81,18 @@ impl AddSubtitleForm {
self.offset_field.parse(),
self.language_field.parse(),
) {
result = Some(AddSubtitleRequest { path, offset, language });
result = Some(AddSubtitleRequest {
path,
offset,
language,
is_default: self.is_default,
title: self.title_input.trim().to_string(),
});
self.selected_path = None;
self.offset_field = SyncOffsetField::new(SyncOffset::default());
self.language_field = LanguageField::new(None);
self.is_default = false;
self.title_input = String::new();
}
}
});
+16
View File
@@ -49,12 +49,16 @@ struct PendingAudio {
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
}
struct PendingSubtitle {
path: FilePath,
offset: SyncOffset,
language: TrackLanguage,
is_default: bool,
title: String,
}
// ── BatchPanel ─────────────────────────────────────────────────────────────────
@@ -117,11 +121,15 @@ impl BatchPanel {
path: t.path.clone(),
offset: t.offset,
language: t.language.clone(),
is_default: t.is_default,
title: t.title.clone(),
}),
Track::Subtitle(t) => self.form_pending_subtitles.push(PendingSubtitle {
path: t.path.clone(),
offset: t.offset,
language: t.language.clone(),
is_default: t.is_default,
title: t.title.clone(),
}),
}
}
@@ -459,6 +467,8 @@ impl BatchPanel {
path: req.path,
offset: req.offset,
language: req.language,
is_default: req.is_default,
title: req.title,
});
}
if let Some(req) = subtitle_req {
@@ -466,6 +476,8 @@ impl BatchPanel {
path: req.path,
offset: req.offset,
language: req.language,
is_default: req.is_default,
title: req.title,
});
}
@@ -507,6 +519,8 @@ impl BatchPanel {
t.path,
t.offset,
t.language,
t.is_default,
t.title,
);
}
for s in subs {
@@ -515,6 +529,8 @@ impl BatchPanel {
s.path,
s.offset,
s.language,
s.is_default,
s.title,
);
}
if let Some(idx) = self.editing_index {