21、MediaMuxer实战:视频加水印(Watermark)——在视频上叠加图片或文字水印

水印,说白了就是在视频画面上「贴」一层东西。可能是你的Logo,也可能是时间戳,或者一段文字。我最早接触这个需求,是帮一个短视频团队做批量处理工具。他们每天要发几百条视频,每条都得带上品牌Logo。那时候我就想,嗯,这事得用MediaCodec + MediaMuxer来搞定。

今天这一章,我们就来聊聊怎么在视频上叠加水印。我会把图片水印和文字水印都讲清楚,顺便分享一些我踩过的坑。

水印叠加的核心思路

你想想看,视频本质上是一帧一帧的图片。要在视频上加东西,无非就是:

  1. 把每一帧解码出来
  2. 在帧上画上水印
  3. 再编码回去

这个过程,我们通常叫它「转码」。说白了就是解码→处理→编码。MediaCodec负责解码和编码,MediaMuxer负责把处理后的数据写回文件。

核心流程:

视频文件 → MediaExtractor 解封装 → MediaCodec 解码 → Surface 渲染 → 在 Surface 上绘制水印 → MediaCodec 编码 → MediaMuxer 封装 → 输出文件

这里有个关键点:水印绘制不是在原始帧数据上直接操作,而是通过Surface来做。为什么?因为MediaCodec解码出来的数据是YUV格式,直接操作YUV数据画东西,效率低还容易出错。用Surface,我们可以用OpenGL或者Canvas来画,方便多了。

方案对比:两种主流做法

方案 原理 优点 缺点
Surface + Canvas 解码到Surface,用Canvas绘制水印 实现简单,适合文字水印 性能一般,复杂效果受限
Surface + OpenGL 解码到Surface,用OpenGL ES绘制 性能好,支持各种特效 实现复杂,需要GL基础

我个人习惯,如果只是简单的文字水印或者静态图片,用Canvas就够了。但如果你要做动态水印、旋转、缩放、透明度渐变这些,那还是得上OpenGL。

实战:用Surface + Canvas实现图片水印

我们先从简单的开始。下面这段代码,演示了如何给视频加上一个图片水印。

// 伪代码,展示核心逻辑
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(inputPath);

MediaCodec decoder = MediaCodec.createDecoderByType(mimeType);
decoder.configure(format, outputSurface, null, 0);
decoder.start();

MediaCodec encoder = MediaCodec.createEncoderByType(mimeType);
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
encoder.start();

// 关键:创建输入Surface给编码器
Surface encoderInputSurface = encoder.createInputSurface();

// 创建一个虚拟的Surface,用于解码输出
// 然后在这个Surface上绘制水印
// 最后把绘制结果传给编码器的输入Surface

// 循环处理每一帧
while (!isEOS) {
    // 1. 解码一帧
    int inputIndex = decoder.dequeueInputBuffer(TIMEOUT_US);
    // ... 填充数据
    
    // 2. 解码输出到Surface
    int outputIndex = decoder.dequeueOutputBuffer(info, TIMEOUT_US);
    if (outputIndex >= 0) {
        // 3. 在Surface上绘制水印
        Canvas canvas = outputSurface.lockCanvas();
        // 绘制图片水印
        canvas.drawBitmap(watermarkBitmap, x, y, null);
        outputSurface.unlockCanvasAndPost(canvas);
        
        // 4. 通知编码器处理
        encoder.signalEndOfInputStream();
    }
}

// 最后用MediaMuxer封装
MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
// ... 添加轨道,写入数据

小技巧:水印的位置,我一般用百分比来计算。比如水印放在右下角,x = 视频宽度 - 水印宽度 - 边距。这样不管视频分辨率是多少,水印都在同一个相对位置。

文字水印的实现

文字水印和图片水印大同小异,只是把drawBitmap换成drawText。但有几个细节要注意:

  • 字体大小:要根据视频分辨率动态计算。我习惯用视频高度的1/30作为基准字号。
  • 文字颜色:白色加黑色描边,基本所有背景都能看清。
  • 抗锯齿:记得设置Paint的ANTI_ALIAS_FLAG,不然文字边缘会很难看。
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(Color.WHITE);
paint.setTextSize(videoHeight / 30f);
paint.setShadowLayer(2f, 1f, 1f, Color.BLACK);

canvas.drawText("蓝海资料掘金营", x, y, paint);

注意:文字水印的坐标y是基线位置,不是文字顶部。如果你想让文字在某个矩形内居中,需要用Paint.getTextBounds()测量文字高度,然后计算基线偏移。我曾经在这里栽过跟头,文字总是偏上或偏下。

SVG:水印叠加的核心流程

下面这张图,展示了整个水印叠加的完整流程。我建议你仔细看看,尤其是数据流向的部分。

视频水印叠加核心流程 输入视频文件 MediaExtractor MediaCodec 解码 Surface 水印绘制处理 Canvas.drawBitmap / drawText MediaCodec 编码 编码Surface MediaMuxer 封装 输出视频文件 音频旁路 不处理 虚线表示音频轨道不经过水印处理,直接复用

避坑指南:我踩过的几个坑

做水印叠加,看起来简单,实际坑不少。我挑几个典型的说说:

  • 时间戳同步:解码和编码的时间戳必须一一对应。我曾经因为时间戳没对齐,导致视频播放时一卡一卡的。解决办法是,解码输出时记录presentationTimeUs,编码输入时原样传过去。
  • 颜色格式:有些设备解码输出的颜色格式和编码器要求的不一致。我建议用COLOR_FormatSurface,让系统帮我们处理格式转换。
  • 性能问题:如果每帧都用Canvas绘制,性能开销不小。对于高清视频,我建议降低绘制频率,比如每两帧画一次,或者用OpenGL替代。

重要提醒:不要在主线程里做水印处理。解码、编码、绘制都应该在子线程中完成。否则,你的App会ANR,用户会骂娘。我见过太多人犯这个错了。

进阶:动态水印

如果你想让水印动起来,比如从左上角飘到右下角,或者每隔几秒闪烁一下,那就要在每一帧里改变水印的位置或透明度。实现起来也不难:

// 动态水印示例:水印从左上角移动到右下角
float progress = (float)frameIndex / totalFrames;
float x = startX + (endX - startX) * progress;
float y = startY + (endY - startY) * progress;

// 透明度也渐变
int alpha = (int)(255 * (1 - progress));
paint.setAlpha(alpha);

canvas.drawBitmap(watermarkBitmap, x, y, paint);

嗯,这里要注意,动态水印的计算要放在解码循环里,每帧都重新算。计算量不大,不用担心性能。

总结

视频加水印,说白了就是「解码→绘制→编码」三步走。用Surface + Canvas是最容易上手的方式,适合大多数场景。如果你追求性能或者要做复杂特效,那就上OpenGL。

我个人建议,先从图片水印开始练手,搞懂了再去做文字水印,最后再挑战动态水印。一步一步来,别想一口吃成胖子。

好了,这一章就到这里。代码我已经放在项目里了,你可以直接拿去用。有问题的话,欢迎来找我交流。


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