最近在 iOS 上的音视频直播开发有些心得体会,准备写一系列的文章来总结一下,本文先会整体地谈一下音视频开发,然后谈一下 iOS 生产侧的音视频开发,最后分别说一下 iOS 视频采集、iOS 视频效果器和 iOS 视频预览。

Camera Sea

音视频开发

从工程师的角度,下图从 4 个方面来划分了音视频开发的职责,难度系数我认为从左到右是递增的:

Audio Video Stack

  • 消费侧:主要就是能看视频和听音频,重点就是播放器。
  • 生产侧:主要就是采集音视频,直播就需要推流,录制就需要保存到文件。
  • 传输侧:要连接生产和消费两端,自然就需要网络传输,采用已有协议或者自研协议,还需要对应的流媒体服务器。
  • 算法侧:音视频的原始数据是不可能直接拿到网络上传输的,太大了,所以就需要算法来进行编解码,需要考虑音视频数据自身的特性,也需要考虑网络传输的场景。

iOS 音视频开发

iOS 平台提供的音视频工具集非常丰富,下图中标红的部分是常用到:

iOS Media Frameworks Overview

iOS 生产侧的音视频开发

iOS Audio Video Producer

上图就是在 iOS 上作为生产侧比较完备的流程,每一步节点表明了当前步骤和采用的技术框架,连线中表明的是流转的数据格式和网络协议。

此流程的目的主要是为了实时推流,当然也可用于录制,如果只是录制,iOS 上有直接使用 AVFoundation 的黑盒方案。

蓝色部分的流程主要是针对视频部分,绿色部分的流程主要是针对音频部分,黄色部分的流程主要是针对编码后的数据。

涉及 OpenGL ES 和 FFmpeg 的部分都可以用 C++ 做跨平台的封装,再应用到 Android 平台上,其余部分都是依赖于 iOS 平台的实现。

这张图上涉及的内容还是比较多,后面详细讲解时,还会在这张图上回顾。

DTLiving

出于实践的目的,开发了 DTLiving 这个 Side Project,目前已经实现了视频采集,视频效果器和视频预览。

主要参考了 音视频开发进阶指南:基于Android与iOS平台的实践 GPUImage

DTLiving 的整体架构:

DTLiving Architecture

整个视频的处理流程,可以看作一个管道,有源头节点,有终止节点,还有中间的处理节点,节点和节点之间传递就是一个纹理,也就是 FrameBuffer。

关键的辅助类:

  • VideoContext 建立了 EAGLContext 和 DispatchQueue 一对一的关系,所有关于这个 EAGLContext 的操作都发送到这个 DispatchQueue 中,这个 DispatchQueue 是线性队列,可以避免并发操作 EAGLContext 会出现的问题。
  • ShaderProgram OpenGL ES Shader Program 的封装,用 C++ 写的,通过 OC 来 Bridge 到 Swift。视频部分的处理一定是会用到 OpenGL ES,可以看下系列文章 OpenGL ES 图像处理 来入个门。
  • FrameBuffer 简单来说就是就一个纹理,详细来说就是将 CVPixelBuffer 作为 CVOpenGLESTexture 的数据来源,并建立了一个 OpenGL Frame Buffer,可以将这个 OpenGL Texture 和 OpenGL Frame Buffer 绑定起来。
  • FrameBufferCache 根据纹理大小来缓存上面的 FrameBuffer。

视频处理管道上的节点:

  • VideoOutput 视频输出节点类,可以向后续的多个分支输出纹理。
  • VideoInput 视频输入节点类,可以接收一个纹理的输入。
  • VideoCamera 作为视频输出节点类,采集相机画面,输出纹理。
  • VideoFilterProcessor 同时作为视频输出节点类和视频输入节点类,接收输入纹理,经过视频效果器的处理,再输出纹理。
  • VideoView 作为视频输入节点类,接收输入纹理,绘制到 CAEAGLLayer 上。

iOS 视频采集

VideoCamera

iOS AVCaptureSession

首先,通过搭建上图的 AVCaptureSession 来采集相机画面,通过实现 AVCaptureVideoDataOutputSampleBufferDelegate 获取相机采集到画面数据 CMSampleBuffer。

extension VideoCamera: AVCaptureVideoDataOutputSampleBufferDelegate {
    
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        guard session.isRunning, !capturePaused else { return }
        
        semaphore.wait()
        
        VideoContext.sharedProcessingContext.async { [weak self] in
            self?.processVideo(with: sampleBuffer)
            self?.semaphore.signal()
        }
    }
    
}

然后,需要搭建 OpenGL ES Pipeline 用于将相机画面转换为纹理,也会做画面方向的调整,这里主要说下如何使 CVPixelBuffer 作为创建的纹理 CVOpenGLESTexture 的数据,CVOpenGLESTexture 就可以作为 OpenGL ES 绘制时的输入纹理:

guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }

var inputTexture: CVOpenGLESTexture!
let textureCache = VideoContext.sharedProcessingContext.textureCache
let resultCode = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                                              textureCache,
                                                              pixelBuffer,
                                                              nil,
                                                              GLenum(GL_TEXTURE_2D),
                                                              GL_RGBA,
                                                              GLsizei(bufferWidth),
                                                              GLsizei(bufferHeight),
                                                              GLenum(GL_BGRA),
                                                              GLenum(GL_UNSIGNED_BYTE),
                                                              0,
                                                              &inputTexture)

最后,OpenGL ES 将结果绘制到输出纹理上,通过 FrameBuffer 来承载这个输出,它的实现也是将 CVPixelBuffer 作为创建的纹理 CVOpenGLESTexture 的数据,不过这个 CVPixelBuffer 数据是空的。在 OpenGL ES 绘制前,通过 FrameBufferCache 来获取 FrameBuffer,然后激活 FrameBuffer 来绑定为当前 OpenGL ES Frame Buffer,此 OpenGL ES Frame Buffer 已经和 FrameBuffer 中的纹理建立了关联,这样就可以保证 OpenGL ES 将结果绘制到此输出纹理上。

class FrameBuffer {
        
    func generateFrameBuffer() {
        VideoContext.sharedProcessingContext.sync {
            VideoContext.sharedProcessingContext.useAsCurrentContext()
            
            glGenFramebuffers(1, &frameBuffer)
            glBindFramebuffer(GLenum(GL_FRAMEBUFFER), frameBuffer)
                        
            let attrs: NSDictionary = [kCVPixelBufferIOSurfacePropertiesKey:  NSDictionary()]
            var resultCode = CVPixelBufferCreate(kCFAllocatorDefault, Int(size.width), Int(size.height),
                                kCVPixelFormatType_32BGRA, attrs, &pixelBuffer)
            if resultCode != kCVReturnSuccess {
                DDLogError("\(tag) Could not create pixel buffer \(resultCode)")
                exit(1)
            }
            
            let textureCache = VideoContext.sharedProcessingContext.textureCache
            resultCode = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                                                      textureCache,
                                                                      pixelBuffer,
                                                                      nil,
                                                                      GLenum(GL_TEXTURE_2D),
                                                                      GL_RGBA,
                                                                      GLsizei(size.width),
                                                                      GLsizei(size.height),
                                                                      GLenum(GL_BGRA),
                                                                      GLenum(GL_UNSIGNED_BYTE),
                                                                      0,
                                                                      &texture)
            if resultCode != kCVReturnSuccess {
                DDLogError("\(tag) Could not create texture \(resultCode)")
                exit(1)
            }
            
            textureName = CVOpenGLESTextureGetName(texture)
                
            glBindTexture(GLenum(GL_TEXTURE_2D), textureName)
            glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR)
            glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR)
            glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_S), GL_CLAMP_TO_EDGE)
            glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_T), GL_CLAMP_TO_EDGE)
            
            glFramebufferTexture2D(GLenum(GL_FRAMEBUFFER), GLenum(GL_COLOR_ATTACHMENT0),
                                   GLenum(GL_TEXTURE_2D), textureName, 0)
            
            if glCheckFramebufferStatus(GLenum(GL_FRAMEBUFFER)) != GL_FRAMEBUFFER_COMPLETE {
                DDLogError("\(tag) Could not generate frame buffer")
                exit(1)
            }
            
            glBindTexture(GLenum(GL_TEXTURE_2D), 0)
        }
    }
    
    func activate() {
        glBindFramebuffer(GLenum(GL_FRAMEBUFFER), frameBuffer)
        glViewport(0, 0, GLsizei(size.width), GLsizei(size.height))
    }
    
}
outputFrameBuffer = VideoContext.sharedProcessingContext.frameBufferCache
    .fetchFrameBuffer(tag: "VideoCamera",
                      for: CGSize(width: rotatedBufferWidth,
                                  height: rotatedBufferHeight))
outputFrameBuffer?.activate()

iOS 视频效果器

效果器部分较复杂,主要分为 Swift 编写的 VideoFilterProcessor,Objective-C 编写的桥接部分,C++ 编写的核心部分,下面按从下层到上层的顺序来讲:

C++ 编写的核心部分

core/effect

首先要明白一个视频效果器,其实就是运行一个 OpenGL ES Shader Program 来进行图像处理,Shader Program 同样会有输入和输出,这里的输出主要就是纹理,输入主要由输入纹理和其他控制 Shader Program 运行的参数组成。

DTLiving VideoEffectProcessor

VideoEffectProcessor 作为控制视频效果器的统一接口类,内部持有一个视频效果器链,接收输入纹理,经过视频效果器链的处理,再输出纹理,这里控制视频效果器接口基本都带有 name 参数,此参数是每个视频效果器的唯一名称,导致目前不能多次添加相同的视频效果器,name 参数的作用是在视频效果器链中定位到具体的视频效果器,这些问题,之后有时间再改进吧:

class VideoEffectProcessor {
public:
    VideoEffectProcessor();
    ~VideoEffectProcessor();
    
    void Init(const char *vertex_shader_file, const char *fragment_shader_file);
    void AddEffect(const char *name, const char *vertex_shader_file, const char *fragment_shader_file);
    void ClearAllEffects();
    void SetDuration(const char *name, double duration);
    void SetClearColor(const char *name, vec4 clear_color);
    void LoadResources(const char *name, std::vector<std::string> resources);
    void SetTextures(const char *name, std::vector<VideoFrame> textures);
    void SetPositions(const char *name, GLfloat *positions);
    void SetTextureCoordinates(const char *name, GLfloat *texture_coordinates);
    void SetEffectParamInt(const char *name, const char *param, GLint *value, int size);
    void SetEffectParamFloat(const char *name, const char *param, GLfloat *value, int size);
    void Process(VideoFrame input_frame, VideoFrame output_frame, double delta);
private:
    VideoEffect *no_effect_;
    std::vector<VideoEffect *> effects_ {};
};

DTLiving VideoEffect

VideoEffect 就是视频效果器,负责加载 OpenGL ES Shader Program,以及配置 Shader Program 运行需要的其他参数,运行 Shader Program,输出处理过的纹理,根据不同视频效果器处理的特点实现了不同的子类。

class VideoEffect {
public:
    static std::string VertexShader();
    static std::string FragmentShader();
    static std::string GrayScaleFragmentShader();
    static std::vector<GLfloat> CaculateOrthographicMatrix(GLfloat width, GLfloat height,
                                                           bool ignore_aspect_ratio = false);

    VideoEffect(std::string name);
    ~VideoEffect();
    
    virtual void LoadShaderFile(std::string vertex_shader_file, std::string fragment_shader_file);
    virtual void LoadShaderSource();
    virtual void LoadUniform();
    virtual void LoadResources(std::vector<std::string> resources);
    virtual void SetTextures(std::vector<VideoFrame> textures);

    void SetPositions(std::vector<GLfloat> positions);
    void SetTextureCoordinates(std::vector<GLfloat> texture_coordinates);
    void SetUniform(std::string name, VideoEffectUniform uniform);
    
    bool Update(double delta, GLsizei width, GLsizei height);
    void Render(VideoFrame input_frame, VideoFrame output_frame);
};

几种主要的基类:

  • VideoTwoPassEffect 运行两次 Shader Program。
  • VideoTwoPassTextureSamplingEffect 继承自 VideoTwoPassEffect,运行两次 Shader Program,第一次垂直地采样,第二次水平地采样。
  • Video3x3TextureSamplingEffect 围绕中心点,采集周围的 8 个点,加上中心点,一共采样 9 个点。
  • Video3x3ConvolutionEffect 继承自 Video3x3TextureSamplingEffect,得到 3x3 的采样点后,再经过 3x3 矩阵的处理,赋值给中心点,这就是卷积。
  • VideoTwoInputEffect 输入两个纹理到同一个 Shader Program,所以是在同一个矩形上混合两个纹理。
  • VideoCompositionEffect 同一个 Shader Program 绘制两次,第一次绘制,输入一个视频纹理,第二次绘制,输入一个图片纹理,所以可到组合两个图像的效果,也就可以实现水印、动图贴纸和文字绘制。

5 种不同类型的实现类:

这部分的内容挺多,有时间再细讲。

Objective-C 编写的桥接部分

Bridge

一个 VideoFilter 对应一个 VideoEffect,前面讲了 VideoEffect 才是运行 Shader Program 实现效果,这里的 VideoFilter 只是作为数据模型,VideoEffectProcessorObject 内部持有 VideoEffectProcessor,通过 VideoEffectProcessorObject 向 C++ 编写的核心部分传递 VideoFilter 数据,这样就达到了控制效果器的目的,不过目前需要写很多冗余代码,其本质就是 Objective-C 层和 C++ 层之间的数据交换,只要定义好数据交换的协议,后期可以考虑通过 Protocol Buffers 来自动生成:

@interface VideoFilter : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) double duration;
@property (nonatomic, copy, readonly) NSString *vertexShaderFile;
@property (nonatomic, copy, readonly) NSString *fragmentShaderFile;
@property (nonatomic, copy, readonly) NSArray<NSNumber*> *positions;
@property (nonatomic, copy, readonly) NSArray<NSNumber*> *textureCoordinates;
@property (nonatomic, copy, readonly) NSDictionary<NSString*, NSArray<NSNumber*>*> *intParams;
@property (nonatomic, copy, readonly) NSDictionary<NSString*, NSArray<NSNumber*>*> *floatParams;
@property (nonatomic, copy, readonly) NSArray<NSString*> *resources;
@property (nonatomic, copy, readonly) NSArray<GLKTextureInfo*> *textures;
@property (nonatomic, assign) VideoVec4 backgroundColor;
@property (nonatomic, assign, readonly) BOOL isRotationAware;
@property (nonatomic, assign) VideoRotation rotation;
@property (nonatomic, assign, readonly) BOOL isSizeAware;
@property (nonatomic, assign) CGSize size;

- (instancetype)initWithName:(const char *)name;

- (NSArray<NSNumber*> *)sizeToArray:(CGSize)size;
- (NSArray<NSNumber*> *)boolToArray:(BOOL)isYES;
- (NSArray<NSNumber*> *)vec2ToArray:(VideoVec2)vec;
- (NSArray<NSNumber*> *)vec3ToArray:(VideoVec3)vec;
- (NSArray<NSNumber*> *)vec4ToArray:(VideoVec4)vec;
- (NSArray<NSNumber*> *)mat3ToArray:(VideoMat3)mat;
- (NSArray<NSNumber*> *)mat4ToArray:(VideoMat4)mat;

@end
@interface VideoEffectProcessorObject : NSObject

- (instancetype)init;
- (void)addFilter:(VideoFilter *)filter;
- (void)updateFilter:(VideoFilter *)filter;
- (void)processs:(GLuint)inputTexture outputTexture:(GLuint)outputTexture size:(CGSize)size delta:(double)delta;
- (void)clearAllFilters;

@end

VideoFilterProcessor

VideoFilterProcessor.swift

VideoFilterProcessor 也是视频处理管道上的节点,同时作为视频输出节点类和视频输入节点类,接收输入纹理,经过视频效果器的处理,再输出纹理。同样 VideoFilterProcessor 内部持有 VideoEffectProcessorObject 来控制效果器:

func clearAllFilters() {
    filters.removeAll()
    VideoContext.sharedProcessingContext.sync {
        processor.clearAllFilters()
    }
}

var numberOfFilters: Int {
    return filters.count
}

func fetchFilter(at index: Int) -> VideoFilter {
    return filters[index]
}

func addFilter(_ filter: VideoFilter) {
    if filter.isRotationAware {
        filter.rotation = inputRotation
    }
    if filter.isSizeAware {
        filter.size = inputSize
    }
    filters.append(filter)
    VideoContext.sharedProcessingContext.sync {
        processor.add(filter)
    }
}
    
func updateFilter(_ filter: VideoFilter, at index: Int) {
    filters[index] = filter
    updateFilter(filter)
}

可以添加多个视频效果器组成一个视频效果器的处理链:

filterProcessor = VideoFilterProcessor()
filterProcessor.addFilter(VideoGaussianBlurFilter())
filterProcessor.addFilter(VideoToonFilter())

iOS 视频预览

VideoView

接收输入纹理,通过 OpenGL ES 绘制到 CAEAGLLayer 上,前面说过将 OpenGL ES Frame Buffer 关联到一个纹理上,就可以绘制到纹理上,这里需要将 OpenGL ES Frame Buffer 关联到 Render Buffer 上,此 Render Buffer 和 CAEAGLLayer 有所关联,这样就可以保证 OpenGL ES 将结果绘制到 CAEAGLLayer上。

private func createDisplayFrameBuffer() {
    VideoContext.sharedProcessingContext.useAsCurrentContext()

    glGenFramebuffers(1, &displayFrameBuffer)
    glBindFramebuffer(GLenum(GL_FRAMEBUFFER), displayFrameBuffer)
    
    glGenRenderbuffers(1, &displayRenderBuffer)
    glBindRenderbuffer(GLenum(GL_RENDERBUFFER), displayRenderBuffer)
    
    if !VideoContext.sharedProcessingContext.context
        .renderbufferStorage(Int(GL_RENDERBUFFER), from: eaglLayer) {
        DDLogError("Could not bind a drawable object’s storage to a render buffer object")
        exit(1)
    }
    
    var backingWidth: GLint = 0
    var backingHeight: GLint = 0
    
    glGetRenderbufferParameteriv(GLenum(GL_RENDERBUFFER), GLenum(GL_RENDERBUFFER_WIDTH), &backingWidth)
    glGetRenderbufferParameteriv(GLenum(GL_RENDERBUFFER), GLenum(GL_RENDERBUFFER_HEIGHT), &backingHeight)
    
    if backingWidth == 0 || backingHeight == 0 {
        destroyDisplayFrameBuffer()
        return
    }
    
    outputSize.width = CGFloat(backingWidth)
    outputSize.height = CGFloat(backingHeight)
    
    glFramebufferRenderbuffer(GLenum(GL_FRAMEBUFFER),
                              GLenum(GL_COLOR_ATTACHMENT0),
                              GLenum(GL_RENDERBUFFER),
                              displayRenderBuffer)
    
    if glCheckFramebufferStatus(GLenum(GL_FRAMEBUFFER)) != GL_FRAMEBUFFER_COMPLETE {
        DDLogError("[VideoView] Could not generate frame buffer")
        exit(1)
    }
            
    recalculateViewGeometry()
}