我们在 iOS 上做一个把字幕烧进视频画面的导出功能:字幕先用 CoreText 画成位图,挂到 CALayer 上,再交给 AVFoundation 离线合成。8 月 13 日那天要出中文版商店截图,导出来的视频播放正常、时长正常、文件大小也正常,就是画面上一个字都没有。
没有报错,没有崩溃,导出回调里是成功。后来查出来是两个互不相关的原因,碰巧表现一模一样。这篇把两个都记下来,再说说之后怎么防。
第一个:CoreText 给的建议高度,中文排不下
位图要先知道多高。最初的写法是让 CoreText 自己建议一个尺寸,再加一点余量:
swift
let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString)
let suggested = CTFramesetterSuggestFrameSizeWithConstraints(
framesetter, CFRangeMake(0, 0), nil,
CGSize(width: width, height: .greatestFiniteMagnitude), nil
)
let height = ceil(suggested.height) + 2
问题出在不含空格的中文上。57.6pt 的「字幕直接写进画面里」,它建议 59 点高,而这段文字真排下来需要 71 点。按 59 加 2 点余量去建 CTFrame,结果是:
CTFrameGetLines返回 0 行;CTFrameGetVisibleStringRange的长度是 0;- 位图整张透明。
CoreText 在框不够高时不会把最后一行裁掉一半画出来,而是一行都不排。差几个点,就是从「有字」直接掉到「没字」。
换成 NSAttributedString.boundingRect 量也不行:57pt 时它给 68.0,CoreText 认为一行要 68.4。差半个点,照样一行都排不下。测量和绘制不是同一个排版器,两边对行高的算法有细微差别。
修法:拿绘制用的排版器真排一遍
既然最后画的时候走的是 CTFrame,测量也用 CTFrame。给一条高到不可能限制排版的路径,让换行只取决于宽度,然后把每一行的实际行高加起来:
swift
static func measuredHeight(_ text: NSAttributedString, width: CGFloat) -> CGFloat {
let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString)
let probePath = CGPath(
rect: CGRect(x: 0, y: 0, width: width, height: 100_000),
transform: nil
)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), probePath, nil)
let lines = CTFrameGetLines(frame) as? [CTLine] ?? []
var total: CGFloat = 0
for line in lines {
var ascent: CGFloat = 0, descent: CGFloat = 0, leading: CGFloat = 0
CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
total += ascent + descent + leading
}
// 一行都没排出来时不返回 0(0 高的位图同样是「成功但空白」),按字号估一行
if total <= 0 { total = pointSize * 1.4 } // pointSize 取自首字符的 CTFont,这里省略
return ceil(total) + 2
}
测量和绘制用同一个排版器、同一个宽度,结果按定义就对得上。
在这之上又加了一层兜底:真正建位图之前,用算出来的高度再建一次 CTFrame,看可见范围是不是覆盖了整段文字,不够就往上加,最多加五轮:
swift
for _ in 0..<5 {
let path = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: height), transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
if CTFrameGetVisibleStringRange(frame).length >= text.length { return height }
height += max(fontSize * 0.5, 4)
}
正常情况下第一轮就通过。留着它是因为「排不下」没有任何报错可依,只能自己验。
第二个:只有一个值的关键帧动画会被丢掉
第二个原因只在一种情况下出现:整段视频只有一条字幕,从第 0 秒一直到结尾。
每条字幕的显隐,我们用一条铺满整条时间线的离散关键帧动画来控制 opacity:自己的区间里是 1,其余是 0。没用「beginTime 设成段起点、duration 设成段时长」那种写法,是因为 AVFoundation 离线渲染里 beginTime 写 0 会被当成「现在」,要用 AVCoreAnimationBeginTimeAtZero;靠动画自然结束来隐藏,又依赖 removedOnCompletion 的时机,离线渲染下不可靠。
离散模式有个规矩:keyTimes 要比 values 多一个,末尾那个时间点负责收尾。数量对不上时 CoreAnimation 不报错,直接忽略整条动画,图层停在初始 opacity 0。这个坑第一版就踩过,所以生成轨迹的函数专门处理了收尾点:
swift
var keyTimes: [Double] = [0]
var values: [Double] = []
if start > 0 {
values.append(0)
keyTimes.append(start / total)
}
values.append(1)
if end < total {
keyTimes.append(end / total)
values.append(0)
}
keyTimes.append(1)
可字幕正好覆盖整条片子时,两个 if 都不进,得到的是 keyTimes = [0, 1]、values = [1]。数量关系合法,但只有一个值的离散动画不产生任何变化,图层还是停在 opacity 0。
当时实测:一句字幕铺满 4 秒的片子,字幕区域里非底色像素是 0;同一句改成 0.5 到 3.5 秒,是 14100。差别只在这一个分支。
修法是单值的情况不挂动画,直接设 opacity:
swift
if track.values.count == 1 {
return track.values[0] > 0 ? .always : .never // .always 时 layer.opacity = 1
}
怎么防:判据不能是「导出成功」
这两个 bug 有个共同点:编译通过,类型检查通过,导出回调成功,文件能播。靠任何一种「有没有报错」的检查都抓不到。
我们最后写了一个本机测试台,在 macOS 上直接编译生产代码跑整条烧录管线(模拟器上 CoreAnimation 离线渲染会崩在 IOSurfaceCreate,真机跑一轮又要五分钟左右)。判据只有像素:
- 在该有字幕的时间点抽帧,数字幕区域里的非底色像素;
- 在没有字幕的空档也抽一帧,数同一块区域;
- 前者要显著多于后者,否则判失败、非零退出。
另外两种边界单独做成回归用例:字幕铺满整条片子,以及纯中文、不含空格的长句。
如果你也在做「把文字画进视频」这类功能,可以对照这几条:
- 测量文本尺寸和绘制文本,用同一个排版器;
- CoreText 框不够高时是一行都不画,不是裁一半;
- 离散关键帧动画注意
keyTimes比values多一个,也注意只剩一个值的情况; - 导出结果要按像素验,导出成功说明不了画面上有东西。
本文部分内容包含 AI 辅助创作