字幕一个字都不显示,而每一层都是正常的
轨道列得出来、sid 设得进去且回读一致、sub-visibility 是 yes、sub-text 里就是解好的台词——画面上什么都没有。两个洞叠在一起:自建的 libass 没编 fontconfig(鸿蒙上没有),所以 font-provider 只剩 none;而 mpv 剩下的唯一入口是配置目录下的 subfont.ttf,偏偏 mpv_create() 给 libmpv 默认设了 config=no。修法是一条符号链接加两个显式选项,以及为什么刻意不用 --sub-fonts-dir。
Vela Player 是鸿蒙上的私人影视库,双引擎播放:系统 AVPlayer 优先,它解不了的格式切 libmpv 整机软解。软解这条路跑通之后,冒出一个症状:
选了字幕,画面上什么都没有。
而且每一层看起来都是对的:
- 轨道枚举列得出来,字幕轨在里面
sid设得进去,回读一致sub-visibility是yessub-text属性里明明白白是解好的台词
也就是说,mpv 确实在解字幕,解出来的文本也拿得到。只是它没有被画到画面上。没有报错,没有告警,没有任何一个返回码是非零的。
日志级别拉到 verbose 才现形
这种「每层都正常」的失效模式,没有别的办法,只能把引擎自己的日志翻出来。真机上把 mpv 的 [sub/ass] 日志放到 verbose:
[sub/ass] Setting up fonts...
[sub/ass] can't find selected font provider
[sub/ass] fontselect: failed to find any fallback with glyph 0x0 for font: (sans-serif, 400, 0)
第二行是根因,第三行是它的直接后果:libass 找不到任何 font provider,于是对任何字形都拿不出字体,连 fallback 都没有。它有文字,但它没有笔。
两个洞叠在一起
追下去发现不是一个问题,是两个,而且单修任何一个都不管用。
洞一:自建的 libass 没有 fontconfig。
libass 通过 font provider 找字体,在 Linux 上默认是 fontconfig。鸿蒙上没有 fontconfig,交叉编译 libass 时自然也就没编进去。于是可用的 provider 只剩 none。
none 不是「用系统默认」的意思,是字面意义的没有——libass 不会自己去系统目录里翻字体。这一点和很多库的行为不同:大多数库找不到配置会退到某个内置默认值,libass 在这里退到的是空。
洞二:mpv 唯一剩下的入口也是关着的。
没有 fontconfig 时,mpv 还留了一条路:配置目录下的 subfont.ttf。把字体文件放在那个位置,libass 就用它。
但 libmpv 不是 mpv。mpv_create() 默认设置 config=no —— libmpv 作为嵌入库不读用户配置文件,这是它的既定行为,文档里也写着。config 关着,配置目录这个概念就不存在,subfont.ttf 这条路同样是断的。
两个洞的合成效果是:libass 有零个找字体的途径,而整条链路上没有一个环节认为这是错误。
修法
common/MpvFonts.ets 做两件事:在沙箱里备一个 subfont.ttf,然后把 config 显式打开。
const dir = `${context.filesDir}/mpv`;
if (!fs.accessSync(dir)) {
fs.mkdirSync(dir, true);
}
const link = `${dir}/subfont.ttf`;
字体从系统里找,按覆盖面排优先级:
private static readonly CANDIDATES: string[] = [
'/system/fonts/HarmonyOS_Sans_SC.ttf',
'/system/fonts/NotoSansCJK-Regular.ttc',
'/system/fonts/HYQiHeiL3.ttf',
'/system/fonts/NotoSans[wdth,wght].ttf'
];
前三个都覆盖中日韩加拉丁,最后一个只在极端裁剪的系统上兜住外语字幕。
然后把 config 和 config-dir 显式传给 mpv。这一步不能省——备好了文件但不开 config,等于没备。
为什么是符号链接,不是拷贝
await fs.symlink(src, dest);
系统中文字体是 20MB 级的。拷一份进沙箱既占空间,又要考虑系统升级换了字体之后这份拷贝失效。
链接交给 libass 的是路径,FreeType 按路径流式打开,只读需要的字形表。
这里有个细节:fs.accessSync 跟随符号链接。所以「链接还在,但目标没了」(系统升级换掉了字体文件)会被判为不存在,自动走重建。这个行为正好是想要的——如果 accessSync 只检查链接本身存在,升级后就会留下一条指向虚空的链接,而症状又回到「字幕不显示」。
为什么刻意不用 --sub-fonts-dir
mpv 有个看起来更直接的选项:--sub-fonts-dir,指一个字体目录。直觉上应该指向 /system/fonts 就完事了。
不能这么干。libass 的 load_fonts_from_dir 是 fopen 然后把目录里每个文件整个读进内存。指向 /system/fonts 会一次吞掉一百多 MB——一个播放器为了显示字幕先吃掉一百多兆常驻内存,这个代价在手机上不可接受。
符号链接那条路只暴露一个文件,而且是按需读的。所以最终选的是看起来绕一点的那条。
.ttc 扩展名的小坑
候选里第二个是 NotoSansCJK-Regular.ttc——.ttc 是字体集合(TrueType Collection),不是单个字体。而链接名固定叫 subfont.ttf。
这不冲突:FreeType 按文件内容判断格式,不看扩展名。 一个 .ttc 链接成 .ttf 的名字,它照样能正确解析出集合里的第一个字体。mpv 那条路径要求的文件名是 subfont.ttf,照它的要求给就行。
兜底与留痕
try {
await fs.symlink(src, dest);
return;
} catch (e) {
hilog.warn(..., 'MpvFonts: symlink 失败(code=%{public}d),改为拷贝', ...);
}
fs.copyFileSync(src, dest);
沙箱允许建一条指向系统只读分区的符号链接,这是预期行为,但这条依赖没有白纸黑字的保证。字幕能不能显示不该押在一条没有文档承诺的行为上,所以链接失败就退回拷贝。拷 20MB 是难看的兜底,总好过整条字幕链路再一次静默失效。
而 ensure() 整体失败时:
} catch (e) {
/*
* 这里静默的代价正是本文件要修的那个 bug——字幕悄无声息地不显示。
* 备不出字体不该拦住播放,但必须留痕。
*/
hilog.error(..., 'MpvFonts: 备字体失败 code=%{public}d msg=%{public}s', ...);
return '';
}
备不出字体不拦播放——没字幕的片子照样能看。但必须留一条错误日志。这个 bug 之所以查起来费劲,就是因为整条链路上没有任何一处开口说话。修它的时候顺手把这张嘴装上。
顺带做完的字幕一圈
字体这条通了之后,字幕的其他几件事一起做掉了,其中两个坑值得记。
副字幕(双语并排) 走 mpv 的 secondary-sid,画在画面顶部,与主字幕天然错开。候选列表要剔掉两类:位图轨(PGS/VobSub——设进去不报错也不显示)和当前主字幕那一条(mpv 直接拒绝)。这两种情况用户看到的都是「点了没反应」,所以宁可不给这个选项,也不要给一个点了没用的选项。这个功能只在 mpv 引擎下存在:AVPlayer 一次只吐一条轨的文本,第二条没有来源。
字幕画到黑边上 这件事牵出一串连锁问题。原来 XComponent 是按视频宽高比排的,黑边是 ArkUI 的背景、不在 mpv 的画布里(osd-dimensions 的 mt/mb/ml/mr 全是 0),所以 sub-pos 无论怎么调都出不去画面。改成软解下把整个舞台交给 mpv、由它自己保比填黑,sub-pos 超过 100 才落得到黑边上。
改完立刻出了两个连锁问题:
- PiP 画面被拉伸。小窗是按视频宽高比开的,surface 一旦变成舞台比例就对不上。所以
pipActive期间退回「surface 恰好等于画面」的老口径。 - 字幕变得超级大。mpv 的
sub-font-size单位是「画布高 720 时的像素数」,画布高了近一倍,字就大了一倍。原先写死的 ×2.2 系数是照着老画布调出来的。改成按画布高度反算,屏上尺寸只由subtitleSize(vp)决定,画布一变(旋转 / 折叠 / 进出小窗)就重算。
第二条是典型的魔数债:一个系数在当时的画布尺寸下调得刚好,它就把那个尺寸偷偷变成了隐含前提。画布一改,系数失效,而失效方式是「字大了一倍」——看着像样式问题,根因在坐标系。
学到什么
「每一层都正常」是最贵的失效模式。 这个 bug 里每一个我能查的状态都是对的:轨道在、sid 对、visibility 开、文本有。所有断言都通过,所以自动化测试也会全绿。能戳破它的只有引擎自己的 verbose 日志——在集成第三方引擎时,先搞清楚怎么把它的内部日志捞出来,这件事的优先级高于集成本身。
默认值是隐形依赖。 mpv_create() 设 config=no 是文档里写着的,也是合理的库行为。但它把「配置目录」整个概念抽走了,而依赖那个概念的功能(subfont.ttf)不会因此报错,只会静默失效。每次用嵌入式库替代它的独立版本,都要问一遍:独立版本的哪些默认行为,在库形态下被关掉了。
裁剪掉一个依赖,要跟着检查它的所有下游。 没编 fontconfig 是交叉编译时一句「OHOS 上没有,跳过」的决定,当时完全正确。但 libass 的字体查找全部建立在 provider 之上,provider 空了之后它的兜底行为是空而不是默认值。裁依赖的那一刻,该问的是「谁在用它、少了它之后退化成什么」。
留言