feat: adiciona suporte para definir título e faixa padrão em faixas de áudio e legenda
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user