时间:2025-11-20 11:34
人气:
作者:admin
作者: 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,全称是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。
作为一个Android移动端开发者。应该知道坐标系的概念,物体的位置都是通过坐标系确定的。OpenGL ES 采用的是右手坐标,选取屏幕中心为原点,从原点到屏幕边缘默认长度为 1,也就是说默认情况下,从原点到(1,0,0)的距离和到(0,1,0)的距离在屏幕上展示的并不相同。坐标系向右为 X 正轴方向,向左为 X 负轴方向,向上为 Y 轴正轴方向,向下为 Y 轴负轴方向,屏幕面垂直向上为 Z 轴正轴方向,垂直向下为 Z 轴负轴方向。
总结一下:在 OpenGL 中,世界就是一个坐标系,一个只有 X、Y 和 Z 三个纬度的世界,其它的东西都需要你自己来建设,你能用到的原材料就只有点、线和面(三角形),当然还会有其他材料,比如阳光(光照)和颜色(材质)。
在OpenGL中,"相机"的概念类似于现实世界的相机或人眼,其功能是捕获三维世界中的场景,并呈现到二维视图上。通过调整“相机”参数,可以改变观看的角度和范围,从而影响最终呈现的效果。
纹理是二维图像,用于映射到三维物体的表面上,使其看起来更加真实和细腻。纹理映射是一种重要的渲染技术,通过将纹理应用于物体表面,赋予物体颜色、图案等视觉效果,而不改变其几何形态。纹理的作用类似于为物体穿上“衣服”,提升视觉上的真实感。
通过上面的流程,我们可以确认图形的渲染大致可以表述如下:
在Android开发中,我们就是借助SurfaceView来进行视图的渲染,SurfaceView的实质是将底层显存 Surface 显示在界面上,而 GLSurfaceView 做的就是在这个基础上增加 OpenGL 绘制环
有了上面这些概念之后,那么下面我们从简单的MediaPlayer入手,从图形管线接入的角度,彻底弄清GLSurfaceView的工作原理,再去介绍手撸播放器如何来做。让你的知识点完全串联起来,之前不曾了解的知识点,通过本文也可以进一步的补充。
看一下完整的实现视频动画的流程图:
先看下引用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)。顶点着色器通常用于定义待渲染图形的顶点;例如,对于要绘制的三角形,可以通过顶点着色器指定该三角形的三个顶点。因此,形状就得以确定。片元着色器则负责图形的填充和呈现效果。它可以决定如何为三角形的内部区域上色。
当使用 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));
}
}
在这里面进行了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();
}
}
});
}
首先要获取图形顶点,此步骤用来定义图形形状
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);
}
使用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;
}
// 绑定顶点数据
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);
需要注意的是,这里使用到了纹理坐标和顶点坐标,这两个坐标在下文也有使用,那么这两个坐标起到什么作用?先来看下这两个坐标的定义:
到这里,对于如何使用OpenGL ES进行画面渲染的流程,你应该也比较熟悉了,继续往下看。
在上面知识点了解之前,有人是先学习的FFmpeg,但是很多人在FFmpeg编译这一步时就被劝退了,因为确实有些麻烦,不像上面的知识点那么纯粹,使用FFmpeg做一款动画播放器,涉及到FFmpeg的编译、引入、jni的代码编写(C++)、Android工程、以及上面提供的SurfaceView、Surface、顶点和片段着色器这些知识点。那么这一章节会带你克服之前可能遇到的问题,让你顺利开发出一个播放器。
Windows环境下,不要使用Cygwin,不然需要再去安装一堆插件,解决版本兼容的问题,太麻烦了,试了好几遍都无法成功。直接使用MSYS2,(需要注意的是,这里使用的是Windows的环境,如果你是MAC或者其他环境,操作起来更简单,这个可以自行搜索一下)。
编译完成之后,就可以生成可以跨平台使用的可调用库文件,这里以so文件举例:
借助Android Studio创建一个C++项目,把上面的so文件拷到你的项目里,头文件在include下面,这个拷arm64-v8a或者armeabi-v7a下面的头文件都可以,如下所示:
先看一下流程图,有了这个图之后,就有了清晰的认识,在哪个环节实现动画也就一目了然。再来看一下做一款播放器的流程图:
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);
先来看一下一个简单的处理,把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)
再来看一个灵魂出窍的效果,这个就是类似抖音这种做的滤镜,代码会复杂些,但是原理基本没啥区别。
看到这里,你可能会说使用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、属性动画进行动效实现而言具备下面几个明显的优势:
动效方案通常更适合简单或中等复杂程度的动画,而不是像视频那样可以展示复杂场景和高质量的画面。
视频可以提供更丰富的视觉效果和沉浸感,比如动态的场景变化、特效、音效的结合等。
视频创作可以使用各种视频编辑工具进行高级编辑,而动效需要更多手动编码和调整。
在视频文件的基础上,可以进行动效定制,插入特定的效果,翻转、平移、3D、抠图等均可,可以做到更高的业务场景契合度。
分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。