跳至内容
返回

使用 Media3 给视频添加水印

发布于:  at  07:30 下午
⏱️ 约 7217 字 预计阅读 19 分钟

前言

有一个比较常见的需求:导出分享视频的时候给视频加上 Logo,或者给录屏素材加上出处标识。

Android 上主流的做法大致有这么几种:

  1. FFmpeg —— 经典方案,overlay 滤镜,CPU 软件编解码
  2. Media3 Transformer —— 官方方案,纯 Kotlin/Java,底层走 MediaCodec 硬编硬解 + GPU 效果
  3. 手搓 OpenGL 绘制,写个加水印的 shader,再把渲染出的画面重新编码

其中第一种方案最常用:引入一个预编译好(或者自己编译)的 FFmpeg 库,把加水印的命令丢给它执行。代价是安装包体积增加不少;当然也可以自己编译 FFmpeg、裁掉用不到的功能,做一个平衡。

第二个方案是 Jetpack 提供的视频编辑组件,官方自带添加水印的能力,用起来非常简单,支持文字水印和图片水印。

第三个方案更原始,基本就是第二个方案的手搓版本。

本文介绍 Media3 的水印添加方案,并和 FFmpeg 做一次对比。

Media3 方案

Media3 把”给视频加效果”做成了纯 Kotlin/Java 的 API,硬件编解码和 GPU 渲染都封装在底层,不用碰 JNI。

添加依赖

Transformermedia3-transformer 里,水印效果 OverlayEffectmedia3-effect 里,三个一起加:

implementation(libs.media3.common)
implementation(libs.media3.effect)
implementation(libs.media3.transformer)

水印 Bitmap

两条路线共用同一张 Canvas 画的水印图,这样对比才公平:

fun createWatermarkBitmap(): Bitmap {
    val width = 800
    val height = 240
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)

    val paint = Paint().apply {
        color = Color.argb(230, 255, 255, 255)
        textSize = 140f
        isAntiAlias = true
        textAlign = Paint.Align.CENTER
        typeface = Typeface.DEFAULT_BOLD
    }
    val text = "CHAOSGOO"
    val baseline = (height + paint.fontMetrics.descent - paint.fontMetrics.ascent) / 2 - paint.fontMetrics.descent
    canvas.drawText(text, width / 2f, baseline, paint)
    return bitmap
}

StaticOverlaySettings:锚点坐标系

水印放哪、多大,由 StaticOverlaySettings 控制。它用的是一个归一化锚点坐标系,第一次用容易绕晕,展开说:

所以”右下角贴边”就是背景帧右下角 (1, 1) 对水印右下角 (1, 1):

val overlaySettings = StaticOverlaySettings.Builder()
    .setBackgroundFrameAnchor(1f, 1f)   // 背景帧的右下角
    .setOverlayFrameAnchor(1f, 1f)      // 水印自身的右下角
    .setScale(0.2f, 0.2f)               // 水印尺寸 = 视频宽 * 0.2, 视频高 * 0.2
    .setAlphaScale(0.8f)                // 半透明
    .build()

想放左上角,把两个锚点都换成 (-1f, -1f) 就行;想居中,全换 0fsetScale 是相对视频帧宽高的比例,所以同一套配置放到 1080p 和 4K 上,水印不会忽大忽小——这点比 FFmpeg 按像素写偏移舒服多了。

组装 Effect 并启动

val watermarkOverlay = BitmapOverlay.createStaticBitmapOverlay(watermark, overlaySettings)
val overlayEffect = OverlayEffect(listOf(watermarkOverlay))
val effects = Effects(emptyList(), listOf<Effect>(overlayEffect))

val editedMediaItem = EditedMediaItem.Builder(MediaItem.fromUri(inputUri))
    .setEffects(effects)
    .build()

val transformer = Transformer.Builder(context)
    .addListener(object : Transformer.Listener {
        override fun onCompleted(composition: Composition, exportResult: ExportResult) {
            // 完成, exportResult 里有耗时/大小等统计
        }
        override fun onError(
            composition: Composition,
            exportResult: ExportResult,
            exportException: ExportException,
        ) {
            // 失败
        }
    })
    .setVideoMimeType(MimeTypes.VIDEO_H264)
    .setAudioMimeType(MimeTypes.AUDIO_AAC)
    .build()

transformer.start(editedMediaItem, outputPath)

几个点:

进度条

Transformer.getProgress(ProgressHolder) 的返回值就是当前进度百分比,但有个坑:Transformer 的所有方法(包括 getProgress)都必须在创建它的 application thread 上调用,源码里 getProgress 第一行就是 verifyApplicationThread()。所以不能像直觉那样开个子线程轮询,会直接抛 Transformer is accessed on the wrong thread。正确姿势是回到主线程轮询,结束/出错时记得 removeCallbacks 停掉:

val progressHandler = Handler(Looper.getMainLooper())
val progressHolder = ProgressHolder()
progressHandler.post(object : Runnable {
    override fun run() {
        if (!running) return
        val state = transformer.getProgress(progressHolder)
        if (state == Transformer.PROGRESS_STATE_AVAILABLE) {
            callback.onProgress(progressHolder.progress)
        }
        progressHandler.postDelayed(this, 100)
    }
})

// 在 onCompleted / onError 里:
running = false
progressHandler.removeCallbacksAndMessages(null)

文字水印

BitmapOverlay 之外,还有 TextOverlay,用法几乎一样:

val textOverlay = TextOverlay.createStaticTextOverlay(
    SpannableString("CHAOSGOO 2026"),
    overlaySettings,
)

注意字体大小内部固定 100px,想调字号只能靠 setScale 放大缩小——别去找 setTextSize,没有这个口子。

FFmpeg 方案

依赖:ffmpeg-kit 已从 Maven Central 下架

对比实现用的是 com.arthenica:ffmpeg-kit-full-gpl这里有两个坑,第一个是 2025 年之后的:ffmpeg-kit 停更,而且整个包已经从 Maven Central 移除了——直接加依赖会解析失败:

Could not find com.arthenica:ffmpeg-kit-full:6.0-2.LTS.

第二个坑更隐蔽:ffmpeg-kit-fullffmpeg-kit-full-gpl 不是同一个东西——libx264/libx265 是 GPL 编码器,只有 -gpl 后缀的包才带。用 full 包写 -c:v libx264,运行时会直接报:

Unknown encoder 'libx264'

所以要么换 full-gpl,要么别用 x264。代价是 full-gpl 让整个 ffmpeg 二进制受 GPL v3 约束,商用闭源 App 要掂量一下许可问题。

目前还能用的办法:阿里云镜像保留了历史版本,把它加进仓库列表就能解析:

repositories {
    google()
    mavenCentral()
    maven("https://maven.aliyun.com/repository/public") // ffmpeg-kit 残存
}

implementation("com.arthenica:ffmpeg-kit-full-gpl:6.0-2.LTS")

几个注意点:6.0-2.LTS 要求 minSdk 24;full-gpl 的 AAR 比 full 再大一圈(多塞了 x264/x265/xvidcore 几个 GPL 库,full 版 4 个 ABI 的 so 加起来就把 debug 包撑到了 141MB);上面那个 Unknown encoder 'libx264' 就是包选错的典型症状。

overlay 滤镜

ffmpeg-kit 的用法和命令行一模一样,把命令拼成字符串丢进去:

val command = listOf(
    "-y",
    "-i", inputPath,
    "-i", watermarkPath,
    "-filter_complex", "overlay=main_w-overlay_w-24:main_h-overlay_h-24",
    "-c:v", "libx264",
    "-preset", "medium",
    "-c:a", "copy",
    outputPath,
).joinToString(" ")

FFmpegKit.executeAsync(
    command,
    { session ->  // 完成回调, session.returnCode 判断成败
        if (ReturnCode.isSuccess(session.returnCode)) {
            callback.onSuccess(output)
        } else {
            callback.onError(session.getAllLogs().joinToString("\n") { it.message })
        }
    },
    { log ->      // 日志回调, 进度在这解析
        val message = log.message
        val match = TIME_REGEX.find(message)
        val timeSeconds = timeToSeconds(match.groupValues[1])
        callback.onProgress((timeSeconds / durationSeconds * 100).toInt())
    },
    null,          // statistics 回调, 用不到
)

overlay 滤镜的坐标是像素偏移:main_w-overlay_w-24 表示主画面宽减去水印宽再减 24px。和 Media3 的归一化锚点一比,换不同分辨率的视频就得自己算比例。

进度没有现成 API,只能靠正则扒日志里的 time=00:00:12.34:

private val TIME_REGEX = Regex("""time=(\d{2}:\d{2}:\d{2}\.\d{2})""")

总时长用 FFprobeKit.getMediaInformation(path) 拿(6.0-2 是同步 API,直接返回):

val duration = FFprobeKit.getMediaInformation(inputPath)
    .mediaInformation?.duration?.toDoubleOrNull() ?: 0.0

还有一点和 Media3 不一样:ffmpeg 只能读文件路径,SAF 选的 content:// Uri 得先拷到缓存目录(这件脏活 ContentUriUtil 里干了)。

对比

两条路线我都跑通了,先看实测耗时——同一段素材、同一台手机,Media3 走硬件编码只花了 5.3s,ffmpeg 软件编码跑了 60s,差了十倍还多:

Media3 转码耗时:5.3s

FFmpeg 转码耗时:60s

测试素材长短、手机芯片不同,具体数字会变,但这个数量级差距是稳定的——硬编的优势在长视频上会进一步放大。差异主要在这些维度:

Media3 Transformerffmpeg-kit (FFmpeg)
处理管线GPU(GL shader)+ MediaCodec 硬编CPU 软件管线(libavfilter + x264)
水印定位归一化锚点,自动适配分辨率像素偏移,多分辨率要自己算
输入直接吃 content:// Uri只吃文件路径
依赖体积纯 Java/Kotlin,~几 MBAAR 65MB(full 版),arm64 单 ABI 约 23MB
minSdk2124 (LTS 版)
编解码能力MediaCodec 支持的格式FFmpeg 全家桶(full-gpl 含 x264/x265)
编码速度依赖设备硬编,快软件编码,慢,但结果稳定
API 稳定性@UnstableApi,版本间有变动稳定多年,和命令行一致
维护状态Google 官方持续迭代已停更,从 Maven Central 下架
取消/进度cancel() + getProgress()FFmpegKit.cancel() + 扒日志
自定义滤镜只能叠效果/简单变换任意 filter 图,能做花活

怎么选:

参考资料


在以下平台分享此文章: