浅谈Jellyfin字幕渲染错误前因后果及解决方法

在使用 Jellyfin 的过程中,曾经遇到了字幕渲染错误的问题。从源码入手,简单分析问题原因,并提供一个简单的解决方法。
前置知识
字幕可简单分为硬字幕、内置字幕、外置字幕。
习惯性叫法,仅代表梓喵出没博客(azimiao.com)范围内的个人观点。
- 硬字幕
压制时,将字幕的图像直接渲染在视频图像上。 - 内置字幕/内嵌字幕/内封字幕
在 MKV 等封装内,封装一个或多个字幕流,解码时根据指定的时间轴和样式信息渲染字幕。 - 外置字幕/外挂字幕
和内置字幕类似,但字幕是一个单独的字幕文件,没有封装到视频文件中。
Jellyfin 的字幕渲染
仅针对 Jellyfin Server + Jellyfin-Web 进行讨论。一般情况下,基于 WebView 的第三方客户端与之类似。
两种渲染模式
在 Jellyfin 中,字幕的渲染分为两种模式:
- 烧录字幕图像
- 前端渲染字幕
烧录字幕图像和硬字幕有些相似,处于这种模式下,Jellyfin 调用 ffmpeg 转码视频时,将字幕直接渲染到串流视频片段上。
前端渲染字幕就是由浏览器渲染字幕。服务端把字幕文本发过来,由前端插件在浏览器中生成字幕图像,Jellyfin 使用的是修改后的 JavascriptSubtitlesOctopus。
使用时机
当你打开视频时,Jellyfin 会调用 ffmpeg 持续生成 TS 串流切片;退出播放界面时,这些片段会被自动删除。
对于文本类型字幕(S_TEXT/ASS 等),Jellyfin 优先使用前端渲染,这样有一个好处:多字幕轨的视频,切换字幕时,无需重新生成 TS 切片,直接复用已经生成的、不带字幕图像的 TS 切片。
当字幕编码不被前端渲染插件所支持时(如 HDMV/PGS 类型的字幕),Jellyfin 会使用烧录字幕图像的模式。
烧录字幕图像模式下渲染字幕错误
Jellyfin 中需要烧录字幕图像的场合很少,最常见的就是碰到 HDMV/PGS 等图片类型的字幕。
一个逆天的例子,610M 的天气之子全特效字幕:
然而,HDMV/PGS 本就是基于图像的字幕,相当于保存了一堆图片。ffmpeg 处理时,overlay video filter 直接把图片贴上去,不需要重新读取字体生成字体贴图,因此鲜有乱码。
找到转码后的 TS,可以见到视频图像中已经混合了字幕图像:
对于内嵌字体的视频文件,Jellyfin 调用 ffmpeg 渲染时,会将字体解出到单独文件夹,并将该文件夹指定为 libass 字幕滤镜的 fontdir:
注意,配置针对 libass:FFmpeg does not come with any fonts. FFmpeg uses libass to burn subtitles, which in turn uses fontconfig to scan fonts available on system and use them.
//Jellyfin EncodingHelper.cs
var fontPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
var fontParam = string.Format(
CultureInfo.InvariantCulture,
":fontsdir='{0}'",
_mediaEncoder.EscapeSubtitleFilterPath(fontPath));
对于没有内嵌字体的视频文件,调用 ffmpeg 渲染时,libass 将自动使用 System Font 文件夹(如 Windows 下是 Windows/Fonts)。
所以,这种情况下基本不会发生渲染错误。
前端渲染模式下渲染字幕错误
Jellyfin 会将 TEXT/ASS 等基于文本的字幕发送到浏览器,使用 Jellyfin 修改版 JavascriptSubtitlesOctopus 渲染,在前端渲染的目的是节省 Server 端资源。
对于内封文本字幕的视频,Jellyfin 会将字幕轨解出成单独文件,发给浏览器。
Jellyfin 中 JavascriptSubtitlesOctopus 简要工作流程为:
- 获取字体列表
- 获取字幕文件
- 解析字幕文件(时间轴、FontStyle、FontText 等)
- 根据视频时间轴与 FontStyle 渲染字幕
可以通过浏览器 F12 控制台确认其资源获取逻辑,不再赘述。
在这种情况下,很多人会发现中文、日文等渲染错误,文案显示为一堆方块“□□□□□”。这种乱码问题发生在渲染字幕的步骤,原因是没有找到包含字形的字体文件。
作为前端插件,JavascriptSubtitlesOctopus 没办法读取客户端本地字体文件,而是向 Jellyfin 服务器要字体文件列表。于是,有两种不同的情况:
1.内嵌字体的视频
某些压制组压制视频时,会把字体文件也封装到视频文件中,如下图:
对于内嵌字体的视频,Jellyfin 会把字体从视频中解出,并发给 JavascriptSubtitlesOctopus 作为待使用字体。
# Get /Videos/[VideoUID]/[VideoUID]/Attachments/*
封装字体一般包含了所有字符,因此鲜有乱码,顶多出现字体顺序混乱的问题。
2.未内嵌字体的视频
对于无内嵌字体的视频,Jellyfin 返回 Fallback 字体文件夹下的字体列表,该字体列表生成规则:
- 按照文件体积小/名称靠前/修改时间最近/创建时间最近的优先级排序;
- 取排序列表的前几个,当列表中的字体总大小大于 20M 时,不再加入新的字体。
这段源码如下:
// 梓喵出没博客/azimiao.com fork from github/jellyfin/jellyfin
public IEnumerable<FontFile> GetFallbackFontList()
{
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
var fallbackFontPath = encodingOptions.FallbackFontPath;
if (!string.IsNullOrEmpty(fallbackFontPath))
{
var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false);
var fontFiles = files
.Select(i => new FontFile
{
Name = i.Name,
Size = i.Length,
DateCreated = _fileSystem.GetCreationTimeUtc(i),
DateModified = _fileSystem.GetLastWriteTimeUtc(i)
})
.OrderBy(i => i.Size)
.ThenBy(i => i.Name)
.ThenByDescending(i => i.DateModified)
.ThenByDescending(i => i.DateCreated);
// max total size 20M
const int MaxSize = 20971520;
var sizeCounter = 0L;
foreach (var fontFile in fontFiles)
{
sizeCounter += fontFile.Size;
if (sizeCounter >= MaxSize)
{
_logger.LogWarning("Some fonts will not be sent due to size limitations");
yield break;
}
yield return fontFile;
}
}
else
{
_logger.LogWarning("The path of fallback font folder has not been set");
encodingOptions.EnableFallbackFont = false;
}
}
获取字体列表后,定制版的 JavascriptSubtitlesOctopus 并没有做 FontName 匹配工作,而是采用简单粗暴的做法:
- 如果当前字符能被第一个字体渲染,则用它渲染当前字符,不然就用列表中下一个字体渲染;
- 如果整个列表字体都不包含该字符,则尝试使用 default 字体渲染该字符(注意,这里和通用版前端插件资源组织形式不同,后文会讲);
- 如果 default 字体仍不能渲染该字符,报错。
下图是一个例子,虽然 ASS 中使用相同的字体名标记中英文,但由于英文字体排序在前,因此英文渲染直接用了列表最前的英文字体,而中文字体使用含中文字符的字体渲染:
聪明的你可能想到了,既然 fallback 到 default 字体上,那是不是换掉 default 字体就好了?
和通用版的 JavascriptSubtitlesOctopus 插件不同,Jellyfin 定制版的 JavascriptSubtitlesOctopus 把 default 资源打包在了 wasm 里:
# 梓喵出没博客/azimiao.com fork from github/jellyfin/JavascriptSubtitlesOctopus
dist: src/subtitles-octopus-worker.bc dist/js/subtitles-octopus-worker.js dist/js/subtitles-octopus-worker-legacy.js dist/js/subtitles-octopus.js dist/js/COPYRIGHT dist/js/default.woff2
dist/js/subtitles-octopus-worker.js: src/subtitles-octopus-worker.bc src/pre-worker.js src/SubOctpInterface.js src/post-worker.js build/lib/brotli/js/decode.js
mkdir -p dist/js
emcc src/subtitles-octopus-worker.bc $(OCTP_DEPS) \
--pre-js src/pre-worker.js \
--pre-js build/lib/brotli/js/decode.js \
--post-js src/SubOctpInterface.js \
--post-js src/post-worker.js \
-s WASM=1 \
$(EMCC_COMMON_ARGS)
所以,事情并没有直接替换那么简单,以下是容易想到的三种做法:
- 自行替换并重新打包 Jellyfin 定制版 JavascriptSubtitlesOctopus;
- Fallback 目录直接指向 System Font;
- 提供一个万金油字体放在 Fallback 目录;
做法 1 治标又治本,但 Jellyfin 每次升级都会覆盖你的修改,属于是自己给自己找活干。
做法 2 不可行,在 Windows 系统上,如果按照上文排序规则,那字体列表都是英文字符(注意源码排序优先级)。
做法 3 比较容易实现,且不会被 Jellyfin 未来的更新所覆盖。
解决方法:用万金油字体作为渲染字体
我推荐使用开源的思源黑体作为万金油 Fallback 字体,思源黑体包含了大部分常用的简繁中文、韩文、日文、英文等外文字符,并提供 Variable font(可变字体,一个字体文件即可渲染不同粗细、斜体等多样式字符)。
github: adobe-fonts/source-han-sans
思源黑体是一套 OpenType/CFF 泛中日韩字体。
这个开源项目不仅提供了可用的 OpenType 字体,
还提供了利用 AFDKO 工具创建这些 OpenType 字体时的所有源文件。
梓喵出没博客推荐使用的是SourceHanSansTC-VF
,其包含的字符和特性如上所述。
不要忘了在这里指定文件夹:
不指定文件夹的话,Jellyfin 不会返回 Fallback 字体列表(见上文源代码)。
其他问题
Q:我也找到了 610M 的天气之子全特效字幕,为啥 Jellyfin 不显示?
A:目前稳定版 Jellyfin 不认外部 sup 文件,测试版已经支持,等待稳定版更新(遥遥无期)。
Q:你推荐我更新 Jellyfin 到测试版吗?
A:不推荐,Bug 较多,且无法完美回退旧版本。