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

1.需求背景

如何开发一个类似剪影或抖音的视频剪辑工具?

短视频生产.png

其开发任务如上图,一个短视频生产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文字贴纸

IMG20201015_101746.png

添加的文字或贴纸支持移动、旋转、缩放和设置时间轴。对于多个文字贴纸,我们最终包装为一个与渲染屏幕同尺寸的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.转场、文字贴纸、美颜滤镜

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容