前言-吐槽篇
在这个短视频遍地的时代,小程序端视频剪辑功能似乎一直是缺失状态,前段时间发布了一个视频编辑器的接口,试了下感觉也很鸡肋。也有在社区看到前辈们试水小程序音视频合成初探,研究了一番,其中涉及到几个重要的api:1-视频解码器,将视频解码,获得帧数据绘制到canvas上;2-音视频合成器,负责向原视频中添加音频;3-画面录制器,最后通过画面录制器导出成视频。这里必须要吐槽下小程序的文档,简约派代表,能省略的绝不多写一句,翻一下上述api文档便可石锤,几乎全靠猜,这么复杂的接口也不提供个demo。曾尝试按照上述思路做一些复杂的视频剪辑功能,比如图片视频合成一个,单单解决音频错位的问题就搞得头秃,可以说是步履维艰……
正文开始
最近发现小程序发布了一款视频剪辑插件 - 微剪,看了下官方提供的Demo还挺有趣的,相比于上述API功能更完善,感觉小程序一直在探索新姿势,真香啊。
小程序:是的,我啥都能做[手动狗头]
本文前半部分旨在结合上述插件,输出一个简单的视频剪辑小程序,感兴趣(不想动手只想白嫖)的同学也可以直接在小程序中搜索「微剪插件演示」,感受一下插件的功能;后半部分着重介绍下插件的一些核心思想和高级用法。
插件接入
微剪以__插件__的形式提供给开发者,不能单独使用,所以我们必须要__先有个小程序主体__(不会吧阿sir,这都想白嫖我?如何注册小程序请自行百度好伐)。插件的接入流程也比较简单,官方文档 写的比较清楚,这里就不啰嗦了。目前好像处于公测阶段,可以免费申请使用。
代码开发
上述准备工作做完后,我们就可以在代码中使用微剪插件了,插件暴露了两个组件clip和export,clip是入口组件,export为出口组件,其间的流程完全被插件接管,开发者无需关心也无法干涉(有点霸道总裁的味道了啊),通过一系列简单的配置后就可以看到插件的全貌了。【2020/09/23日 删除】
【2020/09/23日 新增】最新版插件提供两种开发模式,暂称为自动模式(仅通过少许配置即可)和手动模式(利用插件暴露的组件自定义开发,1.3.0版本新增)。先介绍第一种方式,按照下述步骤走一遍,应该就能看到插件的全貌了,问题不大。
step1:app.json 引入插件配置
参考这里:小程序插件配置
{
"plugins": {
"myPlugin": { // 自定义插件名,后面会用到
"version": "1.3.0", // 最新版本号即可
"provider": "wx76f1d77827f78beb" // 插件ID,固定值
}
}
}
step2:页面中添加插件
新建一个页面,在页面的json文件中引入插件暴露的clip组件
{
"usingComponents": {
"my-clip": "plugin://myPlugin/clip"
}
}
my-clip: 自定义组件名字,在wxml中使用;
myPlugin: 引入插件的时候,在app.json中声明的插件名;
clip: 固定值,官方暴露的组件名称。
wxml文件:
<view>
<my-clip settings="{{settings}}"></my-clip>
</view>
js文件:
Page({
data: {
settings: {
common: {
videoMaxDuration: 30, // 小程序限制最多拍摄30秒
chooseMaxDuration: 1000, // 选择视频的默认时长限制
clipMaxDuration: 60, // 裁切时长的默认限制
}
}
},
})
settings有众多配置项,具体可参考官方文档;不设置也可以,插件提供了一套默认的配置项。
PS:这里得吐槽下插件的主题机制,感觉很繁琐。跟平时写vue组件等通过样式覆盖的方案不同,而是通过一系列配置项进行配置,这也意味着只能修改配置项涉及的UI,其他的一律无法修改。这里有个疑问,为什么不采用外部样式类的方案呢?感觉更灵活啊。
到这里,应该就可以在手机上看到插件的全貌了,截取了几张图如下:
step3:导出视频
点击上图的「下一步」按钮,就可以导出视频到相册中了。插件本身也支持自定义导出,下面简单介绍下用法:
step3-1:新增导出页
创建一个自定义的导出页,在导出页中引入插件提供的导出组件即可。
json文件:
{
"usingComponents": {
"my-export": "plugin://myPlugin/export"
}
}
wxml文件:
<view>
<view>自定义导出界面部分</view>
<my-export bindexportsuccess="handleExportSuccess">
<button>自定义导出按钮</button>
</my-export>
</view>
然后在js文件中处理导出成功回调即可,导出组件提供了一系列属性,包括水印,导出进度等,具体导出数据结构及属性,可参考官方文档。
step3-2:配置导出页路径
新增上述导出页后,还需要通知插件导出页路径。还记得上文提到的 my-clip 组件中的settings属性吗,此为插件的配置项入口,在里面配置下即可。
data: {
settings: {
common: {
videoMaxDuration: 30, // 小程序限制最多拍摄30秒
chooseMaxDuration: 1000, // 选择视频的默认时长限制
clipMaxDuration: 60, // 裁切时长的默认限制
exportPagePath: '/pages/export/export' // 自定义导出页面地址
}
}
}
事已至此,通过开发工具的预览功能便可以在手机中看到自定义的导出页了。
【2020/09/23日 新增】
高级模式(1.3.0版本新增)
还记得上文提到的简单(自动)模式,我们只用到了插件暴露的两个组件,clip和export,难道插件只暴露了两个组件吗?就这?就这???
插件从1.3.0版本开始,开放了更多的组件出来,也提供了一种称为【高级接入】的方式,接下来让我们一探究竟:
高级模式需要先了解插件的数据机制,即下文提到的Track驱动模式,在此基础上可以结合插件暴露的组件进行自定义创作。
标准的数据驱动模式
高级模式,即文章开头提到的手动模式,遵循一套标准的Track数据结构,此处引用官方文档的一张图和介绍
什么是Track?
track顾名思义为轨道,播放器支持多种轨道,如:媒体(视频 或者 图片), 音乐, 特效, 滤镜, 文字 等。
每一种类型的轨道对应不同类型的track,可以简单的理解track就是轨道。什么是Clip?
clip是轨道中的素材片段。举个例子: 你需要编辑三段视频,那么你需要将三个视频片段加入媒体轨道中,那么这里的每个视频片段就对应不同的clip,每个clip的属性会不相同,如: 视频时长,开始结束时间等。
上述官方定义看得有点懵?大白话简单理解,画面播放的时候,我们在某一时刻可以看到(听到)很多元素,比如原始画面的一帧,特效的一部分(几个小心心),滤镜,文字以及音乐等,Track就是这些元素的数据载体,不同的元素由不同类型的Track管理,所以Track是个数组。
随着时间轴的推移,我们又可以看到不同的视频、图片、特效切换效果等等,比如由图片A切换到图片B,再切换到C、由特效A切换到特效B等,这个时候就涉及到clip的概念,clip可以简单理解为上述Track的一个属性,里面存放着原始素材的信息(基本信息,展示时长等),比如上述🌰,负责媒体的Track的clips就得包含图片A、图片B以及图片C的信息,负责特效的Track的clips就得包含特效A和特效B的信息。
官方提供了详细的Track以及Clip的数据接口定义,具体请参考官方文档 数据结构文档 部分,这里只做核心思想解释。
积木已有,创作在你
最新版本(1.3.0)暴露的几个核心组件:
- wj-player:插件的核心播放器组件,对应的上述简单模式截图里的播放视频的容器;
- wj-camera:相机组件,可以拍摄视频,也可以从相册选择视频、图片,截图里的图片、视频就是从这来的;
- wj-clipper:裁切器组件,上述截图中可以看到视频一帧一帧画面的那部分,就是用这个组件实现的。
上述组件即插件实现的核心部分,还有个文字组件(wj-textEditor、向视频中添加文字)和导出组件(wj-export、与自动模式的导出组件基本相同)使用比较简单,每个组件都有各自的属性和方法,具体请参考高级组件API文档。结合标准的Track数据驱动,理论上可以搭配出与插件功能完全一致的视频剪辑体验,当然如果自动模式可以满足业务需求,那白嫖它,做条闲鱼不好吗?如果业务需求需要个性化,可以考虑使用上述手动模式,自力更生,丰衣足食。
光说不练假把式
下面通过一个简单的 相册集小demo 演示一下上述组件的用法,可以下载小程序片段到本地运行(注:由于插件的使用需要先申请资格,所以请填写一个有使用权限的小程序AppID)。主要使用了三个组件,wj-camera(收集素材)、wj-player(展示素材和特效)、wj-export(导出视频),通过camera页面从相册选择图片,然后搭配插件内置的一些特效输出到player页面中预览效果,最后通过export页面导出。
核心代码如下:
- camera 页面
camera.wxml
<!--pages/camera/camare.wxml-->
<wj-camera bindmediachanged="onMediaChanged" >
<view class="preview-button" catchtap="onClickDefaultBtn">
<navigator data-source="jump" url="../player/player?type=default" hover-class="none">
<text>随机特效</text>
</navigator>
</view>
<view class="preview-button" style="right:230rpx;width:240rpx" catchtap="onClickPointBtn">
<navigator data-source="jump" url="../player/player?type=wyyl" hover-class="none">
<text>卡点视频(6图)</text>
</navigator>
</view>
</wj-camera>
camera.js
// pages/camera/camare.js
Page({
data:{
_media:[]
},
onMediaChanged(e){
this.data._media = e.detail.track
},
onClickDefaultBtn(){
global.testMedia = JSON.parse(JSON.stringify(this.data._media))
},
// 【万有引力】卡点设置
onClickPointBtn(){
let durationList = [2.6, 2.6, 2.6, 2.8, 2.8, 2.6], startAt = 0
let copyMedia = JSON.parse(JSON.stringify(this.data._media))
copyMedia.clips.forEach((item,index) => { // 更新卡点视频图片播放时长
let duration = durationList[index]
item.section.end = duration
item.section.duration = duration
item.startAt = startAt
startAt += duration
});
copyMedia.duration = startAt
global.testMedia = copyMedia
}
})
- player 页面
player.wxml
// pages/player/player.js
Page({
data: {
styleConfig: { height: 1000, width: 750 },
type: 'default'
},
onLoad: function (option) {
this.setData({
type: option.type
})
},
// 生命周期函数--监听页面初次渲染完成
onReady: function () {
this.bindPlayer()
},
// 绑定player
bindPlayer() {
this.player = this.selectComponent("#player");
},
// 设置player组件的数据
async onPlayerReady() {
let tracks = []
if (this.data.type === 'default') {
tracks = this.getDefaultTrack()
} else {
tracks = this.getWYYLTrack()
}
this.player.updateData(tracks) // 更新player的数据
global.testExportTracks = tracks // 存储导出页数据
},
// 随机特效
getDefaultTrack() {
let startAt = -3, { TRACK_TYPES, CLIP_TYPES, Clip, ClipSection, Track } = global['wj-types']
let mediaTrack = global.testMedia // 获取camera组件输出的media轨道
let effectTrack = new Track({ // 新建一个effect轨道
type: TRACK_TYPES.EFFECT,
clips: []
})
let effectList = this.player.getEffects(); // 获取player内置的特效
let randomEffects = new Array(mediaTrack.clips.length).fill(null).map((item, index) => {
startAt += 3
return new Clip({
id: `effect-${index}`,
type: CLIP_TYPES.EFFECT,
key: this._getRandomEffect(effectList).key,
section: new ClipSection({
start: 0,
end: 3
}),
startAt: startAt
})
})
effectTrack.clips = randomEffects // 更新effect轨道的clips数据
return [mediaTrack, effectTrack]
},
// 【万有引力】卡点特效
getWYYLTrack() {
let { TRACK_TYPES, CLIP_TYPES, Clip, ClipSection, Track } = global['wj-types']
let mediaTrack = global.testMedia // 获取camera组件输出的media轨道
let effectTrack = new Track({ // 新建一个effect轨道
type: TRACK_TYPES.EFFECT,
clips: []
})
// 定制卡点特效
let effectList = [
{ key: 'Swing', duration: 2.6, startAt: 0 },
{ key: 'SoulOut', duration: 0.6, startAt: 2.6 },
{ key: 'Shining', duration: 2, startAt: 3.2 },
{ key: 'SoulOut', duration: 0.6, startAt: 5.2 },
{ key: 'Blink', duration: 2, startAt: 5.8 },
{ key: 'SoulOut', duration: 0.6, startAt: 7.8 },
{ key: 'LightCircle', duration: 2.2, startAt: 8.4 },
{ key: 'SoulOut', duration: 0.6, startAt: 10.6 },
{ key: 'FlowingLight', duration: 2.2, startAt: 11.2 },
{ key: 'SoulOut', duration: 0.6, startAt: 13.4 },
{ key: 'Heart', duration: 2, startAt: 14 },
]
let randomEffects = new Array(mediaTrack.clips.length * 2 - 1).fill(null).map((item, index) => {
return new Clip({
id: `effect-${index}`,
type: CLIP_TYPES.EFFECT,
key: effectList[index].key,
section: new ClipSection({
start: 0,
end: effectList[index].duration
}),
startAt: effectList[index].startAt
})
})
effectTrack.clips = randomEffects // 更新effect轨道的clips数据
let musicTrack = new Track({ // 新建一个music轨道
type: TRACK_TYPES.MUSIC,
clips: []
})
let bgMusic = new Clip({
id: 'music',
type: CLIP_TYPES.MUSIC,
info: {
tempFilePath: "https://imgcache.qq.com/operation/dianshi/other/wanyouyinli.c7f973d906d9b8a3e7db90a90a7874d01454614b.mp3",
},
section: new ClipSection({
start: 0,
end: 1000
}),
startAt: 0
})
musicTrack.clips = [bgMusic]
return [mediaTrack, effectTrack, musicTrack]
},
// 跳转至导出页
goExport() {
wx.navigateTo({
url: '../export2/export'
})
},
// 获取随机特效
_getRandomEffect(effectList = []) {
let index = Math.floor(Math.random() * effectList.length)
return effectList[index]
}
})
- export 页面
export.wxml
<wj-export tracks="{{exportTracks}}"
bindexportsuccess="onExportSuccess"
bindready="onExportReady">
<button>导出视频</button>
</wj-export>
export.js
// pages/export2/export.js
Page({
data: {
exportTracks: []
},
// 设置导出数据
onExportReady() {
this.setData({
exportTracks: global.testExportTracks
})
},
// 导出成功回调
onExportSuccess(e){
let res = e.detail;
wx.saveVideoToPhotosAlbum({
filePath: res.tempFilePath,
success: res => {
wx.showToast({
title: "已保存至相册"
});
}
});
}
})
camera和export页面比较简单,这里就不啰嗦了。主要说下player页面的实现思路,player的Track数据暂时只涉及到了三种:media类型、effect类型以及music类型(卡点模式用),其他的暂未使用。
- 随机模式:针对每张图随机获得一个特效key(wj-player组件内置了一些特效,直接使用即可),然后设置其时长与media类型的clip时长一致即可(默认是3s)。
- 卡点模式:根据音乐节奏调整了下每张照片的展示时长。
这样一个简单的照片集应用就做出来啦,so easy,妈妈再也不用担心我的发际线了。
卡点模式最终的成果物 点此预览
PS:使用过程中发现 wj-player 组件和 wj-export 组件无法在同一个页面中使用,不知道是不是我的姿势不对,有待后续验证;还有就是素材切换的时候没有转场动画,显得有些僵硬;另外看了下,特效倒是挺多的,但是没办法一键输出卡点视频,缺少一些常见的视频模板,自己做卡点视频特别费劲,要微调好久……
自动挡虽然好开,但是真正的赛车手都用手动挡[手动狗头结束]
总结
作为小程序端的一款视频剪辑工具,麻雀虽小五脏俱全,基础的功能是齐全的。个人使用下来整体体验还不错,接入也比较简单,官方配套文档也很完善,也期待官方后续可以提供更丰富的功能、组件。
最后,对视频剪辑小程序感兴趣的同学不防申请插件尝试一下,在官方基础api让人如此头秃的情况下,使用插件或许是个不错的选择。