一、视频贴纸:不只是“贴上去”那么简单
各位同学,今天我们来聊一个特别有意思的话题——在视频上叠加动态贴纸。
说实话,我第一次做贴纸功能时,以为就是简单地把一张PNG图片盖在视频上。结果呢?踩了一堆坑。贴纸位置不对、旋转角度错了、跟视频时长不同步……嗯,后来我才明白,视频贴纸本质上是一个时间轴上的图形渲染问题。
说白了,你要在每一帧画面里,把贴纸画到正确的位置。而且贴纸还能动——缩放、旋转、移动,甚至多张贴纸同时出现。这背后涉及到的核心知识点,就是MediaMuxer + MediaCodec + OpenGL ES 的协同工作。
核心思路: 解码视频帧 → 在帧上叠加贴纸(通过OpenGL) → 重新编码 → 用MediaMuxer合成输出。
我个人习惯把贴纸分为两类:
- 静态贴纸:位置固定,不随时间变化
- 动态贴纸:位置、大小、旋转角度随时间变化(比如飘落的雪花、跳动的心形)
今天咱们重点讲动态贴纸。为什么?因为静态贴纸只是动态贴纸的一个特例——你把时间参数固定住就行了。
二、整体架构:一张图看懂流程
先别急着写代码。我建议你先把整体流程理清楚。下面这张图是我自己总结的,你一看就明白。
你看,整个流程并不复杂。关键点在于OpenGL渲染这一步——你要把贴纸纹理和视频帧纹理混合在一起。音频轨道呢?我一般直接透传,不做任何处理,省得引入音画不同步的问题。
二、贴纸数据模型:先定义好结构
写代码之前,先把数据结构想清楚。我在项目中吃过亏——一开始随便用HashMap存贴纸参数,后来维护起来想哭。
// 贴纸数据模型
data class StickerData(
val id: String, // 贴纸唯一标识
val bitmap: Bitmap, // 贴纸图片(已解码)
var startTimeUs: Long, // 开始时间(微秒)
var endTimeUs: Long, // 结束时间(微秒)
var position: PointF, // 初始位置(归一化坐标 0~1)
var scale: Float = 1.0f, // 缩放比例
var rotation: Float = 0f, // 旋转角度(度)
var alpha: Float = 1.0f, // 透明度 0~1
var keyFrames: List<KeyFrame>? // 关键帧动画(可选)
)
// 关键帧:用于实现动态效果
data class KeyFrame(
val timeUs: Long, // 时间戳
val position: PointF, // 位置
val scale: Float, // 缩放
val rotation: Float, // 旋转
val alpha: Float // 透明度
)
小技巧: 位置用归一化坐标(0~1),这样不管视频分辨率是720p还是4K,贴纸位置都能自动适配。我刚开始用像素坐标,结果换了个视频分辨率,贴纸全跑偏了。
三、OpenGL渲染:贴纸叠加的核心
这部分是硬骨头。我尽量讲得通俗些。
你要在每一帧视频画面里,把贴纸画上去。怎么做?用纹理叠加。
视频帧解码后是一个纹理(Texture),贴纸图片也是一个纹理。在片段着色器里,把两个纹理的颜色混合起来:
// 片段着色器(核心代码)
#version 300 es
precision mediump float;
in vec2 vTexCoord;
uniform sampler2D uVideoFrame; // 视频帧纹理
uniform sampler2D uStickerTex; // 贴纸纹理
uniform vec2 uStickerPos; // 贴纸位置(归一化)
uniform float uStickerScale; // 缩放
uniform float uStickerRotation; // 旋转
uniform float uStickerAlpha; // 透明度
out vec4 fragColor;
void main() {
// 1. 采样视频帧
vec4 videoColor = texture(uVideoFrame, vTexCoord);
// 2. 计算贴纸坐标(平移 + 旋转 + 缩放)
vec2 stickerCoord = vTexCoord - uStickerPos;
// 旋转矩阵
float cosA = cos(uStickerRotation);
float sinA = sin(uStickerRotation);
stickerCoord = vec2(
stickerCoord.x * cosA - stickerCoord.y * sinA,
stickerCoord.x * sinA + stickerCoord.y * cosA
);
stickerCoord /= uStickerScale;
stickerCoord += vec2(0.5); // 贴纸纹理中心对齐
// 3. 采样贴纸纹理
vec4 stickerColor = texture(uStickerTex, stickerCoord);
// 4. 混合:贴纸覆盖在视频上
float alpha = stickerColor.a * uStickerAlpha;
// 只保留贴纸不透明区域
if (stickerCoord.x < 0.0 || stickerCoord.x > 1.0 ||
stickerCoord.y < 0.0 || stickerCoord.y > 1.0) {
alpha = 0.0;
}
fragColor = vec4(
mix(videoColor.rgb, stickerColor.rgb, alpha),
videoColor.a
);
}
注意: 贴纸纹理的采样坐标要处理好边界。如果贴纸坐标超出[0,1]范围,说明这个像素不在贴纸区域内,应该显示视频原色。我曾经忘了做这个判断,结果贴纸边缘出现了奇怪的拉伸。
四、动态贴纸:让贴纸动起来
静态贴纸很简单,位置固定就行。动态贴纸呢?你需要根据时间戳计算每一帧的参数。
举个例子,一个贴纸从左上角移动到右下角,持续3秒:
// 计算当前帧的贴纸位置
fun calculateStickerParams(
sticker: StickerData,
currentTimeUs: Long
): StickerRenderParams {
val duration = sticker.endTimeUs - sticker.startTimeUs
val progress = (currentTimeUs - sticker.startTimeUs).toFloat() / duration
val clampedProgress = progress.coerceIn(0f, 1f)
// 线性插值:从 (0,0) 移动到 (1,1)
val currentPos = PointF(
sticker.position.x + clampedProgress * 1.0f,
sticker.position.y + clampedProgress * 1.0f
)
// 如果有关键帧,用关键帧插值
if (sticker.keyFrames != null && sticker.keyFrames.size > 1) {
return interpolateKeyFrames(sticker.keyFrames, currentTimeUs)
}
return StickerRenderParams(
position = currentPos,
scale = sticker.scale,
rotation = sticker.rotation,
alpha = sticker.alpha
)
}
这里我用了线性插值。实际项目中,你可能需要贝塞尔曲线或者缓动函数,让贴纸运动更自然。比如贴纸弹跳效果,用 OvershootInterpolator 就很合适。
五、MediaMuxer合成:把贴纸视频写出去
渲染完成后,就是MediaMuxer的活了。这部分其实跟普通视频合成一样,只是视频轨道的数据变成了带贴纸的画面。
// MediaMuxer 合成带贴纸的视频
fun muxWithSticker(
videoTrack: Track,
audioTrack: Track?,
outputPath: String,
stickerList: List<StickerData>
) {
val muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
// 1. 添加视频轨道
val videoFormat = MediaFormat.createVideoFormat(
"video/avc", videoTrack.width, videoTrack.height
)
videoFormat.setInteger(MediaFormat.KEY_BIT_RATE, 8_000_000)
videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
videoFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
val videoTrackIndex = muxer.addTrack(videoFormat)
// 2. 添加音频轨道(如果有)
var audioTrackIndex = -1
if (audioTrack != null) {
audioTrackIndex = muxer.addTrack(audioTrack.format)
}
muxer.start()
// 3. 写入视频数据(带贴纸)
val videoEncoder = createVideoEncoder(videoFormat)
val decoder = createVideoDecoder(videoTrack)
// 这里省略了编解码循环的细节
// 核心逻辑:解码一帧 → 渲染贴纸 → 编码 → 写入muxer
while (hasMoreFrames) {
val frame = decoder.dequeueFrame()
val renderedFrame = renderStickerOnFrame(frame, stickerList, frame.timestampUs)
val encodedPacket = videoEncoder.encodeFrame(renderedFrame)
muxer.writeSampleData(videoTrackIndex, encodedPacket.buffer, encodedPacket.bufferInfo)
}
// 4. 写入音频数据(直通)
if (audioTrack != null) {
while (hasMoreAudio) {
val audioPacket = audioTrack.readSample()
muxer.writeSampleData(audioTrackIndex, audioPacket.buffer, audioPacket.bufferInfo)
}
}
muxer.stop()
muxer.release()
}
关键点: 音频轨道我选择直通(pass-through),不重新编码。这样既节省性能,又避免音画不同步。你想想看,音频重新编码一次,延迟就多一分,何必呢?
六、避坑指南:我踩过的那些坑
做贴纸功能,有几个坑特别容易踩。我一个个说。
坑1:贴纸闪烁
我曾经遇到贴纸在播放时一闪一闪的。排查了半天,发现是纹理上传时机的问题。贴纸纹理应该在初始化时上传到GPU,不要在每一帧都重新上传。每帧上传纹理,性能开销大不说,还容易导致帧率不稳。
坑2:贴纸边缘锯齿
贴纸图片如果分辨率太低,放大后边缘全是锯齿。我的解决方案是:贴纸图片至少2倍于显示尺寸。比如贴纸在视频里占200x200像素,那图片资源至少准备400x400。然后用双线性过滤采样,边缘会平滑很多。
坑3:多张贴纸重叠
多张贴纸叠加时,要注意渲染顺序。先渲染的贴纸会被后渲染的覆盖。我一般按贴纸的z-order排序,从底层到顶层依次渲染。每张贴纸单独用一个pass,最后再跟视频帧混合。
坑4:内存泄漏
贴纸Bitmap如果不及时回收,内存涨得飞快。我习惯在贴纸生命周期结束时,主动调用bitmap.recycle()。另外,OpenGL纹理也要在GL线程上删除,别在主线程搞。
七、性能优化:让贴纸跑得更流畅
动态贴纸对性能要求不低。尤其是多张贴纸同时运动时,帧率很容易掉。我分享几个优化技巧:
- 纹理合批:把多张贴纸合并到一张大纹理上(Texture Atlas),减少纹理切换次数
- 预计算:贴纸的运动轨迹可以提前算好,存成数组。运行时直接查表,省去每帧计算的开销
- 降低贴纸分辨率:如果贴纸在视频中只占一小块区域,没必要用高清图。根据实际显示尺寸动态缩放
- 使用VBO:贴纸的顶点坐标如果不变,可以缓存到VBO里,避免每帧上传
我的经验: 在低端机上,贴纸数量控制在3个以内,每个贴纸分辨率不超过512x512。超过这个量,帧率大概率会掉到24fps以下。用户看着卡顿,体验就很差了。
八、完整示例:一个简单的动态贴纸Demo
最后,我给你一个能跑起来的Demo思路。假设我们要在视频右上角加一个旋转的星星贴纸:
// 1. 准备贴纸
val starBitmap = BitmapFactory.decodeResource(resources, R.drawable.star)
val sticker = StickerData(
id = "star",
bitmap = starBitmap,
startTimeUs = 0,
endTimeUs = 5_000_000L, // 持续5秒
position = PointF(0.8f, 0.2f), // 右上角
scale = 0.3f,
rotation = 0f,
alpha = 1.0f
)
// 2. 每帧更新旋转角度
fun onFrameAvailable(timestampUs: Long) {
if (timestampUs in sticker.startTimeUs..sticker.endTimeUs) {
// 每秒旋转360度
val progress = (timestampUs - sticker.startTimeUs) / 1_000_000f
sticker.rotation = (progress * 360) % 360
// 更新OpenGL uniform
updateStickerUniforms(sticker)
}
}
// 3. 渲染和合成(复用前面的代码)
这个Demo虽然简单,但涵盖了动态贴纸的核心逻辑。你可以在它的基础上扩展——加多张贴纸、加关键帧动画、加缓动函数,都行。
好了,关于视频贴纸的内容就讲到这里。记住一句话:贴纸的本质,是时间轴上的纹理混合。把时间参数玩明白了,动态贴纸就难不倒你。
公众号:蓝海资料掘金营,微信deep3321