写点什么

短视频编辑:基于 ExoPlayer 可实时交互的播放器

用户头像
梅芳姑
关注
发布于: 2021 年 04 月 02 日

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 文档传入起始时间和结束时间。


/** * Creates a new clipping source that wraps the specified source and provides samples between the * specified start and end position. * * @param mediaSource The single-period source to wrap. * @param startPositionUs The start position within {@code mediaSource}'s window at which to start *     providing samples, in microseconds. * @param endPositionUs The end position within {@code mediaSource}'s window at which to stop *     providing samples, in microseconds. Specify {@link C#TIME_END_OF_SOURCE} to provide samples *     from the specified start point up to the end of the source. Specifying a position that *     exceeds the {@code mediaSource}'s duration will also result in the end of the source not *     being clipped. */public ClippingMediaSource(MediaSource mediaSource, long startPositionUs, long endPositionUs) {  this(      mediaSource,      startPositionUs,      endPositionUs,      /* enableInitialDiscontinuity= */ true,      /* allowDynamicClippingUpdates= */ false,      /* relativeToDefaultPosition= */ false);}
复制代码


多个视频拼接播放,使用 ConcatenatingMediaSource 可以用来无缝地合并播放多个素材,为了能对单个素材进行编辑,isAtomic 设为 true。


/** * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated *     as a single item for repeating and shuffling. * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link *     MediaSource} instance to be present more than once in the array. */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。


@Overridepublic 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 绘制到屏幕上


/** * 创建OpenGL环境 绘制bitmap纹理 * * @param 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)。


@Overrideprotected 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.转场、文字贴纸、美颜滤镜


https://vod.cc.163.com/file/5f896edad70f81a0e3c77dbe.mp4

发布于: 2021 年 04 月 02 日阅读数: 121
用户头像

梅芳姑

关注

还未添加个人签名 2021.03.31 加入

还未添加个人简介

评论

发布
暂无评论
短视频编辑:基于ExoPlayer可实时交互的播放器