从零开始仿写一个抖音App——视频编辑SDK开发(一)

2019-11-21 14:58:44 浏览数 (1)

本文首发于微信公众号——世界上有意思的事,搬运转载请注明出处,否则将追究版权责任。交流qq群:859640274

大家好久不见,又有一个多月没有发文章了。不知道还有哪些读者记得我的 从零开始仿写抖音App 的系列文章,这个系列的文章已经很久没有更新了,最后一篇文章是我开始开发 视频编辑SDK 时写的。当时踏入到了一个新的领域里,未知的东西太多了,导致接下来的大半年都没有更新相关的文章。 但是别以为我已经放弃了,今天对于我来说是一个值得纪念的日子,2019年10月28日 我终于将 视频编辑SDK 的最简版本给完成了,我将这个 视频编辑SDK 命名为 WSVideoEditor,接下来的一段时间里我计划更新 4 篇解析该 SDK 的相关文章,WsVideoEditor 中的代码我也会随着文章同步更新。当 SDK 解析完毕之后 从零开始仿写一个抖音App 系列文章将会踏出最关键的一步。

阅读须知:

本文分为以下章节,读者可按需阅读:

  • 1.项目介绍
  • 2.SDK功能介绍
  • 3.SDK架构以及运行机制介绍
  • 4.VideoDecodeService解析

一、项目介绍

本章我将介绍 WsVideoEditor 项目的基本结构、组织方式以及运行方式。需要大家把项目 clone 下来跟着我一步步来做。

1.基本结构

图1:总结构

我们看着图1,一个个来讲:

  • 1.android:顾名思义,这个目录下是一个 Android 项目,去掉 .gradle、build、.idea 等等 ignore 的文件,我们主要关注下面这几个文件夹。

图2:ffmpeg-cpp.png

  • 1.ffmpeg-cpp:如图2,这个文件夹中有 FFMPEG 的头文件与 .so 文件,我们需要将这个库集成到我们的 SDK 中,我们的 编辑SDK 需要有解码视频的能力,解码分为硬解和软解,ffmpeg 就是用于软解的最强开源库。至于如何得到这些东西,我之前写过一篇 FFMPE食用指南 有兴趣的读者可以看看。
  • 2.protobuf-cpp:这个文件夹与 ffmpeg-cpp 类似,里面有 Protobuf For Cpp 的头文件与 .a 文件,因为我们 Native 与 Android/iOS/Linux 的通信方式使用的是 Protobuf,所以我们也需要将 Cpp 层的 Protobuf 集成到我们的 SDK 中。

图3:wsvideoeditor-sdk.png

  • 3.wsvideoeditor-sdk:如图3,这个文件夹是一个 Android Library 项目,我们的 编辑SDK 在 Android 端会以一个独立的 jar 包形式存在。这个目录下的东西比较多,例如 src 目录下是 Java 层的一些封装代码。jni 目录下是一些使用了 Android Native Api 的 Cpp 代码。更详细的解析,会在后面几章。
  • 4.wsvideoeditor-test:这个文件夹则是一个 Android Application 项目,主要是用于编写一些测试 编辑 SDK 的代码。

图4:buildtools.png

  • 2.buildtools:如图4,这个目录下主要存放一些工具脚本,例如目前 build_proto.sh 用于生成 Java 与 Cpp 层的 Protobuf 代码。
  • 3.ios、linux:因为我给 编辑SDK 的定义是一个跨平台的视频编辑SDK,所以未来的想法是 iOS 和 Linux 端也能接入我们的 编辑SDK,目前这两个目录里还啥也没有:-D。

图5:sharedcpp.png

  • 4.sharedcpp:如图5,这个目录里面主要存放与平台无关的 Cpp 代码,因为我们要做的是一个跨平台的视频编辑 SDK,所以尽量多的将与平台无关的代码进行共用,是一个明智的选择。可以看见里面的 prebuilt_protobuf 目录下就有我们使用 build_proto.sh 生成的 Cpp 文件,这些文件就是可以共用的。
  • 5.sharedproto:这里存放着我们定义的 Protobuf 文件。
  • 6.thirdparty:这里存放着一些包含源码的与平台无关的三方库,例如 libyuv。
  • 7.CMakeLists.txt:这个文件主要是为了让 Clion 能够识别我们这个整个项目。

2.如何运行项目

二、SDK功能介绍

这一章我们来介绍一下 编辑SDK 目前有的以及未来会有的功能。编辑SDK 的最终形态会和抖音的视频编辑功能接近,有其他想法的读者也可以在评论区留言或者提 issue。

1.目前有的功能

  • 1.开始播放
  • 2.暂停播放
  • 3.视频音量调整
  • 4.单段视频播放
  • 5.多段视频播放
  • 6.视频 Seek
  • 7.视频边缘模糊填充

2.规划中的功能

  • 1.视频类:
    • 1.按时间轴添加额外的声音
    • 2.按时间轴添加滤镜
    • 3.按时间轴添加静态贴纸、动态贴纸
    • 4.多段视频间转场
  • 2.图片类:
    • 1.添加声音
    • 2.多张图片间的转场
    • 3.照片电影
  • 3.工具类:
    • 1.视频缩略图截取
    • 2.视频元数据读取
  • 4.编码类:
    • 1.导出不同格式的视频
    • 2.更改视频的分辨率、帧率
    • 3.视频转 gif
  • 5.技术类:
    • 1.多进程编解码视频
    • 2.多进程播放视频
    • 3.多进程视频缩略图截取

三、SDK架构以及运行机制介绍

这一章我来介绍一下目前 编辑SDK 的整体架构以及运行机制。

1.编辑SDK架构

图6:编辑SDK架构.png

图6是 编辑SDK 的架构图,这一节我会照着这张图来介绍。

(1).基础API

先从底部看起,底部是整个 SDK 依赖的底层 API 库。

  • 1.FFMPEG:前面简单介绍过,是一个开源的视频库,在我们的项目中主要用于软编解码
  • 2.MediaCodec:是 Android 中的硬编解码 API,相应的 iOS 也有自己的硬编解码方式。
  • 3.OpenGL:是一个开源的图形库,Android 和 iOS 中都有内置 OpenGL ES 作为默认图形库。在我们的项目中主要用于将视频解码后的视频帧绘制到屏幕上去。当然也可以对这些图像做一些效果的变化,例如滤镜、视频/图片转场等等。
  • 4.Libyuv:是 Google 开源的实现各种 YUV 与 RGB 之间相互转换、旋转、缩放的库。
  • 5.Protobuf:是 Google 开源的一种平台无关、语言无关、可扩展且轻便高效的序列化数据结构的协议。在我们的项目中主要用于 Cpp 与 Java、OC、Dart 之间的数据通信。
(2).SDK主体

接着我们再看图片中的主体部分,因为目前只有 Android 端的实现,所以主体部分的上层实现我使用 Android 来代替。

  • 1.Android层架构
    • 1.WSMediaPlayerView:继承于 TextureView,所以其可以提供一个具有 Open GL 环境的线程。对 Surface 家族不了解的同学可以看看这两篇文章:Android绘制机制以及Surface家族源码全解析、相机/OpenGL/视频/Flutter和SurfaceView
    • 2.WSMediaPlayer:这个是一个代理了 Native 的 NativeWSMediaPlayer 的 Java 类。该类具有一个播放器应该有的各种 API,例如 play、pause、seek 等等。其实很多 Android 中的系统类都是以这种形式存在的,例如 Bitmap、Surface、Canvas 等等。说到底 Java 只是 Android 系统方便开发者开发 App 的上层语言,系统中大部分的功能最终都会走到 Native 中去,所以读者需要习惯这种代码逻辑
    • 3.AudioPlayer:这个类是基于 Android 中的 AudioTrack 封装的能够播放音频帧的 Java 类。我们在 Native 层也有一个 AudioPlayer。这里与 WSMediaPlayer 相反 Native 层的 AudioPlayer 是一个空壳,Java 层的 AudioPlayer 反向代理了 Native 层的 AudioPlayer,因为在这里 Java 层的 AudioPlayer 才是真正播放音频的东西。
  • 2.Native层架构:这里我们自底向上来剖析,Native 层的架构
    • 1.AudioDecodeService:它负责使用 FFMPEG/MediaCodec,来从视频/音频中解码出某个时间点的音频帧,并且存储在一个音频帧队列中。最终被外部取出音频帧交给音频播放器播放。
    • 2.VideoDecodeService:它和 AudioDecodeService 类似,是使用 FFMPEG/MediaCodec 来从视频中解码出某个时间点的视频帧并且存储在一个视频帧队列中。最终被外部取出视频帧交给 OpenGL 绘制到屏幕上。
    • 3.VideoFramePool:它负责响应外部的 seek 事件,然后使用 FFMPEG/MediaCodec 来从视频中解码出当前时间点的视频帧,然后存储到一个 LruCache 中同时返回 seek 时间点的视频帧。
    • 4.AudioPlayer:前面说过,这个是 Java 层的 AudioPlayer 代理类,主要用于播放 AudioDecodeService 解码出来音频帧。
    • 5.FrameRenderer:这个东西是一个渲染器,在视频播放时用于渲染 VideoDecodeService 不断解码出的视频帧,在视频 seek 的时用于向 VideoDecoderPool 发送 seek 请求,然后渲染返回的视频帧。
    • 6.NativeWSMediaPlayer:用于同步 AudioPlayer 和 FrameRenderer 的音视频播放。即我们一般认为的视频播放器实体,被 Java 层的 WSMediaPlayer 代理着。

2.编辑SDK运行机制

图7:编辑SDK运行机制.png

上一节讲解了 编辑SDK 的架构,这一节在来基于图7讲讲 编辑SDK 的运行机制。

  • 1.经过上一节的介绍,我们都知道了 WSMediaPlayerView 是整个 编辑SDK 的顶级类。所以我们由 WSMediaPlayerView 入手,先看图片最上面。
    • 1.可以看见 WSMediaPlayerView 中会维护一个 30ms的定时循环,这个循环中会不断的调用 draw frame 来驱动 WSMediaPlayer/NativeWSMediaPlayer 进行视频/音频的播放。
    • 2.与此同时,最左边的用户会通过 play、pause、seek 等 API 来更新 NativeWSMediaPlayer 的状态。
    • 3.需要注意的是,WSMediaPlayerView 的定时循环不会被用户的 play、pause、seek 等操作所中断的。
  • 2.再来看看图片左边,这是 WSMediaPlayer 的内部播放机制。要点为 三个循环,两个播放,我们还是自底向上解析。
    • 1.VideoDecodeService:它内部维护了一个可阻塞循环与一个先进先出队列——BlockingQueue,当我们开始播放视频或者 seek 视频到某个时间点的时候,VideoDecodeService 会记录这个开始的时间点,然后不断的解码当前时间点之后的每一帧,每解码出一帧便把这一帧放入 BlockingQueue 中。当队列中的元素达到最大值时,当前的循环就会被阻塞,直到外部将 BlockingQueue 中的 Top 帧消费了,那么循环又会被启动继续解码。需要注意的是:VideoDecodeService 只在视频播放的时候提供视频帧,因为在这个情况下 BlockingQueue 中的视频帧的顺序就是视频真正播放的顺序。
    • 2.VideoFramePool:它内部维护了一个可阻塞请求循环与一个LruCachePool。一般情况下 VideoFramePool 的循环是处于阻塞状态的。当外部 seek 视频的时候,循环会接收到一个请求并开始处理这个请求,如果 LruCachePool 中有 Cache 被命中了,那么就直接返回 Cache,否则将会立即从视频中解码出这个请求中时间点的视频帧存到 LruCachePool 中然后再返回。需要注意的是:VideoFramePool 只在视频 seek 的时候提供视频帧,因为我们的 seek 操作是随机的,所以在这个情况下 VideoDecodeService 无法使用。
    • 3.AudioDecodeService:它与 VideoDecodeService 类似,也维护了一个可阻塞循环先进先出队列,内部的其他行为也类似,只是将视频帧换成了音频帧。
    • 4.FrameRenderer
      • 1.当视频 seek 的时候,其会从 VideoFramePool 中取出 seek 时刻的视频帧绘制它。
      • 2.当视频处于 playing 状态时,它的 drawFrame 方法就会不断被 WSMediaPlayerView 通过定时循环调用并从 VideoDecodeService 中取出当前帧通过 Open GL 绘制它。
    • 5.AudioPlayer:当视频处于 playing 状态时,它也会不断被 WSMediaPlayerView 通过定时循环驱动着从 AudioDecodeService 中取出当前的音频帧,然后通过反向代理将音频帧交给 Java 层的 AudioPlayer 进行播放。

四、VideoDecodeService解析

上一章大概的讲了讲整个 编辑SDK 的整体架构和运行机制,但其实整个 编辑SDK 内部的每一个部分的细节都非常多,所以这一章我会先讲解 VideoDecodeService 的内部细节。其他各个部分则放在后面几篇文章中讲解。与此同时,WsVideoEditor 中的代码也会随着讲解的进行而不断更新。最终形成一个可用的 编辑SDK

1.API讲解

代码语言:javascript复制
-----代码块1----- VideoDecodeService.java
  private native long newNative(int bufferCapacity);
  
  private native void releaseNative(long nativeAddress);
  
  private native void setProjectNative(long nativeAddress, double startTime, byte[] projectData);
  
  private native void startNative(long nativeAddress);
  
  private native String getRenderFrameNative(long nativeAddress, double renderTime);
  
  private native void updateProjectNative(long nativeAddress, byte[] projectData);
  
  private native void seekNative(long nativeAddress, double seekTime);
  
  private native void stopNative(long nativeAddress);
  
  private native boolean endedNative(long nativeAddress);
  
  private native boolean stoppedNative(long nativeAddress);
  
  private native int getBufferedFrameCountNative(long nativeAddress);

如代码块1所示,我们先来讲讲 VideoDecodeService 的 API

  • 1.newNative:由前面几章的讲解我们知道,VideoDecoderService 内部有一个先进先出的阻塞队列,这个方法的入参 bufferCapacity 就是用于设置这个阻塞队列的长度。这个方法调用之后 Native 层会创建一个与 Java 层同名的 VideoDecodeService.cpp 对象。然后返回一个 long 表示这个 Cpp 对象的地址。我们会将其记录在 Java 层,后续要调用其他方法时需要通过这个地址找到相应的对象。
  • 2.releaseNative:因为 Cpp 没有垃圾回收机制,所以 Cpp 对象都是需要手动释放的,所以这个方法就是用于释放 VideoDecodeService.cpp 对象。
  • 3.setProjectNative:因为 Protobuf 是高效的跨平台通信协议,所以 Java 与 Cpp 层的通信方式使用的就是 Protobuf,我们可以看 ws_video_editor_sdk.proto 这个文件,里面定义的 EditorProject 就是两端一起使用的数据结构。这个方法的入参 nativeAddress 就是我们在 1 中获取到的对象地址。入参 startTime 表示起始的解码点,单位是秒。入参 projectData 就是 EditorProject 序列化之后的字节流。
  • 4.startNative:这个方法表示开始解码。
  • 5.getRenderFrameNative:这个方法表示获取 renderTime 这一时刻的帧数据,目前返回到 Java 层的是一个 String,在 Cpp 层后续我们主要就是使用这个方法获取到的帧数据使用 OpenGL 绘制到屏幕上。
  • 6.updateProjectNative:这个方法和 setProjectNative 类似,用于更新 EditorProject。
  • 7.seekNative:我们在看视频的时候,将进度条拖动到某一时刻的操作被称为 seek,在 VideoDecodeService 中的体现就是这个方法,这个方法会将当前的解码时间点设置为 seekTime
  • 8.stopNative:这个方法表示暂停解码。
  • 9.endedNative:返回一个 boolean 表示视频的解码点是否到达了视频的结尾。
  • 10.stoppedNative:返回了一个 boolean 表示当前是否暂停了解码。
  • 11.getBufferedFrameCountNative:返回一个 int,表示当前阻塞队列中有多少个帧,最大不会超过我们在 1 中设置的 bufferCapacity

2.代码分析

这一小节中,我使用一个完整的例子来分析 VideoDecodeService 的源码

  • 1.例子在 TestActivity 中,我们运行项目会看见界面上有三个 Button 和两个 TextView。
  • 2.我们在 initButton 中进行了下面这些操作
    • 1.初始化了 ui。
    • 2.创建了一个 VideoDecodeService.java 类,内部就是调用我们上一节说的 newNative 方法。这个方法最终会进入到 video_decode_service.h 中调用 VideoDecodeService.cpp 的构造方法,构造方法则会创建一个 BlockingQueue.cpp 对象 decoded_unit_queue_,这就是我们一直说的 先进先出阻塞队列
    • 3.构建了一个 EditorProject.java,里面传了一个需要解码的视频路径 /sdcard/test.mp4
  • 3.我们点击 START 按钮
    • 1.stringBuildertimes 是用来记录测试数据的就不说了
    • 2.这里然后调用了 setProject 方法,进过一系列调用链后会通过 jni 进入到代码块3
      • 1.将 buffer 反序列化成 EditorProject.cpp 对象。
      • 2.address 强转 VideoDecodeService.cpp 对象。
      • 3.使用 LoadProject 方法解析出一些数据,例如视频的帧率、宽高等等。有兴趣的读者可以跟进入看看。
      • 4.调用 SetProject 给 VideoDecodeService.cpp 设置 EditorProject.cpp。
    • 3.调用 start 最终也是到代码块3中,调用 Start 方法。我们继续进入 Start 方法中,发现其中是启动了一个线程然后调用 VideoDecodeService::DecodeThreadMain,这个方法内部则是一个 while 循环,每当使用 FFMPEG 解码出一个视频帧的时候就会将这一帧放到 decoded_unit_queue_ 中。当外部没有消费者时,decoded_unit_queue_ 的帧数量将会很快达到阈值(我们设置的是10),此时这个线程就会被阻塞。直到外部消费后,帧数量减少了,本线程将会继续开始解码视频帧,如此往复。
代码语言:javascript复制
-----代码块3----- com_whensunset_wsvideoeditorsdk_inner_VideoDecoderService.cc
JNIEXPORT void JNICALL
Java_com_whensunset_wsvideoeditorsdk_inner_VideoDecodeService_setProjectNative
        (JNIEnv *env, jobject, jlong address, jdouble render_pos, jbyteArray buffer) {
    VideoDecodeService *native_decode_service = reinterpret_cast<VideoDecodeService *>(address);
    model::EditorProject project;
    jbyte *buffer_elements = env->GetByteArrayElements(buffer, 0);
    project.ParseFromArray(buffer_elements, env->GetArrayLength(buffer));
    env->ReleaseByteArrayElements(buffer, buffer_elements, 0);
    LoadProject(&project);
    native_decode_service->SetProject(project, render_pos);
}

JNIEXPORT void JNICALL Java_com_whensunset_wsvideoeditorsdk_inner_VideoDecodeService_startNative
        (JNIEnv *, jobject, jlong address) {
    VideoDecodeService *native_decode_service = reinterpret_cast<VideoDecodeService *>(address);
    native_decode_service->Start();
}
  • 4.继续看代码块2,可以看见我启动了一个 Java 层的无限循环线程,每隔 30ms 会 sleep 一下。每次循环我则会调用 getRenderFrame 方法来从 VideoDecodeService 中消费一个视频帧。然后把帧的信息打印到 TextView 上面。其实这里的代码可以类比为视频的播放,VideoDecodeService 不断地在后台线程进行解码按顺序将视频帧放入到队列中,本线程则不断的从队列中取出一帧进行消费,就像视频帧被渲染到屏幕上一样。
  • 5.最下面还有一个 Java 层的无限循环线程,会不断的读取 VideoDecodeService 的其他信息打印到 TextView 上。
  • 6.这一节只是简单的介绍 VideoDecodeService 的运行思路,其实代码里还有很多实现细节,这些细节解析就只能交给读者了,毕竟我比较懒:-D

五、尾巴

终于从零开发仿写一个抖音APP这一系列文章又重新开始更新了,今年以来文章的发表间隔长了很多,写文章的时间也少了很多。但是为了那么多支持、关注我的读者我也不能就这样放弃更新。立一个 flag,今后每个月都要更新一篇文章,希望大家能够多多支持,感谢!!

连载文章

  • 1.从零开始仿写一个抖音app——开始
  • 4.从零开始仿写一个抖音App——日志和埋点以及后端初步架构
  • 5.从零开始仿写一个抖音App——app架构更新与网络层定制
  • 6.从零开始仿写一个抖音App——音视频开篇
  • 7.从零开始仿写一个抖音App——基于FFmpeg的极简视频播放器
  • 8.从零开始仿写一个抖音App——跨平台视频编辑SDK项目搭建
  • 9.从零开始仿写一个抖音App——Android绘制机制以及Surface家族源码全解析

0 人点赞