Android动效探索:彻底弄清如何让你的视频更加酷炫

作者: vivo 互联网客户端团队- Xu Jie

在Android移动端视频处理领域,除了基本的播放功能外,添加动画和滤镜等特效已经成为提升用户体验的重要手段。然而,很多开发人员可能对于实现这些功能所需的技术细节感到困惑。因此,本文旨在提供一个详细的指导,帮助开发人员掌握如何使用开源MediaPlayer或自定义播放器,并利用OpenGL ES来实现视频动画和滤镜效果。

 

1分钟看图掌握核心观点👇

图片

 

从事Android移动端开发的人员一定会跟动效打交道,并且对于常见的帧动画、属性动画使用起来更是得心应手,但是你一定也遇到一些问题,就是在做动效时,你能使用的资源无非就是图片、gif图或者PAG图,这些资源只能做简短、复杂度一般的效果,如果要做一个时间跨度较长并且动效要求较高的动效,这时候就需要借助视频来做了。

 

一、视频做动画,你可能无从下手

我们可以直接使用Mediaplayer、VideoView等开源播放器把UI设计师给我们的视频文件播放出来,一般情况下这样就够了。但是有一天UI设计师让你在视频的第50-100帧做些处理,视频画面做下抖动、放大等的处理,你可能会有些不知所措,这时候你的脑子里面可能有这些概念:

图片

 

那么问题来了,究竟使用什么方案才能实现UI要求的效果?这个时候,你可能会deepseek或者找些技术博客去了解一下,不过结果无非是这样的,仍然是无法把应该具备的知识点串起来:

图片

 

总之这时候的你,还是无从下手!

所以如果没有系统的了解,这时候就有可能使用错方案,达不到效果,比如你可能会想到是不是在原先的视频播放器窗口覆盖一层View,View动态显示截图的视频窗口图片,这种方案就是存在问题的。那么本文就是为了帮助梳理这些知识点,整理出了为了实现视频动效的完整实现流程,话不多说,先看实现结构图:

图片

 

仔细看上面这张结构图,你的零散的知识点也许可以串联起来一些了,但是可能还不够全面!

 

结论先行,实现一个视频动画有两种方式

实现方案1

直接使用开源的MediaPlayer播放器,然后利用OpenGL ES进行图形管线的接管与处理,对每一帧图片再去处理。优点是实现起来更加的方便,可以快速上手,但是缺点就是你只能对既有的视频帧做处理,没办法去修改视频帧底层的逻辑,虽然可以实现复杂的动效,但是仍然是受限的。

 

实现方案2

使用FFmpeg自己手撸一个播放器,要是实现简单动效,就借助原生的ANativeWindow,可以直接操作帧缓冲区(FrameBuffer),属于内存到屏幕的像素级拷贝,没有GPU的参与;或者使用GL介入,做视频纹理的管理,实现更加复杂的动效。这个实现方式缺点是比较复杂,但是最大的优点就是FFmpeg本身可以做到跨平台编译,不止是可以使用在Android,也可以使用在iOS平台。另外可以修改视频的更底层逻辑,满足更多的动效需求,比如类似抖音,有些特效都是可以做的。

 

两个方案有共同点,都需要OpenGL ES进行渲染视图,很多开发者只是了解这个概念,不清楚为什么要使用它,下面我们来彻底讲清楚。

 

二、初识OpenGL ES相关概念

OpenGL,全称是Open Graphics Library,译名:开放图形库或者“开放式图形库”,用于渲染 2D、3D 矢量图形的跨语言、跨平台的应用程序编程接口(API)。OpenGL 跟语言和平台无关。OpenGL 纯粹专注于渲染,而不提供输入、音频以及窗口相关的 API。这些都有硬件和底层操作系统提供。OpenGL 的高效实现(利用了图形加速硬件)存在于 Windows,部分 UNIX 平台和 Mac OS,可以便捷利用显卡等设备。

 

也就是说,OpenGL就是绘制图形使用的,那么你的视频中播放的一帧帧图片,也是图形,所以你要是想做动画,也就是对图形做形变,就需要使用OpenGL帮你绘制出最终的图形。

 

OpenGL ES (OpenGL for Embedded Systems) 是 OpenGL 三维图形 API 的子集,针对手机、PDA和游戏主机等嵌入式设备而设计。经过多年发展,现在主要有两个版本,OpenGL ES 1.x 针对固定管线硬件的,OpenGL ES 2.x 针对可编程管线硬件。Android 2.2 开始支持 OpenGL ES 2.0,OpenGL ES 2.0 基于 OpenGL 2.0 实现。一般在 Android 系统上使用 OpenGL,都是使用 OpenGL ES 2.0,1.0 仅作了解即可。我们在Android开发中,使用的稳定版本,也都是ES 2.0。

 

2.1 坐标系的概念

作为一个Android移动端开发者。应该知道坐标系的概念,物体的位置都是通过坐标系确定的。OpenGL ES 采用的是右手坐标,选取屏幕中心为原点,从原点到屏幕边缘默认长度为 1,也就是说默认情况下,从原点到(1,0,0)的距离和到(0,1,0)的距离在屏幕上展示的并不相同。坐标系向右为 X 正轴方向,向左为 X 负轴方向,向上为 Y 轴正轴方向,向下为 Y 轴负轴方向,屏幕面垂直向上为 Z 轴正轴方向,垂直向下为 Z 轴负轴方向。

图片

 

总结一下:在 OpenGL 中,世界就是一个坐标系,一个只有 X、Y 和 Z 三个纬度的世界,其它的东西都需要你自己来建设,你能用到的原材料就只有点、线和面(三角形),当然还会有其他材料,比如阳光(光照)和颜色(材质)。

 

2.2 相机

在OpenGL中,"相机"的概念类似于现实世界的相机或人眼,其功能是捕获三维世界中的场景,并呈现到二维视图上。通过调整“相机”参数,可以改变观看的角度和范围,从而影响最终呈现的效果。

 

2.3 纹理

纹理是二维图像,用于映射到三维物体的表面上,使其看起来更加真实和细腻。纹理映射是一种重要的渲染技术,通过将纹理应用于物体表面,赋予物体颜色、图案等视觉效果,而不改变其几何形态。纹理的作用类似于为物体穿上“衣服”,提升视觉上的真实感。

 

2.4 OpenGL ES的使用流程

图片

 

通过上面的流程,我们可以确认图形的渲染大致可以表述如下:

  • 管理一个 surface,这个 surface 就是一块特殊的内存,能直接排版到 android 的视图 view 上。

  • 管理一个 EGL display,它能让 opengl 把内容渲染到上述的 surface 上。

  • 用户可以自定义渲染器(render)。

  • 让渲染器在独立的线程里运作,和 UI 线程分离。传统的 View 及其实现类,渲染等工作都是在主线程上完成的。

 

在Android开发中,我们就是借助SurfaceView来进行视图的渲染,SurfaceView的实质是将底层显存 Surface 显示在界面上,而 GLSurfaceView 做的就是在这个基础上增加 OpenGL 绘制环

 

有了上面这些概念之后,那么下面我们从简单的MediaPlayer入手,从图形管线接入的角度,彻底弄清GLSurfaceView的工作原理,再去介绍手撸播放器如何来做。让你的知识点完全串联起来,之前不曾了解的知识点,通过本文也可以进一步的补充。

 

三、轻松上手-MediaPlayer实现视频动画

看一下完整的实现视频动画的流程图:

图片

 

1. OpenGL环境搭建

先看下引用GLSurfaceView的代码结构。第一步是创建一个Activity,并且在布局文件里面构建一个自定义的VideoGLSurfaceView,Activity里面声明该VideoGLSurfaceView准备使用。

布局文件如下:

// 其他代码
<com.ne.firstvideo.gl.VideoGLSurfaceView  
    android:id\="@+id/glSurfaceView"  
    android:layout\_width\="match\_parent"  
    android:layout\_height\="200dp"  
    app:layout\_constraintTop\_toBottomOf\="@+id/original\_surfaceView"\>  
</com.ne.firstvideo.gl.VideoGLSurfaceView\>
// 其他代码

 

VideoGLSurfaceView里面需要创建GlSurView环境

private voidinit(Context context) {  
    // 使用 OpenGL ES 2.0 以兼容更多设备  
    setEGLContextClientVersion(2);  
    // 关键步骤 1: 设置透明背景  
    setEGLConfigChooser(new TransparentConfigChooser());  
    setZOrderOnTop(true); // 必须设置  
    getHolder().setFormat(PixelFormat.TRANSLUCENT); // 必须设置  
  
    renderer \= new VideoRenderer(this);  
    setRenderer(renderer);  
    setRenderMode(RENDERMODE\_WHEN\_DIRTY);  
}

 

在 OpenGL 中,一旦我们设置好了基本环境(即画布),就可以开始绘制图形了。在这个过程中,着色器(shader)相当于画笔的功能,主要有两类着色器:顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)。顶点着色器通常用于定义待渲染图形的顶点;例如,对于要绘制的三角形,可以通过顶点着色器指定该三角形的三个顶点。因此,形状就得以确定。片元着色器则负责图形的填充和呈现效果。它可以决定如何为三角形的内部区域上色。

 

2. Render渲染器声明

当使用 GLSurfaceView 时,为了定义着色器,我们需要继承 GLSurfaceView.Renderer 类。Renderer 在这里是渲染器的意思,负责图形的渲染过程。OpenGL ES 2.0 专为支持可编程流水线的硬件设计,因此其使用与编程紧密结合。这里我们定义了渲染器VideoRenderer,首先,我们需要定义着色器的构建程序。程序如何写,后面再详讲:

// 顶点着色器(兼容 OpenGL ES 2.0)
    privatestaticfinal String VERTEX_SHADER =
            "uniform mat4 uMVPMatrix;\n" +
                    "attribute vec4 aPosition;\n" +
                    "attribute vec2 aTexCoord;\n" +
                    "varying vec2 vTexCoord;\n" +
                    "void main() {\n" +
                    "  gl_Position = uMVPMatrix * aPosition;\n" +
                    "  vTexCoord = aTexCoord;\n" +
                    "}";

    // 片段着色器(支持外部纹理)
    privatestaticfinal String FRAGMENT_SHADER =
            "#extension GL_OES_EGL_image_external : require\n" +
                    "precision mediump float;\n" +
                    "varying vec2 vTexCoord;\n" +
                    "uniform samplerExternalOES uVideoTexture;\n" +
                    "void main() {\n" +
                    "  gl_FragColor = texture2D(uVideoTexture, vTexCoord);\n" +
                    "}";

 

再去按照固定的写法去构建着色器,代码是相对固定的

privatevoidinitShader(){
    int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER);
    int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER);

    program = GLES20.glCreateProgram();
    GLES20.glAttachShader(program, vertexShader);
    GLES20.glAttachShader(program, fragmentShader);
    GLES20.glLinkProgram(program);

    // 检查错误
    int[] linkStatus = newint[1];
    GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);
    if (linkStatus[0] != GLES20.GL_TRUE) {
        Log.e("Renderer", "Shader link error: " + GLES20.glGetProgramInfoLog(program));
    }
}

 

再去创建好program,就说明你的环境基本可以使用了

privatevoidinitShader(){
int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER);
int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER);

    program = GLES20.glCreateProgram();
    GLES20.glAttachShader(program, vertexShader);
    GLES20.glAttachShader(program, fragmentShader);
    GLES20.glLinkProgram(program);

// 检查错误
int[] linkStatus = newint[1];
    GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);
if (linkStatus[0] != GLES20.GL_TRUE) {
        Log.e("Renderer", "Shader link error: " + GLES20.glGetProgramInfoLog(program));
    }
}

 

3. 初始化MediaPlayer

在这里面进行了MediaPlayer的创建:

publicvoidsetVideoPath(String path){
       this.pendingVideoPath = path;
       if (mediaPlayer == null) {
           mediaPlayer = new MediaPlayer();
           mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
               @Override
               publicvoid onPrepared(MediaPlayer mp) {
                   mp.start();
                   // 触发 OpenGL 初始化(如果尚未就绪)
                   requestRender();
               }
           });
       }
   }

 

细心的开发同学会发现,Mediaplayer创建完成之后,并没有立即播放视频,如果你播放视频,会崩溃,这是因为视频流绘制相关的SurfaceTexture的创建还没完成,你想把画面展示在Surface上面一定会失败。所以我们需要加入一个监听,等SurfaceTexture创建完成之后,再去播放视频。

 

先声明好回调

surfaceTexture = new SurfaceTexture(textureId);
surfaceTexture.setOnFrameAvailableListener(st -> {
    // 请求渲染
    mVideoGLSurfaceView.requestRender();
});

if (textureReadyListener != null) {
    textureReadyListener.onSurfaceTextureReady(surfaceTexture);
}

 

再去做监听,进行视频播放

privatevoidinit(Context context){
    // 使用 OpenGL ES 2.0 以兼容更多设备
    setEGLContextClientVersion(2);
    // 关键步骤 1: 设置透明背景
    setEGLConfigChooser(new TransparentConfigChooser());
    setZOrderOnTop(true); // 必须设置
    getHolder().setFormat(PixelFormat.TRANSLUCENT); // 必须设置

    renderer = new VideoRenderer(this);
    setRenderer(renderer);
    setRenderMode(RENDERMODE_WHEN_DIRTY);


    // SurfaceTexture 就绪回调
    renderer.setOnSurfaceTextureReadyListener(surfaceTexture -> {
        if (mediaPlayer != null && pendingVideoPath != null) {
            try {
                // 1. 重置 MediaPlayer
                mediaPlayer.reset();
                // 2. 设置 DataSource
                mediaPlayer.setDataSource(pendingVideoPath);
                // 3. 设置 Surface
                mediaPlayer.setSurface(new Surface(surfaceTexture));
                // 4. 准备异步
                mediaPlayer.prepareAsync();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    });
}

 

4. 从SurfaceTexture获取帧纹理

首先要获取图形顶点,此步骤用来定义图形形状

publicVideoRenderer(VideoGLSurfaceView videoGLSurfaceView){
    mVideoGLSurfaceView = videoGLSurfaceView;
    // 初始化顶点缓冲
    vertexBuffer = ByteBuffer.allocateDirect(VERTEX_DATA.length * 4)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()
            .put(VERTEX_DATA);
    vertexBuffer.position(0);

    // 初始化纹理坐标缓冲
    texCoordBuffer = ByteBuffer.allocateDirect(TEX_COORD_DATA.length * 4)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()
            .put(TEX_COORD_DATA);
    texCoordBuffer.position(0);
}

 

Android 的 OpenGL 底层是用 C/C++ 实现的,所以和 Java 的数据类型字节序列有一定的区别,主要是数据的大小端问题。ByteBuffer.order() 方法设置以下数据的大小端顺序,顺序设置为 native 层的数据顺序。使用 ByteOrder.nativeOrder() 可以得到 native 层的大小端数据顺序。

 

进行具体绘制操作。主要是实现继承自 GLSurfaceView.Renderer 的三个方法:

@Override
    publicvoidonSurfaceCreated(GL10 gl, EGLConfig config){
        initTexture();
        initShader();
    }

    @Override
    publicvoidonSurfaceChanged(GL10 gl, int width, int height){
        GLES20.glViewport(0, 0, width, height);
        Matrix.setIdentityM(mvpMatrix, 0);
    }

    @Override
    publicvoidonDrawFrame(GL10 gl){
        Log.d("VideoRender", "onDrawFrame");
        GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

        // 更新帧计数器
        frameCount++;

        // 从第10帧开始动画
        if (frameCount >= 10 && !animationStarted) {
            animationStarted = true;
            frameCount = 0; // 重置计数器以便计算动画进度
        }

        // 计算缩放因子
        if (animationStarted && frameCount <= ANIMATION_DURATION) {
            float progress = (float) frameCount / ANIMATION_DURATION;
            scaleFactor = 1.0f + (MAX_SCALE - 1.0f) * progress;
        } else {
            scaleFactor = 1.0f;
        }

        // 计算旋转角度
        if (animationStarted && (frameCount <= ANIMATION_DURATION + 30 && frameCount > 20)) {
            // 计算旋转进度(从第20帧开始)
            int rotationFrame = frameCount - (ROTATION_START_FRAME - 10);
            if (rotationFrame < 0) rotationFrame = 0;
            float rotationProgress = (float) rotationFrame / ROTATION_DURATION;
            if (rotationProgress > 1) {
                rotationProgress = 1;
            }
            rotationAngle = MAX_ROTATION * rotationProgress;
        } else {
            rotationAngle = 0.0f;
        }

        // 生成缩放后的MVP矩阵
        float[] finalMvpMatrix = applyScaleAndRotationToMvpMatrix(mvpMatrix, scaleFactor, rotationAngle);


        if (surfaceTexture != null) {
            surfaceTexture.updateTexImage(); // 更新纹理
        }

        GLES20.glUseProgram(program);
        int mvpMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix");
//        GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, mvpMatrix, 0); 这个是没有任何缩放动画的代码
        // 这个是有缩放效果的代码
        GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, finalMvpMatrix, 0);

        // 绑定顶点数据
        int positionHandle = GLES20.glGetAttribLocation(program, "aPosition");
        GLES20.glEnableVertexAttribArray(positionHandle);
        GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);

        // 绑定纹理坐标
        int texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord");
        GLES20.glEnableVertexAttribArray(texCoordHandle);
        GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);

        // 绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

        GLES20.glDisableVertexAttribArray(positionHandle);
        GLES20.glDisableVertexAttribArray(texCoordHandle);
    }

 

5. 处理帧数据/叠加动画

使用finalMvpMatrix 对原先的mvpMatrix做了转变,在这里进行动画相关的设置,这里我们做了一个旋转的动画,并且是从视频的第10-20帧缩放,从第20-30帧旋转,31帧开始回到原先状态。

// 更新帧计数器
frameCount++;

// 从第10帧开始动画
if (frameCount >= 100 && !animationStarted) {
    animationStarted = true;
    frameCount = 0; // 重置计数器以便计算动画进度
}

// 计算缩放因子
if (animationStarted && frameCount <= ANIMATION_DURATION) {
    float progress = (float) frameCount / ANIMATION_DURATION;
    scaleFactor = 1.0f + (MAX_SCALE - 1.0f) * progress;
} else {
    scaleFactor = 1.0f;
}

// 计算旋转角度
if (animationStarted) {
    // 计算旋转进度(从第150帧开始)
    int rotationFrame = frameCount - (ROTATION_START_FRAME - 100);
    if (rotationFrame < 0) rotationFrame = 0;
    float rotationProgress = (float) rotationFrame / ROTATION_DURATION;
    rotationAngle = MAX_ROTATION * rotationProgress;
}

// 生成缩放后的MVP矩阵
float[] finalMvpMatrix = applyScaleAndRotationToMvpMatrix(mvpMatrix, scaleFactor, rotationAngle)
GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, finalMvpMatrix, 0);

 

privatefloat[] applyScaleAndRotationToMvpMatrix(float[] originalMatrix, float scale, float rotation) {
       float[] finalMatrix = newfloat[16];
       Matrix.setIdentityM(finalMatrix, 0);
       // 1. 应用原始矩阵
       Matrix.multiplyMM(finalMatrix, 0, originalMatrix, 0, finalMatrix, 0);
       // 2. 应用缩放
       Matrix.scaleM(finalMatrix, 0, scale, scale, 1.0f);
       // 3. 应用旋转(绕Z轴)
       Matrix.rotateM(finalMatrix, 0, rotation, 0, 0, 1.0f);
       return finalMatrix;
   }

 

6. 渲染到屏幕

// 绑定顶点数据
int positionHandle = GLES20.glGetAttribLocation(program, "aPosition");
GLES20.glEnableVertexAttribArray(positionHandle);
GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);

// 绑定纹理坐标
int texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord");
GLES20.glEnableVertexAttribArray(texCoordHandle);
GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);

// 绘制
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

GLES20.glDisableVertexAttribArray(positionHandle);
GLES20.glDisableVertexAttribArray(texCoordHandle);

 

需要注意的是,这里使用到了纹理坐标和顶点坐标,这两个坐标在下文也有使用,那么这两个坐标起到什么作用?先来看下这两个坐标的定义:

 

7. 效果呈现

图片

 

到这里,对于如何使用OpenGL ES进行画面渲染的流程,你应该也比较熟悉了,继续往下看。

 

四、提升难度-FFmpeg手撸播放器实现动画

在上面知识点了解之前,有人是先学习的FFmpeg,但是很多人在FFmpeg编译这一步时就被劝退了,因为确实有些麻烦,不像上面的知识点那么纯粹,使用FFmpeg做一款动画播放器,涉及到FFmpeg的编译、引入、jni的代码编写(C++)、Android工程、以及上面提供的SurfaceView、Surface、顶点和片段着色器这些知识点。那么这一章节会带你克服之前可能遇到的问题,让你顺利开发出一个播放器。

 

4.1 FFmpeg 编译

Windows环境下,不要使用Cygwin,不然需要再去安装一堆插件,解决版本兼容的问题,太麻烦了,试了好几遍都无法成功。直接使用MSYS2,(需要注意的是,这里使用的是Windows的环境,如果你是MAC或者其他环境,操作起来更简单,这个可以自行搜索一下)。

 

编译完成之后,就可以生成可以跨平台使用的可调用库文件,这里以so文件举例:

图片

 

借助Android Studio创建一个C++项目,把上面的so文件拷到你的项目里,头文件在include下面,这个拷arm64-v8a或者armeabi-v7a下面的头文件都可以,如下所示:

图片

 

4.2 基础播放器实现

先看一下流程图,有了这个图之后,就有了清晰的认识,在哪个环节实现动画也就一目了然。再来看一下做一款播放器的流程图:

图片

 

4.2.1 初始化FFmpeg库

这里比较简单,初始化一下网络协议就行,为了方便起见,可以把头部需要引用的库都加进来。

#include<jni.h>
#include<string>
#include<android/native_window.h>
#include<android/native_window_jni.h>
#include<android/log.h>
#include<android/bitmap.h>

#define LOG_TAG "Firstvideo"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)


extern"C" {
#include"include/libavutil/log.h"
#include"include/libavutil/frame.h"
#include"include/libavutil/avutil.h"
#include"include/libavutil/imgutils.h"
#include"include/libavutil/opt.h"
#include"include/libavformat/avformat.h"
#include"include/libavcodec/avcodec.h"
#include"include/libswscale/swscale.h"

//初始化FFmpeg库
avformat_network_init();

 

4.2.2 打开视频文件

constchar *videoPath = env->GetStringUTFChars(videoPath_, 0);
LOGD("videoPath: %s", videoPath);
if  (videoPath == NULL) {
    LOGE("videoPath is null");
    return;
}
AVFormatContext *formatContext = avformat_alloc_context();
LOGD("open video file");
int ret = avformat_open_input(&formatContext, videoPath, NULL, NULL);
if (ret != 0) {
    char errorBuf[256];
    av_strerror(ret, errorBuf, sizeof(errorBuf));
    LOGE("无法打开视频文件: %s, 错误: %s", videoPath, errorBuf);
    return;
}

 

4.2.3 查找流信息

LOGD("Retrieve stream information");
if (avformat_find_stream_info(formatContext, NULL) < 0) {
    LOGE("Cannot find stream information");
return;
}

 

4.2.4 查找视频流

LOGD("Find video stream");
int video_stream_index = -1;
for (int i = 0; i < formatContext->nb_streams; i++) {
    if (formatContext->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
        video_stream_index = i;
    }
}
if (video_stream_index == -1) {
    LOGE("No video stream found");
    return;
}

 

4.2.5 获取编码器上下文

LOGD("Get a pointer to the codec context for the video stream");
AVCodecParameters *codecParameters = formatContext->streams[video_stream_index]->codecpar;

LOGD("Find the decoder for the video stream");
const AVCodec *codec = avcodec_find_decoder(codecParameters->codec_id);
if (codec == NULL) {
    LOGE("Codec not found");
    return;
}
AVCodecContext *codecContext = avcodec_alloc_context3(codec);
if (codecContext == NULL) {
    LOGE("CodecContext not found");
    return;
}
if (avcodec_parameters_to_context(codecContext, codecParameters) < 0) {
    LOGE("Fill CodecContext failed");
    return;
}

 

4.2.6 打开编解码器

LOGD("Open codec");
if (avcodec_open2(codecContext, codec, NULL) < 0) {
    LOGE("Init CodecContext failed");
    return;
}

 

4.2.7 为视频帧分配空间

AVPixelFormat dstFormat = AV_PIX_FMT_RGBA;
AVPacket *packet = av_packet_alloc();
if (packet == NULL) {
    LOGE("Could not allocate av packet");
    return;
}
LOGD("Allocate video frame");
AVFrame *frame = av_frame_alloc();
LOGD("Allocate render frame");
AVFrame *renderFrame = av_frame_alloc();
if (frame == NULL || renderFrame == NULL) {
    LOGE("Could not allocate video frame");
    return;
}

 

4.2.8 分配处理视频帧的内存空间

LOGD("Determine required buffer size and allocate buffer");
int size = av_image_get_buffer_size(dstFormat, codecContext->width, codecContext->height, 1);
uint8_t *buffer = (uint8_t *) av_malloc(size * sizeof(uint8_t));
av_image_fill_arrays(renderFrame->data, renderFrame->linesize, buffer, dstFormat, codecContext->width, codecContext->height, 1);

 

4.2.9 初始化图像转换结构体SwsContext

structSwsContext *swsContext = sws_getContext(codecContext->width,
                                                  codecContext->height,
                                                  codecContext->pix_fmt,
                                                  codecContext->width,
                                                  codecContext->height,
                                                  dstFormat,
                                                  SWS_BILINEAR,
                                                  NULL,
                                                  NULL,
                                                  NULL);

   if (swsContext == NULL) {
       LOGE("Init SwsContext failed");
       return;
   }

 

4.2.10 创建本地视图窗口管理器

LOGD("native window");
ANativeWindow *nativeWindow = ANativeWindow_fromSurface(env, surface);
ANativeWindow_Buffer windowBuffer;
LOGD("get video width, height");

 

4.2.11 获取视频的宽高

int videoWidth = codecContext->width;
int videoHeight = codecContext->height;
LOGD("set video width, height:[%d, %d]", videoWidth, videoHeight);
LOGD("set native window");

 

4.2.12 向解码器发送帧数据与解码器接收帧数据

while (av_read_frame(formatContext, packet) == 0) {
       if (packet->stream_index == video_stream_index) {

           int sendPacketState = avcodec_send_packet(codecContext, packet);
           if (sendPacketState == 0) {
               LOGD("向解码器-发送数据");
               int receiveFrameState = avcodec_receive_frame(codecContext, frame);
               if (receiveFrameState == 0) {
                   LOGD("从解码器-接收数据");
                   frameCount++;  // 成功解码一帧,计数器递增
                   if (frameCount == 5) {
                       // 提取第100帧生成Bitmap
                       convertFrameToBitmap(env, codecContext, frame, bitmap);  // 自定义函数
                   }
                   ANativeWindow_lock(nativeWindow, &windowBuffer, NULL);
                   // 格式转换
                   sws_scale(swsContext, (uint8_tconst *const *) frame->data,
                             frame->linesize, 0, codecContext->height,
                             renderFrame->data, renderFrame->linesize);
                   //获取stride
                   uint8_t *dst = (uint8_t *) windowBuffer.bits;
                   uint8_t *src = (uint8_t *) renderFrame->data[0];
                   int dstStride = windowBuffer.stride * 4;
                   int srcStride = renderFrame->linesize[0];
                   // 由于Windows的stride和帧的stride不同,因此需要逐行复制
                   for (int i = 0; i < videoHeight; i++) {
                       memcpy(dst + i * dstStride, src + i * srcStride, srcStride);
                   }
                   ANativeWindow_unlockAndPost(nativeWindow);
               } elseif (receiveFrameState == AVERROR(EAGAIN)) {
                   LOGD("从解码器-接收-数据失败:AVERROR(EAGAIN)");
               } elseif (receiveFrameState == AVERROR_EOF) {
                   LOGD("从解码器-接收-数据失败:AVERROR_EOF");
               } elseif (receiveFrameState == AVERROR(EINVAL)) {
                   LOGD("从解码器-接收-数据失败:AVERROR(EINVAL)");
               } else {
                   LOGD("从解码器-接收-数据失败: 未知");
               }
           } elseif (sendPacketState == AVERROR(EAGAIN)) {
               LOGD("向解码器-发送-数据失败:AVERROR(EAGAIN)");
           } elseif (sendPacketState == AVERROR_EOF) {
               LOGD("向解码器-发送-数据失败:AVERROR_EOF");
           } elseif (sendPacketState == AVERROR(EINVAL)) {
               LOGD("向解码器-发送-数据失败:AVERROR(EINVAL)");
           } elseif (sendPacketState == AVERROR(ENOMEM)) {
               LOGD("向解码器-发送-数据失败:AVERROR(ENOMEM)");
           } else {
               LOGD("向解码器-发送-数据失败:未知");
           }
       }
       av_packet_unref(packet);
   }

 

动画在sws_scale处完成,大致代码如下:

// 格式转换(原有逻辑)
sws_scale(swsContext, frame->data, frame->linesize, 0,
          codecContext->height, renderFrame->data, renderFrame->linesize);

// 将renderFrame数据绑定到OpenGL纹理
glBindTexture(GL_TEXTURE_2D, mTextureID);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, renderFrame->width, renderFrame->height,
                GL_RGBA, GL_UNSIGNED_BYTE, renderFrame->data[0]);

// 更新动画参数(示例:每帧放大1%,旋转1度)
mCurrentScale += 0.01f;
mCurrentRotation += 1.0f;
if (mCurrentRotation >= 360.0f) mCurrentRotation = 0.0f;

// 渲染到屏幕
glUseProgram(mProgram);
glUniform1f(mScaleUniform, mCurrentScale);     // 传递缩放值
glUniform1f(mRotationUniform, mCurrentRotation); // 传递旋转角度

// 绘制矩形(带纹理)
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

 

可以看到,跟第二章部分内容一样,这里也使用了GL环境进行缩放和旋转动画的处理。代码的实现思路也基本是一致的,就是Surface承接渲染任务,然后使用顶点和片元着色器进行图形的绘制和渲染。

 

4.2.13 内存释放

// 内存释放
LOGD("release memory");
ANativeWindow_release(nativeWindow);

 

4.3 酷炫动画的实现

先来看一下一个简单的处理,把rgb做了一个简单的均值,然后赋值给rgb都赋值为这个均值,就可以得到一个黑白的颜色,这就是最简单的视频处理。

const GLchar* VideoDrawer::GetFragmentShader(){
    staticconst GLchar shader[] = "precision mediump float;\n"
                            "uniform sampler2D uTexture;\n"
                            "varying vec2 vCoordinate;\n"
                            "void main() {\n"
                            "  vec4 color = texture2D(uTexture, vCoordinate);\n"
                            //                            "  color.a = 0.5f;"
                            //                            "  gl_FragColor = color;\n"
                            "float gray = (color.r + color.g + color.b)/3.0;\n"
                            "gl_FragColor = vec4(gray, gray, gray, 1.0);\n"
                            //                            "  gl_FragColor = vec4(1, 1, 1, 1);\n"
                            "}";
    return shader;
}

 

关键是这一行 gl_FragColor = vec4(gray, gray, gray, 1.0)

图片

 

再来看一个灵魂出窍的效果,这个就是类似抖音这种做的滤镜,代码会复杂些,但是原理基本没啥区别。

图片

 

4.4 自己写播放器的好处

看到这里,你可能会说使用Mediaplayer跟自己写FFmpeg没啥区别,这么麻烦干嘛,那下面再来详细总结下FFmpeg的好处:

4.4.1 格式支持更全面

FFmpeg 支持几乎所有的音视频格式(如 H.265/HEVC、VP9、FLAC、MKV、MOV 等),甚至冷门格式或损坏文件。

传统播放器 依赖系统解码器,可能无法播放未安装解码器的格式(如某些 4K 视频或无损音频)。

 

4.4.2 解码能力更强

FFmpeg 直接调用底层库(如 libx264、libvpx),支持硬解码、多线程解码,流畅播放高码率视频。

传统播放器 可能因解码优化不足导致卡顿,尤其是播放高分辨率(如 4K/8K)或高帧率视频时。

 

4.4.3 高度自定义与灵活性

FFmpeg 播放器 支持通过命令行参数或脚本控制播放行为,例如:

调整播放速度:ffplay -vf "setpts=0.5\*PTS" input.mp4(2倍速播放)

实时滤镜:添加去噪、锐化、色彩校正等效果。

截取片段:ffplay -ss 00:01:30 -t 10 input.mp4(从1分30秒开始播放10秒)。

传统播放器 通常仅提供固定功能,无法深度自定义。

 

4.4.4 处理异常文件更稳定

FFmpeg 可强制忽略错误继续播放不完整或损坏的媒体文件(如未下载完的视频)。

ffplay -err\_detect ignore\_err input\_corrupted.mp4

传统播放器 遇到文件异常时可能直接报错退出。

 

4.4.5 资源占用更低

FFmpeg 无图形界面(如 ffplay),资源消耗更少,适合老旧设备或后台处理。

传统播放器 因GUI和附加功能(如皮肤、插件)可能占用更多内存和CPU。

 

4.4.6 跨平台一致性

FFmpeg 可在 Windows、Linux、macOS 等系统上运行,命令和功能完全一致。

传统播放器 通常仅限特定平台(如 Windows Media Player 仅限 Windows)。

 

4.4.7 支持流媒体与网络协议

FFmpeg 可直接播放网络流(如 RTMP、HLS、HTTP):

ffplay rtsp://example.com/live.stream

传统播放器 可能需要额外插件或无法支持专业流媒体协议。

 

4.4.8 开发与调试友好

FFmpeg 提供详细的日志和调试信息,便于开发者分析问题:

ffplay -v debug input.mp4  # 输出详细解码日志

传统播放器 日志功能有限,难以排查播放故障。

 

适用场景对比

图片

 

五、可以做的更多

上面的动画还是太简单了!!!

要是需要做一个更复杂的动效:具备3D效果的视频该怎么办呢?比如百度地图的3D图层。

图片

 

看一下下面这个知识架构图,我们本文主要是把Core这部分做了讲解,其他的知识点就是做3D效果的必备知识点,大家可以自行deepseek做进一步的了解。

├── Core
│   ├── Shader(着色器管理)
│   ├── Texture(纹理加载与采样)
│   ├── Model(模型加载,支持OBJ/FBX)
│   └── Camera(摄像机控制)
├── Rendering
│   ├── ForwardRenderer(前向渲染器)
│   ├── DeferredRenderer(延迟渲染器)
│   └── ShadowRenderer(阴影渲染模块)
├── Lighting
│   ├── PointLight(点光源)
│   ├── DirectionalLight(平行光)
│   └── PBR(基于物理的渲染)
└── Utils
    ├── GLM(数学库)
    ├── Assimp(模型导入库)
    └── STB(图像加载库)

 

上述知识点都掌握后,基本就可以实现3D地图效果了,这时候再去做视频的3D动画原理也是相同,不再有阻碍了!

 

六、结束语

使用视频文件代替GIF、属性动画进行动效实现而言具备下面几个明显的优势:

1. 复杂性限制

动效方案通常更适合简单或中等复杂程度的动画,而不是像视频那样可以展示复杂场景和高质量的画面。

 

2.多样性和沉浸感

视频可以提供更丰富的视觉效果和沉浸感,比如动态的场景变化、特效、音效的结合等。

 

3. 创作灵活性

视频创作可以使用各种视频编辑工具进行高级编辑,而动效需要更多手动编码和调整。

 

4. 更加满足业务场景需求

在视频文件的基础上,可以进行动效定制,插入特定的效果,翻转、平移、3D、抠图等均可,可以做到更高的业务场景契合度。

posted @ 2025-11-20 11:33  vivo互联网技术  阅读(18)  评论(0)    收藏  举报