一、视频贴纸:不只是“贴上去”那么简单

各位同学,今天我们来聊一个特别有意思的话题——在视频上叠加动态贴纸。

说实话,我第一次做贴纸功能时,以为就是简单地把一张PNG图片盖在视频上。结果呢?踩了一堆坑。贴纸位置不对、旋转角度错了、跟视频时长不同步……嗯,后来我才明白,视频贴纸本质上是一个时间轴上的图形渲染问题

说白了,你要在每一帧画面里,把贴纸画到正确的位置。而且贴纸还能动——缩放、旋转、移动,甚至多张贴纸同时出现。这背后涉及到的核心知识点,就是MediaMuxer + MediaCodec + OpenGL ES 的协同工作。

核心思路: 解码视频帧 → 在帧上叠加贴纸(通过OpenGL) → 重新编码 → 用MediaMuxer合成输出。

我个人习惯把贴纸分为两类:

  • 静态贴纸:位置固定,不随时间变化
  • 动态贴纸:位置、大小、旋转角度随时间变化(比如飘落的雪花、跳动的心形)

今天咱们重点讲动态贴纸。为什么?因为静态贴纸只是动态贴纸的一个特例——你把时间参数固定住就行了。

二、整体架构:一张图看懂流程

先别急着写代码。我建议你先把整体流程理清楚。下面这张图是我自己总结的,你一看就明白。

动态贴纸叠加核心流程 视频文件输入 MP4 / MOV MediaCodec 解码 输出 YUV / Surface OpenGL ES 渲染 叠加贴纸纹理 MediaCodec 编码 H.264 / HEVC MediaMuxer 复用 混合音视频轨道 输出带贴纸视频 MP4 文件 贴纸控制参数(每帧更新) 位置(x,y) | 缩放 | 旋转角度 | 透明度 时间戳 → 插值计算 → 动态变化 音频轨道(直通) 不处理,直接复用

你看,整个流程并不复杂。关键点在于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