19、MediaMuxer实战:GIF生成——从视频中提取帧生成GIF动图

说实话,GIF 生成这个需求,我在项目里遇到过不下十次。用户想从自己拍的视频里截取一段搞笑的瞬间,做成表情包分享出去。你想想看,这功能要是做得好,用户粘性直接拉满。

但 GIF 生成这事儿,坑不少。我最早做的时候,以为就是逐帧解码然后编码成 GIF 就完事了。结果生成的 GIF 要么太大,要么画质惨不忍睹,要么播放速度不对。嗯,今天咱们就把这些坑一个一个填上。

GIF 生成的核心流程

说白了,从视频到 GIF,就三步:

  1. 解码视频帧——从视频文件中提取出每一帧的 Bitmap
  2. 缩放与裁剪——把帧缩放到 GIF 需要的尺寸
  3. 编码为 GIF——把处理后的帧序列写入 GIF 文件

这里有个关键点:GIF 格式本身只支持 256 色,而且压缩效率远不如视频编码。所以如果你直接拿 1080p 的视频帧去生成 GIF,文件大小会爆炸。我建议你先把帧缩放到 480p 以下,甚至 320p 都够用。

核心原则:GIF 是给社交分享用的,不是给电影院用的。画质够用就行,文件大小才是王道。

MediaMuxer 在 GIF 生成中的角色

你可能会问:MediaMuxer 不是用来混合音视频的吗?跟 GIF 有什么关系?

其实 MediaMuxer 在这里扮演的是「解码端」的角色。我们需要用 MediaExtractor 配合 MediaCodec 来解码视频帧,而 MediaMuxer 本身不参与 GIF 编码。但整个流程中,如何高效地从视频中提取指定时间范围的帧,这才是 MediaMuxer 相关知识的用武之地。

我个人习惯把视频解码和 GIF 编码分成两个独立的模块。解码模块负责输出 Bitmap 列表,GIF 编码模块只管把 Bitmap 列表写成 GIF 文件。这样职责清晰,也方便单元测试。

视频帧提取:关键帧 vs 非关键帧

这里有个坑,我踩过。视频编码里有 I 帧(关键帧)、P 帧(预测帧)、B 帧(双向预测帧)。如果你用 MediaExtractor 直接 seek 到某个时间点,它不一定能精确落到你想要的帧上,而是落到最近的关键帧。

为什么会这样?因为 MediaExtractor 的 seekTo 方法默认是 SEEK_TO_CLOSEST_SYNC,它只保证定位到关键帧。如果你想要精确到非关键帧,就得用 SEEK_TO_PREVIOUS_SYNC 然后逐帧解码过去。

// 精确 seek 到指定时间戳
mediaExtractor.seekTo(targetTimeUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC);

// 然后逐帧解码,直到达到目标时间戳
while (true) {
    int sampleSize = mediaExtractor.readSampleData(byteBuffer, 0);
    if (sampleSize < 0) break;

    mediaCodec.queueInputBuffer(...);
    // 解码并判断当前帧的时间戳
    if (currentPtsUs >= targetTimeUs) {
        // 拿到这一帧
        break;
    }
    mediaExtractor.advance();
}

小技巧:如果你只需要每隔 N 帧取一帧(比如每秒取 10 帧),可以不用精确 seek,直接连续解码然后按帧序号采样。这样代码更简单,性能也更好。

GIF 编码:Android 原生方案

Android SDK 本身没有提供 GIF 编码器。但 Android 的 android.graphics 包下有一个 AnimatedGifEncoder 类,不过它是隐藏 API,不推荐直接使用。

我常用的方案有两个:

方案 优点 缺点
AndroidX 的 GifEncoder 官方支持,API 简洁 只支持 API 26+
第三方库(如 gif-encoder 兼容性好,可定制 需要引入依赖
自己实现 LZW 压缩 完全可控 开发成本高,不推荐

我个人推荐用 AndroidX 的 GifEncoder,前提是你的 minSdk 在 26 以上。如果还要兼容低版本,那就用第三方库吧。

实战代码:从视频到 GIF

下面是一个完整的示例,演示如何从视频中提取帧并生成 GIF。代码我简化了,但核心逻辑都在。

fun videoToGif(
    videoPath: String,
    outputPath: String,
    startTimeMs: Long,
    durationMs: Long,
    frameIntervalMs: Long = 100L,  // 每 100ms 取一帧
    targetWidth: Int = 320,
    targetHeight: Int = 240
) {
    val extractor = MediaExtractor()
    extractor.setDataSource(videoPath)

    // 选择视频轨道
    val trackIndex = selectVideoTrack(extractor)
    extractor.selectTrack(trackIndex)

    // 配置 MediaCodec 解码器
    val format = extractor.getTrackFormat(trackIndex)
    val codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!)
    codec.configure(format, null, null, 0)
    codec.start()

    // 准备 GIF 编码器
    val gifEncoder = GifEncoder()
    gifEncoder.start(outputPath)
    gifEncoder.setSize(targetWidth, targetHeight)
    gifEncoder.setFrameRate(1000f / frameIntervalMs)  // 帧率

    // 解码循环
    val bufferInfo = MediaCodec.BufferInfo()
    var isEOS = false
    var frameCount = 0

    while (!isEOS) {
        // 输入数据
        val inputIndex = codec.dequeueInputBuffer(10000)
        if (inputIndex >= 0) {
            val inputBuffer = codec.getInputBuffer(inputIndex)
            val sampleSize = extractor.readSampleData(inputBuffer!!, 0)
            if (sampleSize < 0) {
                codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                isEOS = true
            } else {
                codec.queueInputBuffer(inputIndex, 0, sampleSize, extractor.sampleTime, 0)
                extractor.advance()
            }
        }

        // 输出数据
        val outputIndex = codec.dequeueOutputBuffer(bufferInfo, 10000)
        if (outputIndex >= 0) {
            val outputBuffer = codec.getOutputBuffer(outputIndex)
            val bitmap = bufferToBitmap(outputBuffer!!, bufferInfo, format)

            // 按时间间隔采样
            if (bufferInfo.presentationTimeUs / 1000 >= startTimeMs &&
                bufferInfo.presentationTimeUs / 1000 <= startTimeMs + durationMs) {
                if (frameCount % (frameIntervalMs / 33L).toInt() == 0) {
                    val scaledBitmap = Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
                    gifEncoder.addFrame(scaledBitmap)
                    scaledBitmap.recycle()
                }
                frameCount++
            }

            codec.releaseOutputBuffer(outputIndex, false)
            bitmap.recycle()
        }
    }

    // 清理
    gifEncoder.finish()
    codec.stop()
    codec.release()
    extractor.release()
}

注意:上面的代码中 bufferToBitmap() 方法需要你自己实现,核心是把 MediaCodec 输出的 YUV 数据转换成 Bitmap。这个过程涉及到颜色空间转换,我建议用 RenderScript 或直接使用 YuvImage 类。

GIF 文件大小优化

生成 GIF 不难,难的是生成「小而美」的 GIF。我总结了几条经验:

  • 降低帧率:视频通常是 30fps,但 GIF 用 10-15fps 就够了。人眼对 GIF 的流畅度要求没那么高。
  • 缩小尺寸:320x240 是社交分享的黄金尺寸。再大就浪费了。
  • 减少颜色数:GIF 支持 256 色,但如果你用 128 色甚至 64 色,文件大小能减少 30%-50%。画质损失肉眼几乎看不出来。
  • 裁剪无用区域:如果视频上下有黑边,先裁剪掉再缩放。别让 GIF 编码器去压缩那些无意义的黑色像素。

我曾经做过一个测试:同样的 3 秒视频片段,用 320x240、10fps、128 色生成的 GIF 只有 800KB,而用 640x480、30fps、256 色生成的 GIF 高达 8MB。用户会选哪个?不言而喻。

SVG 流程图:GIF 生成核心逻辑

GIF 生成核心流程 步骤 1:视频解码 MediaExtractor + MediaCodec 步骤 2:帧采样 按时间间隔提取 Bitmap 步骤 3:缩放 Bitmap.createScaledBitmap 步骤 4:GIF 编码 GifEncoder.addFrame() 优化决策 降低帧率 10-15fps 缩小尺寸 320x240 减少颜色数 128 色 输出 GIF 文件 小而美,适合社交分享 解码 采样 缩放 编码 优化决策

避坑指南

最后,分享几个我踩过的坑:

  • 内存泄漏:解码出来的 Bitmap 如果不及时 recycle,内存会暴涨。尤其是你要处理几十帧甚至上百帧的时候。我建议用对象池来复用 Bitmap。
  • 颜色空间问题:MediaCodec 输出的 YUV 格式因设备而异。有的设备是 NV12,有的是 NV21,还有的是 YV12。写转换代码时一定要做兼容处理。
  • GIF 播放速度:GIF 的帧率是通过每帧的延迟时间控制的。如果你设置帧率为 10fps,那每帧的延迟就是 100ms。但有些播放器对延迟时间的解析有偏差,建议你实际测试一下。
  • 文件大小失控:我曾经生成过一个 10 秒的 GIF,结果文件有 15MB。后来发现是忘了缩放,直接用了 1080p 的原始帧。嗯,从那以后我把缩放逻辑写成了强制性的,不允许跳过。

GIF 生成这个功能,看起来简单,但要做好其实挺考验细节的。希望今天的内容能帮你少走一些弯路。如果你在实际开发中遇到什么问题,欢迎随时交流。

公众号:蓝海资料掘金营,微信 deep3321