The export finished. The file played and the size looked right. There was not a single caption on screen.
That's what my iOS caption-burning app did the day I tried to make Chinese App Store screenshots. Every Chinese caption came out blank. English was fine, which is why nobody had noticed. No API returned an error, and CGContext.makeImage() happily returned a non-nil image. It was fully transparent.
It was two separate bugs with the same symptom, and both only show up if you look at pixels.
How the captions get into the video
Captions are drawn with CoreText into a CGImage, put on a CALayer, and composited over the video with AVVideoCompositionCoreAnimationTool. Each caption layer starts at opacity = 0 and gets a discrete keyframe animation that switches it on and off at the segment's start and end times. Both steps can fail silently.
Bug 1: CoreText measured Chinese text too short
To size the bitmap, the old code asked CoreText how tall the text would be:
let size = CTFramesetterSuggestFrameSizeWithConstraints(
framesetter, CFRangeMake(0, 0), nil,
CGSize(width: width, height: .greatestFiniteMagnitude), &fitRange)
return ceil(size.height) + 2
For a 57.6pt Chinese caption (no spaces anywhere), it suggested 59 points. Actually laying out the same string needed 71. We built a CTFrame with a 61pt path, and CTFrameGetLines returned zero lines. Zero lines means nothing gets drawn, and every API involved treats the transparent result as a successful render.
We'd been bitten by this class of bug before. boundingRect once measured 68.0 where CoreText wanted 68.4, and half a point was enough to drop the only line. Our takeaway then was "use the CoreText API". It should have been "measure with the exact code path you draw with".
The current version lays the text out for real inside a path too tall to constrain anything, then sums the actual line metrics:
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)
for line in CTFrameGetLines(frame) as? [CTLine] ?? [] {
var ascent: CGFloat = 0, descent: CGFloat = 0, leading: CGFloat = 0
CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
total += ascent + descent + leading
}
I didn't trust that alone. A second step builds the frame at the measured height and checks that CTFrameGetVisibleStringRange(frame).length covers the whole string. If not, it adds height and retries, up to five times. It's cheap, and it's the only code in the pipeline that can see this failure.
Bug 2: a caption covering the whole clip never appears
This one showed up on a 4-second screenshot clip with one caption running from 0 to the end. Our timing code turned that into keyTimes = [0, 1], values = [1]. That's a legal shape for a discrete animation (one more key time than values), but a one-value discrete animation changes nothing, and the layer sat at opacity 0 for the whole export.
Same sentence, measured both ways: spanning the whole clip, 0 caption pixels. Moved to 0.5s through 3.5s, 14,100.
The fix is boring on purpose. When the track has one value, skip the animation:
if track.values.count == 1 {
return track.values[0] > 0 ? .always : .never
}
and .always just sets container.opacity = 1.
Counting pixels instead of watching videos
Neither bug throws or logs. A test asserting "export returned a URL" passes on both. Watching output by hand doesn't scale across 26 caption presets.
The simulator was no help either. CoreAnimation offline rendering crashes there in IOSurfaceCreate, and a round trip on a real device took about five minutes. So we added a thin shim (PlatformFont, PlatformColor) that lets the production ExportService.swift compile unchanged on macOS, and wrote a command-line harness, Tests/LocalHarness/burnin-main.swift, that links the real source files rather than a copy.
The harness:
- Synthesizes a solid-color 6-second source video with a 90° rotation transform.
- Runs the real
ExportService.renderBurnInon it. - Grabs frames at exact timestamps with
AVAssetImageGenerator, zero tolerance on both sides. - Samples the background color near the top of the frame and counts pixels in the caption band that differ from it.
When a caption should be visible, it expects more than 500 foreign pixels. In a gap, exactly zero, which also catches captions lingering past their end time.
Both bugs are now permanent sections. Section 5 renders three space-free Chinese strings, one long enough to wrap. Section 6 renders a caption from 0 to 6 seconds and checks frames at 0.5s, 3.0s and 5.5s. Any ❌ exits non-zero.
What this costs
"Pixels that aren't background" tells you something was drawn. It can't tell you it's the right text, font or position. We have a few narrower checks (color-specific pixel counts for custom styles, alpha on rounded corners), but the core test is deliberately dumb, and because the source is a solid color it won't notice problems that only appear over busy footage.
I'll take that. In a caption app, the failure I worry about most is a successful export with nothing in it, and this is the check that catches it.
I built this for CapScribe, an iOS app that transcribes on device with Apple's SpeechAnalyzer and burns captions into the video file. grep -rn URLSession Sources/ in the repo returns nothing. https://apps.apple.com/app/id6801041856
Top comments (0)