iOS - 创建多个延迟预览实时摄像头视图

4

我进行了大量的研究,但仍未能找到一个可行的解决方案,原因如下。


问题

在我的iOS应用程序中,我想要三个视图无限期地显示设备相机的延迟实时预览。

例如,视图1将显示相机视图,延迟5秒,视图2将显示相同的相机视图,延迟20秒,而视图3将显示相同的相机视图,延迟30秒。

这将用于记录自己执行某种活动,如锻炼运动,然后在几秒钟后观看自己,以完善给定运动的形式。

尝试的解决方案

我尝试和研究了几种不同的解决方案,但都存在问题。

1. 使用AVFoundationAVCaptureMovieFileOutput

  • 使用AVCaptureSessionAVCaptureMovieFileOutput将短片段录制到设备存储器。需要短片段,因为您不能从URL播放视频,并同时写入该URL。
  • 有3个AVPlayerAVPlayerLayer实例,所有这些实例都在其所需的时间延迟下播放短录制的片段。
  • 问题:
    1. 使用AVPlayer.replaceCurrentItem(_:)切换剪辑时,剪辑之间有非常明显的延迟。这需要平稳的过渡。
    2. 虽然旧的评论在此处建议不要创建多个AVPlayer实例,但由于设备限制,我无法找到确认或否认此声明的信息。E:从Jake G的评论中- iPhone 5及更高版本可以使用10个AVPlayer实例。

2. 使用AVFoundationAVCaptureVideoDataOutput

使用AVCaptureSessionAVCaptureVideoDataOutput来流式传输并处理相机的每一帧,使用didOutputSampleBuffer委托方法。在OpenGL视图上绘制每个帧(例如GLKViewWithBounds)。这解决了来自“解决方案1”中多个AVPlayer实例的问题。
问题:将每个帧存储起来以便稍后显示需要大量的内存(这在iOS设备上不可行),或者磁盘空间。如果我想以每秒30帧的速度存储2分钟视频,那就是3600帧,如果直接从didOutputSampleBuffer复制,总计超过12GB。也许有一种方法可以将每个帧压缩x1000而不失去质量,这样我就可以在内存中保存这些数据。如果存在这样的方法,我还没有找到它。
可能的第三种解决方案:
如果有一种同时读写文件的方法,我认为以下解决方案将是理想的。
记录视频作为循环流。例如,对于一个2分钟的视频缓冲区,我将创建一个文件输出流,该流将写入两分钟的帧。一旦达到2分钟标记,流将从开头重新启动,覆盖原始帧。
通过不断运行此文件输出流,我将在同一记录的视频文件上拥有3个输入流。每个流都指向流中的不同帧(实际上是写入流之后的X秒)。然后,每个帧将显示在相应的输入流UIView上。
当然,这仍然存在存储空间问题。即使将帧存储为压缩的JPEG图像,对于较低质量的2分钟视频,我们需要多个GB的存储空间。
问题:
1. 有人知道实现我想要的有效方法吗? 2. 我如何解决我已经尝试的解决方案中的一些问题?

2
关于AVPlayer设备限制,在iPhone 5及更高版本上,您应该能够同时分配10个播放器(实际上是视频通道)而不会出现问题。 - Jake G
@cohenadair 你最终选择了什么? - denfromufa
2
@denfromufa,实际上我采用了三种解决方案的组合。最终我创建了一个循环文件存储缓冲区,将短剪辑按顺序使用OpenGL进行显示。结果效果非常好。如果你想看看最终的成果,这个应用程序在App Store上是免费的:https://apps.apple.com/us/app/xlr8-skill-system/id1353246743 - cohenadair
@cohenadair,很酷,看看下面使用新API的新答案:https://dev59.com/yanka4cB1Zd3GeqPKTag#66829118 - denfromufa
2个回答

6

自被采纳的答案以来,事情发生了变化。现在有一种替代分段的AVCaptureMovieFileOutput并且在iOS上创建新片段时不会丢帧的方法,这个替代方案就是AVAssetWriter

iOS 14起,AVAssetWriter可以创建分段的MPEG4,这些实质上是内存中的MPEG 4文件。虽然旨在用于HLS流应用程序,但它也是缓存视频和音频内容非常方便的方法。

这种新功能是由Takayuki Mizuno在WWDC 2020会议上介绍的使用AVAssetWriter创作分段MPEG-4内容

凭借分段MP4的AVAssetWriter,通过将mp4段写入磁盘,并使用多个AVQueuePlayerAVPlayerLayers以不同的时间偏移播放它们,很容易创建解决该问题的解决方案。

因此,这将成为第四种解决方案:使用AVAssetWritermpeg4AppleHLS输出配置文件捕获摄像头流并将其写入磁盘分段的mp4,并使用AVQueuePlayers和AVPlayerLayers以不同的延迟播放视频。

如果您需要支持iOS 13及以下版本,则必须替换分段的AVAssetWriter,这可能会很快变得技术性,特别是如果您还想编写音频。谢谢,Takayuki Mizuno!

import UIKit
import AVFoundation
import UniformTypeIdentifiers

class ViewController: UIViewController {
    let playbackDelays:[Int] = [5, 20, 30]
    let segmentDuration = CMTime(value: 2, timescale: 1)

    var assetWriter: AVAssetWriter!
    var videoInput: AVAssetWriterInput!
    var startTime: CMTime!

    var writerStarted = false
    
    let session = AVCaptureSession()
    
    var segment = 0
    var outputDir: URL!
    var initializationData = Data()
    
    var layers: [AVPlayerLayer] = []
    var players: [AVQueuePlayer] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        
        for _ in 0..<playbackDelays.count {
            let player = AVQueuePlayer()
            player.automaticallyWaitsToMinimizeStalling = false
            let layer = AVPlayerLayer(player: player)
            layer.videoGravity = .resizeAspectFill
            layers.append(layer)
            players.append(player)
            view.layer.addSublayer(layer)
        }
        
        outputDir = FileManager.default.urls(for: .documentDirectory, in:.userDomainMask).first!
    
        assetWriter = AVAssetWriter(contentType: UTType.mpeg4Movie)
        assetWriter.outputFileTypeProfile = .mpeg4AppleHLS // fragmented mp4 output!
        assetWriter.preferredOutputSegmentInterval = segmentDuration
        assetWriter.initialSegmentStartTime = .zero
        assetWriter.delegate = self
        
        let videoOutputSettings: [String : Any] = [
            AVVideoCodecKey: AVVideoCodecType.h264,
            AVVideoWidthKey: 1024,
            AVVideoHeightKey: 720
        ]
        videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoOutputSettings)
        videoInput.expectsMediaDataInRealTime = true

        assetWriter.add(videoInput)

        // capture session
        let videoDevice = AVCaptureDevice.default(for: .video)!
        let videoInput = try! AVCaptureDeviceInput(device: videoDevice)
        session.addInput(videoInput)
        
        let videoOutput = AVCaptureVideoDataOutput()
        videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
        session.addOutput(videoOutput)
        
        session.startRunning()
    }
    
    override func viewDidLayoutSubviews() {
        let size = view.bounds.size
        let layerWidth = size.width / CGFloat(layers.count)
        for i in 0..<layers.count {
            let layer = layers[i]
            layer.frame = CGRect(x: CGFloat(i)*layerWidth, y: 0, width: layerWidth, height: size.height)
        }
    }
    
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .landscape
    }
}

extension ViewController: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        
        if startTime == nil {
            let success = assetWriter.startWriting()
            assert(success)
            startTime = sampleBuffer.presentationTimeStamp
            assetWriter.startSession(atSourceTime: startTime)
        }
        
        if videoInput.isReadyForMoreMediaData {
            videoInput.append(sampleBuffer)
        }
    }
}

extension ViewController: AVAssetWriterDelegate {
    func assetWriter(_ writer: AVAssetWriter, didOutputSegmentData segmentData: Data, segmentType: AVAssetSegmentType) {
        print("segmentType: \(segmentType.rawValue) - size: \(segmentData.count)")
        
        switch segmentType {
        case .initialization:
            initializationData = segmentData
        case .separable:
            let fileURL = outputDir.appendingPathComponent(String(format: "%.4i.mp4", segment))
            segment += 1

            let mp4Data = initializationData + segmentData
            try! mp4Data.write(to: fileURL)

            let asset = AVAsset(url: fileURL)

            for i in 0..<players.count {
                let player = players[i]
                let playerItem = AVPlayerItem(asset: asset)
                player.insert(playerItem, after: nil)
                
                if player.rate == 0 && player.status == .readyToPlay {
                    let hostStartTime: CMTime = startTime + CMTime(value: CMTimeValue(playbackDelays[i]), timescale: 1)

                    player.preroll(atRate: 1) { prerolled in
                        guard prerolled else { return }
                        player.setRate(1, time: .invalid, atHostTime: hostStartTime)
                    }
                }
            }
            
        @unknown default:
            break
        }
    }
}

结果看起来像这样:

四个时钟可见,一个在背景中,三个在iPad上,它们之间的延迟时间分别为5、20和30秒

性能还算不错:我的 2019 年版 iPod 占用了 10-14% 的 CPU 和 38MB 的内存。


1
当然可以,只需在“preroll”和“setRate”中传递不同的速率即可。当然,速率<=1的效果最好,因为>1的速率会消耗您的延迟并超过“现在”。我现在意识到截图应该是两个设备和四个时钟。我会尽力做得更好。 - Rhythmic Fistman
1
太棒了!我曾经使用一个非常老旧(但是可用)的设置,在文档目录中自己编写图像,需要大量代码,这种方法更加清晰和现代化。你的代码立即运行,但是它显示的最终视频旋转了90度。我想知道为什么你的截图没有显示出来...你有什么想法吗? - Bob de Graaf
1
不知道,Bob - 我可能已经篡改了肖像测试代码,这是你尝试的吗? - Rhythmic Fistman
1
可能是因为在这个答案中,UI 代码被媒体处理所抛弃了。 - Rhythmic Fistman
1
哎呀,我真的搞不定,我已经试了一整天了。 我也有一个问题,相机反馈太宽了。 我一直在仔细看你的截图,但我认为那里也是一样的。 如果您仔细观察实际时钟中的数字,则可以看到它们更小,数字8最明显。 我想我要问一个新问题,并设置赏金,这让我发疯;) - Bob de Graaf
显示剩余6条评论

2
在iOS上,当切换文件时AVCaptureMovieFileOutput会丢帧。在OSX上不会发生这种情况。有一个关于此问题的讨论在头文件中,参见captureOutputShouldProvideSampleAccurateRecordingStart
你可以结合使用2和3来解决这个问题。你需要使用AVCaptureVideoDataOutputAVAssetWriter将视频文件分块写入,而不是使用AVCaptureMovieFileOutput,这样就不会丢帧了。添加3个环形缓冲区,存储足够的数据以跟上播放速度,使用GLES或Metal来显示缓冲区(使用YUV而不是RGBA,使用4/1.5倍少的内存)。
我曾经在强大的iPhone 4s和iPad 2时代尝试过更为简单的版本。它显示了现在和10秒前的内容。我估计因为你可以以3倍实时速度编码30fps,所以我应该能够在只使用2/3的硬件容量的情况下编码块并读取之前的块。可惜,我的想法可能是错误的,或者硬件存在非线性,或者代码有问题,导致编码器一直落后。

你是如何选择环形缓冲区大小的? - Rhythmic Fistman
然而视频播放是可能的!您需要找出需要多少秒的解压缩帧来维持播放,可用的内存量以及该意味着什么帧分辨率。使用YUV将降低您的内存需求,提高您的分辨率。我不会担心C和Swift之间的差异。 - Rhythmic Fistman
一个合适的尺寸是多少?你还没有计算需要多少帧。也许这个数字很小。 - Rhythmic Fistman
我想要回放最多两分钟的延迟。对于30fps的缓冲区大小为30 * 120秒= 3600。如果我可以获得压缩帧,那可能是可行的,但使用AVAssetReader无法存储超过几秒钟的帧而不崩溃。 - cohenadair
1
帧具有呈现时间戳,即它们应该出现的时间。如果您有帧_f0_和_f1_,则知道_f0_应显示的时间间隔。同样,您的环形缓冲区也表示时间间隔,并且对于每个视图(延迟或实时),您都会在其绘制回调中查找该时间的帧并在GL / Metal中绘制它们,这些回调以固定速率发生 - 通常是屏幕刷新率。 - Rhythmic Fistman
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接