24、截图与GIF生成:视频帧截图、连续帧合成GIF动图

做播放器做到这个阶段,大家应该都有感觉了——解码、渲染、同步这些核心流程跑通之后,剩下的就是怎么把视频“玩出花”来。今天聊的截图和GIF生成,就是两个非常实用的功能。

我个人习惯把这两个功能放在一起讲,因为它们的底层逻辑是相通的:都是从视频流里“捞”帧数据。只不过截图是捞一帧,GIF是捞一堆帧再拼起来。

24.1 视频帧截图:从Surface到Bitmap

先说说截图。这个需求很常见,比如用户看到某个精彩画面,想保存下来发朋友圈。那问题来了——视频画面是在Surface上渲染的,我们怎么把它变成Bitmap?

其实思路很简单:从解码器输出的帧里,把数据拷贝出来。具体怎么做?我给大家梳理了三种主流方案。

方案一:MediaMetadataRetriever(最简单)

如果你不需要实时截图,比如用户点了个按钮“截取当前时间点的画面”,那直接用系统API就行。

MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(videoPath);
Bitmap bitmap = retriever.getFrameAtTime(timeUs, 
    MediaMetadataRetriever.OPTION_CLOSEST_SYNC);
retriever.release();

嗯,这里要注意:OPTION_CLOSEST_SYNC 取的是最近的关键帧,不是精确时间点。如果你要精确到毫秒级,用 OPTION_CLOSEST,但性能会差一些。

我的经验:MediaMetadataRetriever 适合非实时场景。我在做一个短视频剪辑工具时,用它来做封面图提取,效果不错。但如果你在播放过程中截图,它就不太合适了——因为它会重新打开解码器,有延迟。

方案二:从SurfaceView/TextureView截图

如果你用的是TextureView,那就方便了。它有个 getBitmap() 方法,可以直接拿到当前渲染的画面。

Bitmap bitmap = textureView.getBitmap();

一行代码搞定。但有个坑——这个方法拿到的Bitmap是经过SurfaceFlinger合成的,分辨率可能不是你想要的。我曾经遇到过一个问题:视频是4K的,但TextureView显示区域只有1080p,截出来的图就是1080p的。

注意:SurfaceView不支持getBitmap()。如果你用SurfaceView做渲染,想截图就得走方案三。

方案三:从解码器OutputBuffer拷贝(最灵活)

这是我最推荐的方式,也是专业播放器常用的方案。原理很简单:在解码器输出帧时,把数据从Buffer里拷贝出来,转成Bitmap。

// 在解码循环中
int outputIndex = codec.dequeueOutputBuffer(bufferInfo, timeoutUs);
if (outputIndex >= 0) {
    MediaCodec.BufferInfo info = bufferInfo;
    ByteBuffer outputBuffer = codec.getOutputBuffer(outputIndex);
    
    // 关键:把YUV数据转成Bitmap
    Bitmap bitmap = yuvToBitmap(outputBuffer, width, height);
    
    // 释放buffer
    codec.releaseOutputBuffer(outputIndex, true);
}

这里有个技术难点:YUV转RGB。解码器输出的通常是YUV420格式,而Bitmap需要的是ARGB_8888。转换算法不难,但要注意性能——毕竟你是在解码线程里操作。

核心要点:

  • 用RenderScript或libyuv做颜色空间转换,比纯Java快3-5倍
  • 截图时最好暂停解码,避免画面撕裂
  • 保存Bitmap用PNG格式,质量高且支持透明

24.2 连续帧合成GIF:从序列到动图

截图讲完了,咱们聊聊GIF。说白了,GIF就是把连续的多帧画面拼成一个动图文件。我刚开始做这个功能时,以为很简单——不就是把一堆Bitmap塞进去吗?结果踩了不少坑。

GIF文件结构速览

先简单了解一下GIF的格式。它其实挺古老的,1987年就有了。结构大致是这样的:

GIF文件头 (6字节) → 逻辑屏幕描述块 → 全局颜色表 → 
  图像块1 (局部颜色表 + 图像数据) → 
  图像块2 → ... → 
  图像块N → 结尾标记

每个图像块就是一帧。GIF支持透明色、帧间延迟、循环次数等参数。说白了,你只要按这个格式把数据填进去,就能生成一个可播放的GIF。

方案一:使用Android内置库(不推荐)

Android SDK里其实有个 android.graphics.Movie 类,可以解析GIF。但注意——它只能读,不能写。所以生成GIF还得靠第三方库或自己写。

方案二:使用开源库(推荐)

我个人习惯用 android-gif-encoder 这个库,轻量且稳定。用法很简单:

GifEncoder encoder = new GifEncoder();
encoder.start(outputPath);
encoder.setDelay(100); // 每帧延迟100ms,相当于10fps
encoder.setRepeat(0);   // 0表示无限循环

for (Bitmap frame : frameList) {
    encoder.addFrame(frame);
}

encoder.finish();

你看,核心就三步:开始、加帧、结束。但这里有个性能问题——如果帧数太多,内存会爆。比如你要生成一个10秒的GIF,每秒10帧,那就是100帧。每帧如果是1080p的Bitmap,光内存就得几百MB。

我的优化建议:
  • 先缩放到合适尺寸,比如480x270,GIF不需要太高分辨率
  • 用RGB_565格式,比ARGB_8888省一半内存
  • 控制帧率,5-10fps就够用了,再高文件体积会爆炸

方案三:自己实现GIF编码器(进阶)

如果你对性能有极致要求,或者想完全掌控编码过程,可以自己写一个。核心算法是LZW压缩——这是GIF标准里规定的压缩方式。

// 伪代码:LZW压缩核心
public byte[] lzwCompress(int[] pixels) {
    // 1. 初始化字典,包含所有单像素颜色
    // 2. 遍历像素,查找最长匹配
    // 3. 输出编码,更新字典
    // 4. 当字典满时,清空并重新开始
}

说实话,自己实现LZW挺折腾的。我当年为了搞懂它,啃了好几天的GIF规范文档。如果你不是对底层特别感兴趣,直接用现成库就好。

24.3 实战:截图+GIF合成完整流程

好了,理论讲完了,咱们来个实战。假设你要做一个功能:用户长按屏幕,开始录制GIF;松手后,把这段时间内的帧合成GIF。

流程是这样的:

  1. 用户长按时,开启一个帧缓存队列
  2. 每解码一帧,把帧数据(缩略图)加入队列
  3. 用户松手时,停止缓存
  4. 把队列里的帧传给GIF编码器
  5. 保存GIF文件

这里有个关键点:帧缓存队列要控制大小。我曾经遇到过一个问题:用户长按了30秒,结果队列里存了300帧,内存直接OOM了。

解决方案:
  • 限制最大录制时长,比如5秒
  • 降低缓存帧的分辨率,比如缩放到320x180
  • 使用LRU缓存,超出限制时丢弃最早的帧

代码实现大致如下:

public class GifRecorder {
    private static final int MAX_FRAMES = 50;
    private static final int TARGET_WIDTH = 320;
    private static final int TARGET_HEIGHT = 180;
    
    private Queue<Bitmap> frameQueue = new LinkedList<>();
    private boolean isRecording = false;
    
    public void startRecording() {
        isRecording = true;
        frameQueue.clear();
    }
    
    public void addFrame(Bitmap originalFrame) {
        if (!isRecording) return;
        
        // 缩放到目标尺寸
        Bitmap scaled = Bitmap.createScaledBitmap(
            originalFrame, TARGET_WIDTH, TARGET_HEIGHT, true);
        
        frameQueue.offer(scaled);
        
        // 控制队列大小
        if (frameQueue.size() > MAX_FRAMES) {
            frameQueue.poll().recycle(); // 回收最旧的帧
        }
    }
    
    public void stopRecording(String outputPath) {
        isRecording = false;
        
        GifEncoder encoder = new GifEncoder();
        encoder.start(outputPath);
        encoder.setDelay(100);
        encoder.setRepeat(0);
        
        for (Bitmap frame : frameQueue) {
            encoder.addFrame(frame);
            frame.recycle(); // 用完就回收
        }
        
        encoder.finish();
        frameQueue.clear();
    }
}

你看,代码其实不复杂。核心就是:控制内存、控制分辨率、控制帧率。做到这三点,GIF生成功能就稳了。

24.4 性能优化与避坑指南

最后,我把自己踩过的坑总结一下,希望能帮大家少走弯路。

问题 原因 解决方案
截图颜色偏绿 YUV转RGB时颜色空间不对 使用正确的转换矩阵,推荐BT.709
GIF文件太大 帧分辨率高、颜色数多 缩小尺寸、降低颜色深度(256色足够)
GIF播放卡顿 帧间延迟设置不合理 根据帧率计算延迟:delay = 1000 / fps
内存溢出 缓存了太多原始帧 使用缩略图、限制队列大小

嗯,说到颜色偏绿这个问题,我印象特别深。有一次做直播截图功能,截出来的图全是绿的。查了半天才发现是YUV的采样格式搞错了——解码器输出的是NV12,但我按YUV420P去解析,颜色当然不对。

所以大家在做截图时,一定要先确认解码器输出的颜色格式。可以通过 MediaCodec.getOutputFormat() 拿到 KEY_COLOR_FORMAT 字段,常见的值是 COLOR_FormatYUV420SemiPlanar(NV12)或 COLOR_FormatYUV420Planar(I420)。

总结一下今天的内容:

  • 截图有三种方案:MediaMetadataRetriever、TextureView.getBitmap()、解码器Buffer拷贝
  • GIF生成的核心是控制内存和分辨率
  • 颜色格式转换是截图最容易出错的地方
  • 帧缓存队列要限制大小,防止OOM

好了,截图和GIF生成就聊到这儿。这两个功能在实际项目中非常实用,希望大家能动手实现一下。遇到问题别慌,先检查颜色格式,再检查内存管理——大部分坑都出在这两个地方。

截图与GIF生成核心流程 视频解码器 截图:单帧提取 GIF:连续帧缓存 YUV → Bitmap → 保存PNG 缩略图队列 → GIF编码器 → 保存GIF 输出文件

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