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
+15
View File
@@ -82,6 +82,20 @@ As ferramentas existentes são complexas (ex: interface direta do FFmpeg via CLI
- O usuário pode atribuir um código de idioma a cada faixa (ex: `por`, `eng`)
- Implementado via `ffmpeg -metadata:s`
### RF-10 — Definir nome da faixa
- O usuário pode preencher um título livre para cada faixa externa adicionada (ex: `Português`, `Comentários`)
- O campo aceita string vazia, que instrui o `FfmpegCommandBuilder` a emitir `title=` limpando qualquer título herdado do arquivo fonte
- Objetivo principal: sobrescrever títulos gerados automaticamente por ferramentas externas (ex: `"ISO Media file produced by Google Inc."` em arquivos M4A do Google) que confundem players como Jellyfin mobile
- Implementado via `ffmpeg -metadata:s:a:{idx} title=` ou `-metadata:s:s:{idx} title=`; sempre emitido, mesmo quando vazio
### RF-11 — Definir faixa padrão
- O usuário pode marcar uma faixa externa como “faixa padrão” do container MKV
- Quando marcada, o `FfmpegCommandBuilder` emite `-disposition:a/s:{idx} default` para a faixa selecionada e `-disposition:a/s:{orig_idx} 0` para todas as faixas existentes do mesmo tipo, removendo o flag `default=1` que o arquivo fonte carrega
- Objetivo: garantir que players como Jellyfin selecionem automaticamente a faixa preferida sem depender de configuração manual no side do player
- Implementado via `ffmpeg -disposition:a:{idx} default`
### RF-05 — Selecionar arquivo de vídeo base
- O usuário seleciona o arquivo de vídeo de entrada
@@ -319,6 +333,7 @@ src/
| DT-04 | Compatibilidade de codecs | Alguns codecs podem não ser aceitos | Usar MKV como container padrão |
| DT-05 | Performance de processo externo | Pequeno overhead | Aceitável — mux é rápido |
| DT-06 | ffprobe como dependência adicional | Parsing de JSON da saída do ffprobe | Validar presença de ffprobe na inicialização; exibir mensagem clara se ausente |
| DT-07 | Compatibilidade com Jellyfin mobile | Faixas não selecionadas automaticamente | Investigando; as RFC RF-10 e RF-11 mitigam parcialmente — `title` e `disposition` são emitidos corretamente, porém o Jellyfin mobile pode ter comportamento diferente do web; requer testes com arquivo gerado |
---
+34
View File
@@ -112,6 +112,39 @@ src/
---
## Inventário de Mudanças — Compatibilidade com Jellyfin (01/03/2026)
### Contexto
Arquivos M4A produzidos pelo Google (ex: downloads do YouTube Music ou Google Drive) carregam
um metadado `title` na stream de áudio com o valor `"ISO Media file produced by Google Inc."`. Quando
o FFmpeg faz o mux sem sobrescrever esse metadado, ele é copiado literalmente para a faixa do MKV.
O Jellyfin **web** é tolerante e usa a tag `language` para selecionar a faixa preferida. Já o
Jellyfin **mobile** usa o campo `title` da stream para exibir e selecionar faixas — quando não
reconhece o título como idioma, não realiza seleção automática e mantém a faixa original.
Algo semelhante ocorre com `disposition:default`: a faixa original do fonte fica com `default=1`;
a nova faixa fica com `default=0`; o Jellyfin mobile respeitam esse flag e tocam a original.
> **Status:** mudanças implementadas e compilando; investigação em andamento — o problema no
> Jellyfin mobile ainda não foi completamente resolvido.
### Mudanças por camada
| Arquivo | Mudança |
| --- | --- |
| `domain/entities/audio_track.rs` | Adicionado `is_default: bool` e `title: String` a `AudioTrack` |
| `domain/entities/subtitle_track.rs` | Adicionado `is_default: bool` e `title: String` a `SubtitleTrack` |
| `application/use_cases/add_audio_track.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
| `application/use_cases/add_subtitle.rs` | Parâmetros `is_default` e `title` adicionados ao `execute()` |
| `adapters/ffmpeg/command_builder.rs` | Emite `-disposition:a/s:{idx} 0` nas faixas existentes quando uma externa é marcada como padrão; emite `-disposition:a/s:{idx} default` na faixa marcada; emite `-metadata:s:a/s:{idx} title={valor}` para toda faixa externa (string vazia apaga o título herdado) |
| `ui/components/add_audio_track_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
| `ui/components/add_subtitle_form.rs` | Campo “Nome da faixa” (`TextEdit`) e checkbox “Definir como faixa padrão” |
| `ui/components/batch_panel.rs` | `PendingAudio` e `PendingSubtitle` propagam `is_default` e `title` |
---
## Testes Unitários (33/33 passando)
### Domain — Value Objects
@@ -200,6 +233,7 @@ rfd = "0.14"
- **`Project` rejeita `output == source`** — validado no construtor
- **Termos FFmpeg nunca aparecem na UI** — a interface usa linguagem do usuário final
- **Exceção documentada ao RNF-01** — `build_export()` omite `-c copy` exclusivamente para legendas (conversão de container de texto, sem processamento de mídia); comentário inline explica a exceção
- **`title=` sempre emitido** — `FfmpegCommandBuilder` emite `-metadata:s:a/s:{idx} title=` para toda faixa externa, garantindo que títulos herdados do arquivo fonte sejam sobrescritos; string vazia limpa o campo no MKV
---
+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 {