1.需求背景
如何开发一个类似剪影或抖音的视频剪辑工具?
其开发任务如上图,一个短视频生产app的首要任务在于实现一个高度可实时交互的播放器,在播放预览时支持多种编辑能力。
最初我们调研了多种方案,乍一看Android原生播放器肯定不够用,估计要在众多c++的开源播放器中寻找参考方案,最好自己实现一个播放器,高度灵活高度可控。然而我们发现exo这个男团播放器的厉害之处,虽然这个播放器如此常用,但是我们不知道其潜力值爆表,可以拓展得如此强大。
事实上直到现在,我们仍然在自研视频剪辑工具中使用exoplayer做编辑预览。为什么选择exoplayer,基于以下几点原因(一句话,性价比高):
谷歌官方出品的开源库,易于自定义和扩展,exoplayer专门为此做了设计,准许很多组件可以被自定义的实现类替换
java编写,相比于native code,开发更容易,更清楚的获得一些异常源和进行部分代码调试
较少的设备兼容问题
2.技术概述
使用基于exoplayer播放器进行二次开发,快速高效实现视频剪辑功能。视频剪辑播放器用于视频编辑过程中的实时预览播放,支持有功能有:
图片和视频多素材混合播放
实时裁剪
实时旋转
实时变速
实时添加文字贴纸
实时美颜滤镜
添加素材之间转场
添加音乐
3.技术思路
针对上述视频剪辑所需要支持的功能,逐一对照explayer的api文档,寻找拓展实现的方法。
功能 | explayer是否已有api支持 | 拓展使用的基类和接口(不需要拓展使用的api) |
---|---|---|
图片播放 | 不支持,可拓展 | DefaultRenderersFactory,BaseRenderer,BaseMediaSource,MediaPeriod,SampleStream, |
视频裁剪 | 已有支持 | ClippingMediaSource |
素材拼接 | 已有支持 | ConcatenatingMediaSource |
视频旋转 | 不支持 | SimpleExoPlayer.setVideoSurface |
视频变速 | 已有支持 | SimpleExoPlayer.setPlaybackParameters |
文字贴纸 | 不支持 | SimpleExoPlayer.setVideoSurface |
美颜滤镜 | 不支持 | SimpleExoPlayer.setVideoSurface |
素材转场 | 不支持 | SimpleExoPlayer.setVideoSurface |
添加音乐 | 不支持 | 独立实现 |
抽帧预览 | 不支持 | 独立实现 |
其中,视频旋转、文字贴纸、美颜滤镜、素材转场需要调用setVideoSurface控制视频呈现层,自定义GLSurfaceView,使用opengl实现对视频的旋转、美颜滤镜、添加贴纸。exoplayer播放输出的surface与自定义GLSurfaceView的渲染纹理相绑定。
4.技术实现
4.1裁剪、拼接、变速
视频裁剪播放使用ClippingMediaSource设置裁剪素材,按api文档传入起始时间和结束时间。
public ClippingMediaSource(MediaSource mediaSource, long startPositionUs, long endPositionUs) {
this(
mediaSource,
startPositionUs,
endPositionUs,
/* enableInitialDiscontinuity= */ true,
/* allowDynamicClippingUpdates= */ false,
/* relativeToDefaultPosition= */ false);
}
多个视频拼接播放,使用ConcatenatingMediaSource可以用来无缝地合并播放多个素材,为了能对单个素材进行编辑,isAtomic设为true。
public ConcatenatingMediaSource(boolean isAtomic, MediaSource... mediaSources) {
this(isAtomic, new DefaultShuffleOrder(0), mediaSources);
}
变速使用setPlaybackParameters设置速度参数
SimpleExoPlayer simpleExoPlayer = player.getExoPlayer();
if (simpleExoPlayer != null) {
simpleExoPlayer.setPlaybackParameters(new PlaybackParameters(speed));
}
这三个功能使用exoplayer已提供的api就可以实现,相对容易。在执行编辑操作后即时更新播放器素材和参数即可。在我们的产品中,有一个撤销操作的交互,所以需要保留一份数据拷贝,如果用户撤销操作则更新为原来的数据。
4.2图片播放
exoplayer本身不支持图片格式的素材播放。注入一个自定义渲染器来实现图片(格式为jpg、png、gif等)
public class CustomRenderersFactory extends DefaultRenderersFactory {
@Override
protected void buildTextRenderers(Context context,
TextOutput output,
Looper outputLooper,
int extensionRendererMode,
ArrayList<Renderer> out) {
super.buildTextRenderers(context, output, outputLooper, extensionRendererMode, out);
}
@Override
protected void buildVideoRenderers(
Context context,
@ExtensionRendererMode int extensionRendererMode,
MediaCodecSelector mediaCodecSelector,
@Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager,
boolean playClearSamplesWithoutKeys,
boolean enableDecoderFallback,
Handler eventHandler,
VideoRendererEventListener eventListener,
long allowedVideoJoiningTimeMs,
ArrayList<Renderer> out) {
super.buildVideoRenderers(context,
extensionRendererMode,
mediaCodecSelector,
drmSessionManager,
playClearSamplesWithoutKeys,
enableDecoderFallback,
eventHandler,
eventListener,
allowedVideoJoiningTimeMs,
out);
out.add(new ImageRenderer(eventHandler, eventListener));
}
public CustomRenderersFactory(Context context) {
super(context);
}
其中ImageRender继承BaseRenderer,实现了图片的自定义渲染。render主要工作是将每帧数据解码流渲染为屏幕图像。对于图片来说,我们定义ImageMediaSourceImage、SampleStreamImpl和ImageMediaPeriod,分别继承于BaseMediaSource、SampleStream和MediaPeriod,从原素材解析并传送每帧图片数据。图片不需要真正的解码,实现SampleStream的readData方法读取图片uri为解码buffer。
@Override
public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer,
boolean requireFormat) {
if (streamState == STREAM_STATE_END_OF_STREAM) {
buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM);
return C.RESULT_BUFFER_READ;
} else if (requireFormat || streamState == STREAM_STATE_SEND_FORMAT) {
formatHolder.format = format;
streamState = STREAM_STATE_SEND_SAMPLE;
return C.RESULT_FORMAT_READ;
} else if (loadingFinished) {
if (loadingSucceeded) {
buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME);
buffer.timeUs = 0;
if (buffer.isFlagsOnly()) {
return C.RESULT_BUFFER_READ;
}
buffer.ensureSpaceForWrite(sampleSize);
buffer.data.put(sampleData, 0, sampleSize);
} else {
buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM);
}
streamState = STREAM_STATE_END_OF_STREAM;
formatHolder.format = format;
return C.RESULT_BUFFER_READ;
}
return C.RESULT_NOTHING_READ;
}
实现图片播放的核心在于实现render接口:
void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException;
在这个方法内,我们创建opengl环境,将bitmap绘制到屏幕上
public void drawToBitmap(Bitmap bitmap) {
//清空屏幕
GLES20.glClearColor(0, 0, 0, 1);
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
if (bitmap == null || bitmap.isRecycled()) {
return;
}
setBitmapSize(bitmap.getWidth(), bitmap.getHeight());
initBuffer();
vertexShader = loadShader(mVertex, GL_VERTEX_SHADER);
fragmentShader = loadShader(mFragment, GL_FRAGMENT_SHADER);
int mGLProgram = createProgram(vertexShader, fragmentShader);
mGLVertexCo = GLES20.glGetAttribLocation(mGLProgram, "aVertexCo");
mGLTextureCo = GLES20.glGetAttribLocation(mGLProgram, "aTextureCo");
mGLVertexMatrix = GLES20.glGetUniformLocation(mGLProgram, "uVertexMatrix");
mGLTextureMatrix = GLES20.glGetUniformLocation(mGLProgram, "uTextureMatrix");
mGLTexture = GLES20.glGetUniformLocation(mGLProgram, "uTexture");
//绘制bitmap纹理texture
int[] texture = new int[1];
GLES20.glGenTextures(1, texture, 0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture[0]);
//设置缩小过滤为使用纹理中坐标最接近的一个像素的颜色作为需要绘制的像素颜色
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
//设置放大过滤为使用纹理中坐标最接近的若干个颜色,通过加权平均算法得到需要绘制的像素颜色
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
//设置环绕方向S,截取纹理坐标到[1/2n,1-1/2n]。将导致永远不会与border融合
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
//设置环绕方向T,截取纹理坐标到[1/2n,1-1/2n]。将导致永远不会与border融合
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
GLES20.glUseProgram(mGLProgram);
GLES20.glUniformMatrix4fv(mGLVertexMatrix, 1, false, mVertexMatrix, 0);
GLES20.glUniformMatrix4fv(mGLTextureMatrix, 1, false, mTextureMatrix, 0);
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture[0]);
GLES20.glUniform1i(mGLTexture, 0);
GLES20.glEnableVertexAttribArray(mGLVertexCo);
GLES20.glVertexAttribPointer(mGLVertexCo, 3, GLES20.GL_FLOAT, false, 0, mVertexBuffer);
GLES20.glEnableVertexAttribArray(mGLTextureCo);
GLES20.glVertexAttribPointer(mGLTextureCo, 2, GLES20.GL_FLOAT, false, 0, mTextureBuffer);
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
GLES20.glDisableVertexAttribArray(mGLVertexCo);
GLES20.glDisableVertexAttribArray(mGLTextureCo);
}
4.3文字贴纸
添加的文字或贴纸支持移动、旋转、缩放和设置时间轴。对于多个文字贴纸,我们最终包装为一个与渲染屏幕同尺寸的bitmap,在这个bitmap的画布上绘制一系列带坐标大小、起止时间的小bitmap(即stickerItem.getBitmap)。
@Override
protected void drawCanvas(Canvas canvas) {
for (VideoStickerItem stickerItem : stickerList) {
Bitmap show = stickerItem.getBitmap();
if (presentationTimeUs > stickerItem.getStartTimeMs() * (1000 * 1000) && presentationTimeUs < stickerItem.getEndTimeMs() * (1000 * 1000)) {
canvas.drawBitmap(show, (canvas.getWidth() - show.getWidth()) / 2 + stickerItem.getLeft(), (canvas.getHeight() - show.getHeight()) / 2 + stickerItem.getTop(), null);
}
}
}
将这张贴纸画布bitmap与原视频帧像素混合就实现了所有文字贴纸的绘制。用opengl绘制贴纸,就是对屏幕上像素做一个水印滤镜的运算。采用GLSL内建的mix函数做两个纹理的混合,以下是水印滤镜所用的片元着色器。
private final static String FRAGMENT_SHADER =
"precision mediump float;\n" +
"varying vec2 vTextureCoord;\n" +
"uniform lowp sampler2D sTexture;\n" +
"uniform lowp sampler2D oTexture;\n" +
"void main() {\n" +
" lowp vec4 textureColor = texture2D(sTexture, vTextureCoord);\n" +
" lowp vec4 textureColor2 = texture2D(oTexture, vTextureCoord);\n" +
" \n" +
" gl_FragColor = mix(textureColor, textureColor2, textureColor2.a);\n" +
"}\n";
4.4美颜滤镜
和文字贴纸一样,要实现实时的美颜滤镜效果,必须使用帧缓冲fbo。帧缓冲的每一存储单元对应着屏幕每一个像素。而美颜滤镜涉及较复杂算法,由部门内的人工智能组提供sdk接入,在绘制过程中调用sdk方法如下,就是使用fbo进行一次图像纹理转换。传入参数为屏幕方向、摄像头方向和渲染尺寸。
int texId = aiBeautySuite.faceEffectProcessFromVideo(godlikeFramebufferObject.getTexName(),
null,
godlikeFramebufferObject.getWidth(),
godlikeFramebufferObject.getHeight(),
0,
true
);
4.5转场
目前产品实现了左右移、上下移、拉近拉远、顺时针逆时针旋转等几种转场效果。转场的实现方法是:对于两个在其中添加了转场的素材,在上一个素材的最后1000ms绘制转场滤镜,转场滤镜即将两张图片的像素以一定的规律进行渲染,转场算法由opengl使用glsl着色器实现。转场基类的片元着色器如下,移动转场(左右向移动和上下移动)、缩放转场(拉近拉远)、旋转转场对getFromColor与getToColor执行的行为不同。
public static final String BASE_TRANSITION_FRAGMENT_SHADER =
"precision highp float;\n" +
"varying highp vec2 _uv;\n" +
"uniform sampler2D inputImageTexture;\n" +
"uniform sampler2D inputImageTexture2;\n" +
"uniform highp float progress;\n" +
"uniform highp float offsetY;\n" +
"uniform highp float offsetX;\n" +
"uniform highp float reverse;\n" +
"\n" +
"highp vec4 getFromColor(in highp vec2 uv) {\n" +
" highp float v;\n" +
" if(sign(reverse) >= 0.0){\n" +
" v = 1.0 - uv.y;\n" +
" }else{\n" +
" v = uv.y;\n" +
" }" +
" highp vec2 fromTexture = vec2(uv.x, v);\n" +
" highp vec4 fromColor = texture2D(inputImageTexture, fromTexture);\n" +
" return fromColor;\n" +
"}\n" +
"\n" +
"highp vec4 getToColor(in highp vec2 uv) {\n" +
" if(uv.x < offsetX || uv.x > (1.0 - offsetX) || uv.y < offsetY || uv.y > (1.0 - offsetY)) {\n" +
" return vec4(0, 0, 0, 0);\n" +
" } else {\n" +
" highp float u = (float(uv.x) - offsetX) / (1.0 - offsetX * 2.0);\n" +
" highp float v = (float(1.0 - uv.y) - offsetY) / (1.0 - offsetY * 2.0);\n" +
" highp vec2 toTexture = vec2(u, v);\n" +
" highp vec4 toColor = texture2D(inputImageTexture2, toTexture);\n" +
" return toColor;\n" +
" }\n" +
"}\n" +
"\n" +
"\n%s\n" +
"void main() {\n" +
" gl_FragColor = transition(_uv);\n" +
"}\n";
以移动转场的转场glsl着色器为例
public static final String MOVE_TRANSITION_FRAGMENT_SHADER =
"uniform highp vec2 direction; \n" +
"highp vec4 transition (in highp vec2 uv) {\n" +
" highp vec2 p = uv + progress * sign(direction);\n" +
" highp vec2 f = fract(p);\n" +
" highp vec4 result = mix(getToColor(f), getFromColor(f), step(0.0, p.y) * step(p.y, 1.0) * step(0.0, p.x) * step(p.x, 1.0));\n" +
" return result;\n" +
"}\n";
转场的具体实现参考了GPUImageFilter库,和美颜滤镜以及文字贴纸不同的是,转场滤镜需要在渲染前预先设置将下个素材的首帧图。
4.6添加音乐
在预览编辑过程中,由于音乐并不需要真正合成于视频中,因此可以使用另一个播放器单独播放音频,我们采用android更原始的MediaPlayer单独播放音乐,单独支持音乐的裁剪播放和seek。
4.7抽帧预览
抽帧预览即每隔固定时间取视频的一帧图片构成时间轴,我们使用ffmpegMediaMetadataRetriever库进行抽帧 ,使用方法为
public Bitmap getFrameAtTime(long timeUs, int option)
该库内部使用ffmpeg进行解码取帧,接口易用但是其软件解码方式效率过低,相对较慢。因为exoplayer播放器是默认使用硬件解码的,可以采用另一个exoplayer播放器快速播放一次素材,然后每隔一段时间获取屏幕图像,但此种方法开销过大,两个exoplayer播放器不利于管理。
最后,我们发现常用的图片加载库glide也能进行视频抽帧,使用更为简单方便,其内部采用mediaMetadataRetriever进行抽帧。
GLImageLoader.MyRequestOptions requestOptions = build(context).frameOf(frameTime * 1000L).setAsBitmap().setDefaultPlaceHolder();
requestOptions.load(mediaPath).into(target);
5.应用效果展示
1.调整素材,拼接、裁剪、变速
https://vod.cc.163.com/file/5f896ef25655da63cc2d3237.mp4
2.转场、文字贴纸、美颜滤镜