iOS视频编辑从设计到实现-(3)框架设计 - 图1
最初下载了几个比较好用的编辑软件体验,大都功能比较完善,体验下来发现分为两大模块

  • 自定义编辑模式
  • 模板编辑模式

自定义编辑模式,这里面包含的功能比较多,大部分的实现都能找到单独的例子,难点在于怎么把所有的这些功能组合起来,如何编辑,如何预览。

  • 视频拼接
  • 视频裁剪
  • 视频排序
  • 视频分割
  • 视频旋转
  • 视频变速
  • 删除视频
  • 调整画幅
  • 调整音量
  • 画中画
  • 滤镜
  • 视频转场
  • 视频倒播
  • 背景音乐
  • 配乐
  • 字幕
  • 保存草稿
  • 重做功能
  • 导出视频

模板编辑模式,即为用户提供「模板视频」,用户只需要选择视频或者图片,便可创作出与「模板视频」有同样编辑特效的同款视频,实现「一键编辑」。

1、技术选型

在iOS平台上,能够实现上面的自定义编辑功能的技术有不少。比如AVFoundation、GPUImage、FFMPEG,本文主要基于AVFoundation来实现。

主要考虑的几个点:

1、使用AVFoundation可以很方便预览,使用AVPlayer来做编辑预览

2、使用AVFoundation性能有保障,后续做优化比较容易,接口比较完善,方便拓展

基础的编辑功能,比如拼接、排序、裁剪等等功能使用AVMutableComposition很容易就能实现。难么转场、滤镜、画中画这些怎么实现呢。使用AVMutableVideoComposition 可以指定视频的渲染尺寸、缩放比例、帧率等参数,转场动画可以通过添加AVVideoCompositionLayerInstruction指令实现。滤镜功能得针对每一帧视频来做处理,这里我使用AVVideoCompositing来处理。我们可以获取到每一帧的图片,在有多条轨道的时候,还能同时获取多个轨道的帧图片,我们可以在这做转场,滤镜,画中画这些功能。

2、框架设计

视频编辑主要分为以下几个模块

  • 资源缓存(缓存当前视频编辑的资源,包括视频路径,图片、音乐、录音、视频帧图片)
  • 编辑描述 (对视频编辑操作的描述,可以导出为JSON文件,也可以从JSON文件还原到编辑状态)
  • 预览构建 (构建视频预览、处理编辑描述形成能够播放的视频资源)
  • 视频预览 (播放预览构建的资源)
  • 导出构建 (构建视频导出、处理编辑描述形成能够导出的视频资源)
  • UI

下图是整体的一个流程
iOS视频编辑从设计到实现-(3)框架设计 - 图2

在视频编辑的时候,每次编辑都是对编辑描述模块的一个修改,后续所有的预览和导出都依赖于对编辑描述的解析。从视频编辑描述我们可以生成用来播放和导出的资源组合(AVMutableComposition),那么我们怎么来描述视频编辑呢。

首先我们看下视频编辑之后的资源组合是怎么样的:

iOS视频编辑从设计到实现-(3)框架设计 - 图3

  • 时间线 :首先有个时间线的概念,我们所有的资源都是位于时间上的一段,每个资源都处于时间线上,所有操作也都是位于时间下上的一段时间。时间线的长度也就代表了最终视频合成的长度。
  • 轨道:以时间线为坐标系的容器,容器内存放的是每个时间点需要的内容素材及编辑功能,可以分为视频轨道,音频轨道,画中画轨道、文字轨道

然后我们对上面图的编辑模型做描述

  • 我们创建一个时间长度为100s的视频,拥有五条轨道,视频轨道包含转场描述、滤镜描述
  • 视频轨道分两部分,一部分是基本视频,一部分是画中画视频
    • 基础视频:包含5个视频段,分别位于两个视频轨道,类型都是video,所在位置分别为[0—30]、[42—55]、[75—100]、[25—45]、[60—78]
    • 画中画:只包含一个视频段,类型是pip,所在位置是[8—92]
  • 图片轨道:包含两个图片,类型是image,位置为:[28—45]、[62—90]
  • 音频轨道:实例中只包含一条视频轨道,一个视频段,位置为[0—100],实际中可以跟视频一样有多条轨道,多个视频段
  • 转场、滤镜描述:在这里也可以把转场滤镜当作完整的轨道,拥有类型和位置描述,跟视频段一样

有了上述的描述,我们可以将描述转换为相应的代码:

时间线:

我们创建一个时间线的描述类:FXTimelineDescribe,它将拥有下面这些属性,用来存放各个轨道描述文件

  1. CMTime duration; //时间线总时长
  2. NSMutableArray<FXVideoDescribe *> *videoArray; //视频轨道视频段描述数组
  3. NSMutableArray<FXPIPVideoDescribe *> *pipVideoArray; //画中画
  4. NSMutableArray<FXAudioDescribe *> *audioArray; //音频(视频资源中自带的音频)
  5. NSMutableArray<FXTransitionDescribe *> *transitionArray; //转场描述
  6. NSMutableArray<FXMusicDescribe *>*musicArray; //音乐描述
  7. NSMutableArray *filterArray; //滤镜描述
  8. NSMutableArray *titleArray; //字幕描述
  9. NSMutableArray *overlayArray; //水印描述

轨道描述:

根据类型不同,我们创建不同类型的轨道描述:

  1. typedef NS_ENUM(NSUInteger, FXDescribeType) {
  2. FXDescribeTypeNone,
  3. FXDescribeTypeVideo, //视频
  4. FXDescribeTypeAudio, //音频
  5. FXDescribeTypeTransition, //转场
  6. FXDescribeTypeTitle, //字幕
  7. FXDescribeTypePip, //画中画
  8. FXDescribeTypeMusic, //音乐
  9. FXDescribeTypeRecord //配音
  10. };

然后抽出轨道描述相同的部分作为轨道内容描述的基类:FXDescribe

  1. @interface FXDescribe : NSObject
  2. @property (nonatomic, assign) CMTime startTime; //开始时间
  3. @property (nonatomic, assign) CMTime duration; //持续时间
  4. @property (nonatomic, assign) CMTimeRange sourceRange; //在源资源中的位置
  5. @property (nonatomic, assign) CGFloat scale; //变速的倍速
  6. @property (nonatomic, assign) FXDescribeType desType; //类型
  7. - (NSDictionary *)objectDictionary;
  8. - (void)setObjectWithDic:(NSDictionary *)dic;
  9. @end

其中添加了一个sourceRange属性,用来描述当前资源在源资源中的位置,比如视频段截取自某一个视频的一部分。

其中有两个方法需要子类重写的

1、(NSDictionary )objectDictionary; 2、(void)setObjectWithDic:(NSDictionary )dic;

1、(NSDictionary *)objectDictionary;

用来将描述转换为字典,后续组合起来转换为描述JSON,子类需要重写这个方法,然后将子类新增的属性也添加进来

2、(void)setObjectWithDic:(NSDictionary *)dic

这部分相当于数模转换,在这里没有使用自动数模转化(比如用YYModel),因为要处理一些比较特殊的数据,还有部分资源的查找。

接下来我们看下视频轨道的视频段是怎么描述的:

我们创建FXVideoDescribe,继承自:FXDescribe,我们需要添加视频特有的一些描述属性

  1. @property (nonatomic, assign) NSInteger videoIndex; //视频段编号,后续用来做视频排序
  2. @property (nonatomic, assign) BOOL reverse; //视频反转
  3. @property (nonatomic, readonly) FXRotation rotate; //视频旋转,支持90、180、270度的旋转
  4. @property (nonatomic, strong) FXVideoItem *videoItem; //视频资源,包含真实的视频音频轨道,帧缩略图资源等
  5. @property (nonatomic, assign) BOOL mute; //是否静音

跟视频段描述相似,其他类型的描述添加各自需要的属性。

最终把时间线转换为字典:(我们添加两段视频,添加一个转场特效,然后将第一个视频分割为两段)我们来看下生成的描述字典

  1. {
  2. audioTrack = (
  3. );
  4. defaultNaturalSizeHeight = 1080;
  5. defaultNaturalSizeWidth = 608;
  6. duration = "30.182";
  7. lengthTimeScale = 30;
  8. mainVideoVolume = 100;
  9. pipVideoVolume = 100;
  10. transitionTrack = (
  11. {
  12. backVideoIndex = 1;
  13. desType = 3;
  14. duration = 2;
  15. preVideoIndex = 0;
  16. scale = 1;
  17. sourceRangeDuration = nan;
  18. sourceRangeStart = nan;
  19. startTime = 0;
  20. transType = 1;
  21. }
  22. );
  23. videoTrack = (
  24. {
  25. desType = 1;
  26. duration = "5.471666666666667";
  27. filterType = 2;
  28. mute = 0;
  29. reverse = 0;
  30. rotate = 0;
  31. scale = 1;
  32. sourceRangeDuration = "5.471666666666667";
  33. sourceRangeStart = 0;
  34. startTime = 0;
  35. videoIndex = 0;
  36. videoItem = "file:///var/mobile/Media/PhotoData/CPLAssets/group115/480C781E-4B41-48A3-B367-484F5C693464.MP4";
  37. },
  38. {
  39. desType = 1;
  40. duration = "8.389333333333333";
  41. filterType = 2;
  42. mute = 0;
  43. reverse = 0;
  44. rotate = 0;
  45. scale = 1;
  46. sourceRangeDuration = "8.389333333333333";
  47. sourceRangeStart = "5.471666666666667";
  48. startTime = "3.471666666666667";
  49. videoIndex = 1;
  50. videoItem = "file:///var/mobile/Media/PhotoData/CPLAssets/group115/480C781E-4B41-48A3-B367-484F5C693464.MP4";
  51. },
  52. {
  53. desType = 1;
  54. duration = "18.321";
  55. filterType = 2;
  56. mute = 0;
  57. reverse = 0;
  58. rotate = 0;
  59. scale = 1;
  60. sourceRangeDuration = "18.321";
  61. sourceRangeStart = 0;
  62. startTime = "11.861";
  63. videoIndex = 2;
  64. videoItem = "file:///var/mobile/Media/PhotoData/CPLAssets/group259/98D7CEA4-69EA-4EB8-924C-FB99DCDBBDD7.MP4";
  65. }
  66. );
  67. }

转换为JSON 字符串:

  1. {"pipVideoVolume":"100","mainVideoVolume":"100","transitionTrack":[{"scale":1,"desType":3,"backVideoIndex":"1","sourceRangeDuration":"nan","duration":"2","preVideoIndex":"0","startTime":"0","sourceRangeStart":"nan","transType":"1"}],"defaultNaturalSizeWidth":"608","duration":"30.182","audioTrack":[],"lengthTimeScale":"30","videoTrack":[{"scale":1,"reverse":"0","rotate":"0","desType":1,"sourceRangeStart":"0","sourceRangeDuration":"5.471666666666667","filterType":"2","videoIndex":"0","videoItem":"file:\/\/\/var\/mobile\/Media\/PhotoData\/CPLAssets\/group115\/480C781E-4B41-48A3-B367-484F5C693464.MP4","duration":"5.471666666666667","mute":"0","startTime":"0"},{"scale":1,"reverse":"0","rotate":"0","desType":1,"sourceRangeStart":"5.471666666666667","sourceRangeDuration":"8.389333333333333","filterType":"2","videoIndex":"1","videoItem":"file:\/\/\/var\/mobile\/Media\/PhotoData\/CPLAssets\/group115\/480C781E-4B41-48A3-B367-484F5C693464.MP4","duration":"8.389333333333333","mute":"0","startTime":"3.471666666666667"},{"scale":1,"reverse":"0","rotate":"0","desType":1,"sourceRangeStart":"0","sourceRangeDuration":"18.321","filterType":"2","videoIndex":"2","videoItem":"file:\/\/\/var\/mobile\/Media\/PhotoData\/CPLAssets\/group259\/98D7CEA4-69EA-4EB8-924C-FB99DCDBBDD7.MP4","duration":"18.321","mute":"0","startTime":"11.861"}],"defaultNaturalSizeHeight":"1080"}

至此我们的视频编辑描述部分就完成了,我们可以根据描述文件反推会相应的描述模型。因此,我们可以将每次修改之后的描述JSON文件存储起来,作为修改的一个状态,用来做撤销和重做操作(也可以使用NSUndoManager来实现撤销重做,但是相对来说比较复杂一点)。

有了编辑描述之后,我们就可以根据编辑描述来构建UI,构建播放、导出模块。最终的结构如下图

iOS视频编辑从设计到实现-(3)框架设计 - 图4