中文字幕烧进视频后整片空白:CoreText 低报行高,导出照样成功

我们在 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,真机跑一轮又要五分钟左右)。判据只有像素:

  1. 在该有字幕的时间点抽帧,数字幕区域里的非底色像素;
  2. 在没有字幕的空档也抽一帧,数同一块区域;
  3. 前者要显著多于后者,否则判失败、非零退出。

另外两种边界单独做成回归用例:字幕铺满整条片子,以及纯中文、不含空格的长句。

如果你也在做「把文字画进视频」这类功能,可以对照这几条:

  • 测量文本尺寸和绘制文本,用同一个排版器;
  • CoreText 框不够高时是一行都不画,不是裁一半;
  • 离散关键帧动画注意 keyTimes 比 values 多一个,也注意只剩一个值的情况;
  • 导出结果要按像素验,导出成功说明不了画面上有东西。

本文部分内容包含 AI 辅助创作

相关推荐
二流小码农1 小时前
HarmonyOS开发助手,0代码让元服务从开发到上架
android·ios·harmonyos
zhongben123_5 小时前
从0到1开发一款生鲜配送APP:核心技术难点与架构避坑指南
大数据·运维·人工智能·ios·架构·web app
zhongben_keji6 小时前
批发商城APP怎么做:价格引擎、授信台账与资金合规的设计要点
ios·小程序·web app
茶底世界之下8 小时前
夜景加胶片颗粒,先看天空:Awaking 两版真实导出
ios
2501_915106329 小时前
iOS 单元测试怎么跑?用 KXApp 在 VS Code 里跑 XCTest 和 Swift Testing
ide·vscode·ios·单元测试·个人开发·swift·敏捷流程
2501_915921439 小时前
iOS 实时日志怎么看?KeyMob 的日志过滤、崩溃报告与符号化
android·ios·小程序·https·uni-app·iphone·webview
天蒙蒙亮9 小时前
打ios app包+“应用商城”发布
ios
东坡肘子10 小时前
当应用不再属于特定操作系统 -- 肘子的 Swift 周报 #159
人工智能·swiftui·swift
VicLi1 天前
把常用 iOS 调试操作放进一个 macOS 控制台: iCATBox
ios