如何在录制保存的视频中添加动态计时器?
问题:计时器标签无法被录制进视频
当前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("未知麦克风权限状态") } } }
关键修改说明
- 替换输出组件:用
AVCaptureVideoDataOutput和AVCaptureAudioDataOutput替代原有的AVCaptureMovieFileOutput,这样可以获取每一帧的原始数据进行处理。 - 添加视频编码:使用
AVAssetWriter和AVAssetWriterInput将处理后的帧编码为视频文件。 - UI元素渲染:在
captureOutput回调中,将计时器标签的图层渲染为图片,再合成到视频帧上,确保标签被嵌入到录制的视频中。 - 音频会话调整:将音频会话设置为
.playAndRecord,确保录制时同时捕获音频。
内容的提问来源于stack exchange,提问作者Syed Kashan
相关产品推荐
相关产品推荐

