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

如何在录制保存的视频中添加动态计时器?

问题:计时器标签无法被录制进视频

当前Swift代码中,计时器标签作为UIViewController的子视图仅在预览层显示,但无法被AVCaptureMovieFileOutput录制到最终保存的视频文件中,尝试将标签添加到帧的方法无效。


解决方案

要将UI元素(如计时器标签)嵌入到录制的视频中,不能依赖AVCaptureMovieFileOutput的默认录制流程,需要改用AVCaptureVideoDataOutput捕获每一帧视频数据,实时将UI元素渲染到帧上后再编码输出。以下是修改后的完整代码:

import UIKit
import AVFoundation
import Photos

class ViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate {
    let captureSession = AVCaptureSession()
    var videoDataOutput: AVCaptureVideoDataOutput!
    var audioDataOutput: AVCaptureAudioDataOutput!
    var previewLayer: AVCaptureVideoPreviewLayer!
    var timer: Timer?
    var startTime: TimeInterval = 0
    var isRecording = false
    var recordButton: UIButton!
    var timerLabel: UILabel!
    let player = AVQueuePlayer()
    
    // 视频编码相关
    var assetWriter: AVAssetWriter!
    var videoWriterInput: AVAssetWriterInput!
    var audioWriterInput: AVAssetWriterInput!
    var outputURL: URL!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        checkCameraAuthorization()
        checkPhotoLibraryAuthorization()
        checkMicrophoneAuthorization()
        
        setupCamera()
        setupUI()
        
        // 设置音频会话
        do {
            try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default)
            try AVAudioSession.sharedInstance().setActive(true)
        } catch {
            print("音频会话设置错误: \(error.localizedDescription)")
        }
    }
    
    func setupCamera() {
        captureSession.sessionPreset = .high
        
        // 添加视频输入
        guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
              let videoInput = try? AVCaptureDeviceInput(device: camera) else { return }
        captureSession.addInput(videoInput)
        
        // 添加音频输入
        guard let microphone = AVCaptureDevice.default(for: .audio),
              let audioInput = try? AVCaptureDeviceInput(device: microphone) else {
            print("音频输入创建失败")
            return
        }
        captureSession.addInput(audioInput)
        
        // 设置视频数据输出
        videoDataOutput = AVCaptureVideoDataOutput()
        videoDataOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
        if captureSession.canAddOutput(videoDataOutput) {
            captureSession.addOutput(videoDataOutput)
        }
        
        // 设置音频数据输出
        audioDataOutput = AVCaptureAudioDataOutput()
        audioDataOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "audioQueue"))
        if captureSession.canAddOutput(audioDataOutput) {
            captureSession.addOutput(audioDataOutput)
        }
        
        // 配置预览层
        previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        previewLayer.videoGravity = .resizeAspectFill
        previewLayer.frame = view.bounds
        view.layer.addSublayer(previewLayer)
        
        // 启动会话
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            self?.captureSession.startRunning()
        }
    }
    
    func setupUI() {
        // 录制按钮
        recordButton = UIButton(type: .custom)
        recordButton.frame = CGRect(x: (view.bounds.width - 60) / 2, y: view.bounds.height - 100, width: 60, height: 60)
        recordButton.backgroundColor = .white
        recordButton.layer.cornerRadius = 30
        recordButton.addTarget(self, action: #selector(toggleRecording), for: .touchUpInside)
        view.addSubview(recordButton)
        
        // 计时器标签
        timerLabel = UILabel()
        timerLabel.frame = CGRect(x: 0, y: 100, width: view.bounds.width, height: 40)
        timerLabel.textAlignment = .center
        timerLabel.font = UIFont.boldSystemFont(ofSize: 30)
        timerLabel.textColor = .white
        timerLabel.text = "00:00.000"
        view.addSubview(timerLabel)
    }
    
    @objc func toggleRecording() {
        if !isRecording {
            startRecording()
        } else {
            stopRecording()
        }
        isRecording = !isRecording
    }
    
    func startRecording() {
        // 创建输出文件URL
        let timestamp = Date().timeIntervalSince1970
        let filename = "video_\(Int(timestamp)).mov"
        outputURL = URL(fileURLWithPath: NSTemporaryDirectory() + filename)
        
        // 初始化AssetWriter
        do {
            assetWriter = try AVAssetWriter(outputURL: outputURL, fileType: .mov)
            
            // 配置视频输入
            let videoSettings: [String: Any] = [
                AVVideoCodecKey: AVVideoCodecType.h264,
                AVVideoWidthKey: view.bounds.width,
                AVVideoHeightKey: view.bounds.height
            ]
            videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
            videoWriterInput.expectsMediaDataInRealTime = true
            if assetWriter.canAdd(videoWriterInput) {
                assetWriter.add(videoWriterInput)
            }
            
            // 配置音频输入
            let audioSettings: [String: Any] = [
                AVFormatIDKey: kAudioFormatMPEG4AAC,
                AVNumberOfChannelsKey: 1,
                AVSampleRateKey: 44100,
                AVEncoderBitRateKey: 128000
            ]
            audioWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
            audioWriterInput.expectsMediaDataInRealTime = true
            if assetWriter.canAdd(audioWriterInput) {
                assetWriter.add(audioWriterInput)
            }
            
            // 启动写入
            assetWriter.startWriting()
            assetWriter.startSession(atSourceTime: CMClockGetTime(CMClockGetHostTimeClock()))
        } catch {
            print("AssetWriter初始化失败: \(error.localizedDescription)")
            return
        }
        
        // 播放提示音
        playCustomSound()
        
        // 启动计时器
        startTime = Date().timeIntervalSince1970
        timer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(updateElapsedTime), userInfo: nil, repeats: true)
        
        // 更新按钮样式
        recordButton.backgroundColor = .red
    }
    
    func stopRecording() {
        // 停止写入
        videoWriterInput.markAsFinished()
        audioWriterInput.markAsFinished()
        assetWriter.finishWriting { [weak self] in
            guard let self = self else { return }
            // 保存到相册
            PHPhotoLibrary.shared().performChanges({
                PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: self.outputURL)
            }) { success, error in
                if let error = error {
                    print("保存视频失败: \(error.localizedDescription)")
                } else if success {
                    print("视频已保存到相册")
                }
            }
        }
        
        // 停止计时器
        timer?.invalidate()
        
        // 更新按钮样式
        recordButton.backgroundColor = .white
    }
    
    // 处理视频帧
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        guard isRecording, let videoWriterInput = videoWriterInput, videoWriterInput.isReadyForMoreMediaData else { return }
        
        // 将UI标签渲染到视频帧
        guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
        CVPixelBufferLockBaseAddress(imageBuffer, .readOnly)
        
        // 创建上下文
        let context = CIContext(options: [.useSoftwareRenderer: false])
        let ciImage = CIImage(cvImageBuffer: imageBuffer)
        
        // 渲染计时器标签到图片
        UIGraphicsBeginImageContextWithOptions(view.bounds.size, false, UIScreen.main.scale)
        timerLabel.layer.render(in: UIGraphicsGetCurrentContext()!)
        let overlayImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        
        guard let overlayCIImage = CIImage(image: overlayImage!) else {
            CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly)
            return
        }
        
        // 合成视频帧和标签
        let combinedImage = ciImage.composited(over: overlayCIImage.transformed(by: CGAffineTransform(scaleX: 1, y: -1)))
        
        // 将合成后的图片转换回像素缓冲区
        context.render(combinedImage, to: imageBuffer)
        CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly)
        
        // 将处理后的帧写入输出
        videoWriterInput.append(sampleBuffer)
    }
    
    // 处理音频帧
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        guard isRecording, let audioWriterInput = audioWriterInput, audioWriterInput.isReadyForMoreMediaData else { return }
        audioWriterInput.append(sampleBuffer)
    }
    
    @objc func updateElapsedTime() {
        let elapsedTime = Date().timeIntervalSince1970 - startTime
        
        let milliseconds = Int(elapsedTime * 1000) % 1000
        let seconds = Int(elapsedTime) % 60
        let minutes = (Int(elapsedTime) / 60) % 60
        
        let formattedTime = String(format: "%02d:%02d.%03d", minutes, seconds, milliseconds)
        timerLabel.text = formattedTime
    }
    
    func playCustomSound() {
        DispatchQueue.global(qos: .background).async { [weak self] in
            guard let url = Bundle.main.url(forResource: "startsound", withExtension: "m4a") else {
                print("提示音文件未找到")
                return
            }
            let item = AVPlayerItem(url: url)
            self?.player.removeAllItems()
            self?.player.insert(item, after: nil)
            self?.player.play()
        }
    }
    
    // 权限检查方法
    func checkPhotoLibraryAuthorization() {
        let status = PHPhotoLibrary.authorizationStatus()
        switch status {
        case .authorized:
            print("相册权限已授权")
        case .notDetermined:
            PHPhotoLibrary.requestAuthorization { status in
                print(status == .authorized ? "相册权限已授予" : "相册权限被拒绝")
            }
        case .denied, .restricted:
            print("相册权限被拒绝或受限")
        case .limited:
            print("相册权限受限")
        @unknown default:
            print("未知相册权限状态")
        }
    }
    
    func checkCameraAuthorization() {
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        switch status {
        case .authorized:
            print("相机权限已授权")
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { granted in
                print(granted ? "相机权限已授予" : "相机权限被拒绝")
            }
        case .denied, .restricted:
            print("相机权限被拒绝或受限")
        @unknown default:
            print("未知相机权限状态")
        }
    }
    
    func checkMicrophoneAuthorization() {
        let status = AVCaptureDevice.authorizationStatus(for: .audio)
        switch status {
        case .authorized:
            print("麦克风权限已授权")
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .audio) { granted in
                print(granted ? "麦克风权限已授予" : "麦克风权限被拒绝")
            }
        case .denied, .restricted:
            print("麦克风权限被拒绝或受限")
        @unknown default:
            print("未知麦克风权限状态")
        }
    }
}

关键修改说明

  1. 替换输出组件:用AVCaptureVideoDataOutput和AVCaptureAudioDataOutput替代原有的AVCaptureMovieFileOutput,这样可以获取每一帧的原始数据进行处理。
  2. 添加视频编码:使用AVAssetWriter和AVAssetWriterInput将处理后的帧编码为视频文件。
  3. UI元素渲染:在captureOutput回调中,将计时器标签的图层渲染为图片,再合成到视频帧上,确保标签被嵌入到录制的视频中。
  4. 音频会话调整:将音频会话设置为.playAndRecord,确保录制时同时捕获音频。

内容的提问来源于stack exchange,提问作者Syed Kashan

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.25 01:48:13