You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI文本覆盖层与导出视频显示不一致问题排查

问题描述

我在竖屏、固定尺寸的SwiftUI视图(覆盖于比例可变的视频视图之上)中,实现了可拖拽缩放的Text组件。SwiftUI视图代码如下:

struct EditPostLyricsTextView: View {
    @EnvironmentObject private var viewModel: EditPostView.ViewModel
    
    var body: some View {
        TitleView(
            text: viewModel.currentLyricsText,
            font: UIFont(name: viewModel.lyricsMetadata.fontName, size: 17)!,
            fontSize: 25 * viewModel.lyricsMetadata.fontScale,
            alignment: viewModel.lyricsMetadata.alignment,
            textColor: viewModel.lyricsMetadata.color
        )
        .padding(50)
        .frame(maxWidth: .infinity)
        .padding(.horizontal, 20)
        .overlay {
            Color.responsiveClear
        }
        .position(viewModel.lyricsMetadata.coordinates)
        .gesture(
            MagnificationGesture()
                .onChanged { value in
                    let delta = value / viewModel.lyricsMetadata.fontScale
                    let newScale = viewModel.lyricsMetadata.fontScale * delta
                    
                    viewModel.lyricsMetadata.fontScale = min(2, max(1, newScale))
                }.simultaneously(with: DragGesture()
                    .onChanged { value in
                        viewModel.lyricsMetadata.coordinates = value.location
                    }).simultaneously(with: TapGesture()
                        .onEnded(viewModel.presentEditLyricsView))
        )
        .clipped()
    }
}

我需要导出带有该文本覆盖层的视频,确保在所有视频分辨率下文本的位置和大小都与SwiftUI视图一致。视频编辑相关代码如下:

struct TextSegment {
    let text: String
    let startTime: Double
    let endTime: Double
    let originalCanvasSize: CGSize
    let padding: CGFloat
    var coordinates: CGPoint
    let color: UIColor
    let fontName: String
    let fontSize: CGFloat
    let alignment: NSTextAlignment
}

struct VideoEditorService {
    func addTextOverlaysToVideo(videoURL: URL, textSegments: [TextSegment], completion: @escaping (URL?) -> Void) {
        let videoAsset = AVURLAsset(url: videoURL)
        let composition = AVMutableComposition()
        
        let timeRange = CMTimeRange(start: .zero, duration: videoAsset.duration)
        
        guard let videoTrack = videoAsset.tracks(withMediaType: .video).first,
              let compositionTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
            completion(nil)
            return
        }
        
        do {
            if let audioAssetTrack = videoAsset.tracks(withMediaType: .audio).first,
               let compositionAudioTrack = composition.addMutableTrack(
                withMediaType: .audio,
                preferredTrackID: kCMPersistentTrackID_Invalid) {
                try compositionAudioTrack.insertTimeRange(
                    timeRange,
                    of: audioAssetTrack,
                    at: .zero)
            }
            
            try compositionTrack.insertTimeRange(timeRange, of: videoTrack, at: .zero)
            let videoComposition = AVMutableVideoComposition(propertiesOf: videoAsset)
            
            let overlayLayer = CALayer()
            overlayLayer.frame = CGRect(origin: .zero, size: videoComposition.renderSize)
            
            textSegments.forEach { segment in
                addText(
                    textSegment: segment,
                    to: overlayLayer,
                    videoSize: videoComposition.renderSize
                )
            }
            
            // Apply the overlay layer with animations
            let parentLayer = CALayer()
            
            parentLayer.frame = CGRect(x: 0, y: 0, width: videoComposition.renderSize.width, height: videoComposition.renderSize.height)
            parentLayer.addSublayer(overlayLayer)
            
            videoComposition.animationTool = AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayer: overlayLayer, in: parentLayer)
            
            // Export the video
            exportVideo(
                originalVideoURL: videoURL,
                composition: composition,
                videoComposition: videoComposition,
                completion: completion
            )
        } catch {
            print("Failed to add text overlay: \(error)")
            completion(nil)
        }
    }
    
    private func addText(textSegment: TextSegment, to layer: CALayer, videoSize: CGSize) {
        let originalWidth = textSegment.originalCanvasSize.width - textSegment.padding
        let maxWidth = (originalWidth * videoSize.width) / textSegment.originalCanvasSize.width
        
        let fontSize = min((videoSize.width * textSegment.fontSize) / textSegment.originalCanvasSize.width, (videoSize.height * textSegment.fontSize) / textSegment.originalCanvasSize.height)
        let standardRatio = (textSegment.originalCanvasSize.width / textSegment.fontSize) * (textSegment.originalCanvasSize.height / textSegment.fontSize)
        
        let attributedText = NSAttributedString(
            string: textSegment.text,
            attributes: [
                .font: UIFont(name: textSegment.fontName, size: fontSize) ?? .defaultHeavy(size: fontSize),
                .foregroundColor: textSegment.color,
            ]
        )
        
        let textLayer = CATextLayer()
    
            textLayer.string = attributedText
    //        textLayer.backgroundColor = UIColor.red.cgColor
            textLayer.alignmentMode = textSegment.alignment == .center ? .center : textSegment.alignment == .left ? .left : .right
            textLayer.isWrapped = true
            textLayer.truncationMode = .end
            textLayer.contentsScale = UIScreen.main.scale
            textLayer.shouldRasterize = true
            textLayer.rasterizationScale = UIScreen.main.scale
            textLayer.opacity = 0
            
            let textSize = attributedText.boundingRect(with: CGSize(width: maxWidth, height: .infinity),
                                                       options: [.usesLineFragmentOrigin, .usesFontLeading],
                                                       context: nil).size
            let height = ceil(textSize.height)
            print("maxWidth", maxWidth)
            print("textSize", ceil(textSize.height))
            print("textSize", textSegment.coordinates)
            print("textSize", videoSize)
            
            let normalisedX = ((videoSize.width * textSegment.coordinates.x) / textSegment.originalCanvasSize.width) - (maxWidth / 2)
            let normalisedY = (videoSize.height - (videoSize.height * textSegment.coordinates.y) / textSegment.originalCanvasSize.height) - (height / 2)
            
            textLayer.frame = CGRect(
                x: normalisedX,
                y: normalisedY,
                width: maxWidth,
                height: height
            )
            
            textLayer.displayIfNeeded()
            
            let startVisible = CABasicAnimation(keyPath: "opacity")
            
            startVisible.fromValue = 0.0
            startVisible.toValue = 1.0
            startVisible.duration = 0.05 // for appearing in duration
            startVisible.beginTime = AVCoreAnimationBeginTimeAtZero + textSegment.startTime
            startVisible.isRemovedOnCompletion = false
            startVisible.fillMode = CAMediaTimingFillMode.forwards
            textLayer.add(startVisible, forKey: "startAnimation")
            
            let endVisible = CABasicAnimation(keyPath: "opacity")
            endVisible.fromValue = 1.0
            endVisible.toValue = 0.0
            
            endVisible.duration = 0.05 // for disappearing in duration
            endVisible.beginTime = AVCoreAnimationBeginTimeAtZero + textSegment.endTime
            endVisible.fillMode = CAMediaTimingFillMode.forwards
            endVisible.isRemovedOnCompletion = false
            textLayer.add(endVisible, forKey: "endAnimation")
            
            layer.addSublayer(textLayer)
        }
    }

我已对字体大小、坐标等做了比例适配,部分场景下导出视频的文本显示与SwiftUI视图一致,但有时会出现文本多出一行的情况,请问我哪里处理有误?

问题分析与解决

核心问题点

1. 文本可用宽度计算不匹配

SwiftUI视图中,TitleView先后设置了.padding(50)和.padding(.horizontal, 20),实际文本的可用宽度是原始画布宽度减去左右各50+20的内边距,但导出代码中仅用originalCanvasSize.width - textSegment.padding计算原始宽度,导致导出时文本可用宽度比SwiftUI中更宽,触发额外换行。

2. 文本排版逻辑不一致

SwiftUI的Text和CATextLayer的渲染规则存在差异:

  • 两者默认行高、字符间距不同;
  • CATextLayer的isWrapped换行规则和SwiftUI自动换行逻辑不完全对齐;
  • 用boundingRect计算文本尺寸时,未同步CATextLayer的换行和截断规则。

3. 坐标与尺寸的像素对齐问题

boundingRect返回的尺寸是浮点值,仅对高度做ceil处理但宽度存在精度误差,导致CATextLayer实际可容纳宽度和计算值有偏差,触发额外换行。同时Y轴坐标转换逻辑错误,可能将文本推到边缘间接影响换行。

修复步骤

1. 统一文本可用宽度计算

修改导出代码中originalWidth的计算,完全还原SwiftUI中的内边距:

// 替换原originalWidth计算
let totalHorizontalPadding = 50*2 + 20*2 // 对应SwiftUI的padding(50) + padding(.horizontal,20)
let originalWidth = textSegment.originalCanvasSize.width - totalHorizontalPadding
let maxWidth = (originalWidth * videoSize.width) / textSegment.originalCanvasSize.width

如果TextSegment的padding参数用来传递总内边距,需在生成TextSegment时传入正确的总内边距值。

2. 对齐文本排版规则

给NSAttributedString添加段落样式,匹配SwiftUI的行高和换行规则:

// 创建段落样式,对齐SwiftUI的排版
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = textSegment.alignment
paragraphStyle.lineBreakMode = .byWordWrapping // 和SwiftUI自动换行逻辑一致
paragraphStyle.lineHeightMultiple = 1.2 // 根据SwiftUI默认行高调整,通常为1.1~1.3

// 更新attributedText的属性
let attributedText = NSAttributedString(
    string: textSegment.text,
    attributes: [
        .font: UIFont(name: textSegment.fontName, size: fontSize) ?? .defaultHeavy(size: fontSize),
        .foregroundColor: textSegment.color,
        .paragraphStyle: paragraphStyle
    ]
)

同时确保CATextLayer的换行模式和段落样式一致:

textLayer.truncationMode = .none // 不需要截断时和SwiftUI保持一致;需要截断则保留.end

3. 修正文本尺寸计算与像素对齐

计算textSize时使用和CATextLayer完全一致的参数,对宽度也做像素对齐:

let textSize = attributedText.boundingRect(
    with: CGSize(width: maxWidth, height: .infinity),
    options: [.usesLineFragmentOrigin, .usesFontLeading, .usesDeviceMetrics], // 提升计算精度
    context: nil
).size
let width = ceil(textSize.width)
let height = ceil(textSize.height)

// 更新textLayer的frame
textLayer.frame = CGRect(
    x: normalisedX,
    y: normalisedY,
    width: width, // 用对齐后的宽度
    height: height
)

4. 修正坐标转换逻辑

SwiftUI和CALayer的Y轴都是向下递增的,不需要反转Y轴,正确的中心点转换:

// 替换原normalisedY计算
let normalisedX = (videoSize.width * textSegment.coordinates.x / textSegment.originalCanvasSize.width) - (width / 2)
let normalisedY = (videoSize.height * textSegment.coordinates.y / textSegment.originalCanvasSize.height) - (height / 2)

内容的提问来源于stack exchange,提问作者Sotiris Kaniras

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.29 07:44:52