浅谈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 简要工作流程为:

  1. 获取字体列表
  2. 获取字幕文件
  3. 解析字幕文件(时间轴、FontStyle、FontText 等)
  4. 根据视频时间轴与 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 匹配工作,而是采用简单粗暴的做法:

  1. 如果当前字符能被第一个字体渲染,则用它渲染当前字符,不然就用列表中下一个字体渲染;
  2. 如果整个列表字体都不包含该字符,则尝试使用 default 字体渲染该字符(注意,这里和通用版前端插件资源组织形式不同,后文会讲);
  3. 如果 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)

所以,事情并没有直接替换那么简单,以下是容易想到的三种做法:

  1. 自行替换并重新打包 Jellyfin 定制版 JavascriptSubtitlesOctopus;
  2. Fallback 目录直接指向 System Font;
  3. 提供一个万金油字体放在 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 较多,且无法完美回退旧版本。

梓喵出没博客(azimiao.com)版权所有,转载请注明链接:https://www.azimiao.com/9657.html
欢迎加入梓喵出没博客交流群:313732000

发表评论

*

*