一、为什么要在视频上加进度条?
说实话,我第一次接到这个需求时,心里是有点懵的。
那是一个短视频编辑App的项目,用户拍完视频后,想在画面上加一个自定义的进度条——不是播放器那种,而是直接渲染到视频画面里,导出后也带着。说白了,就是把进度条变成视频内容的一部分。
你想想看,这跟播放器UI的进度条完全是两码事。播放器的进度条是播放器控件,只在本地显示;而我们要做的,是用MediaMuxer把进度条画面合成到视频轨道里,最终输出一个带进度条的新视频文件。
嗯,这个场景其实挺常见的:
- 录屏教程视频,想显示录制时长
- 运动记录视频,叠加运动进度
- 倒计时视频,显示剩余时间
- 直播回放,标记时间轴
我个人习惯把这类需求归类为"视频叠加层渲染"。今天我们就拿进度条这个经典案例,把整个流程走一遍。
二、整体思路:两条轨道,一个合成
要实现视频上叠加进度条,核心思路其实就一句话:
把进度条渲染成连续的图像帧,然后通过MediaMuxer把这些帧合成到原视频上。
具体来说,我们需要做三件事:
- 解码原视频 — 用MediaExtractor读出视频帧
- 生成进度条帧 — 用Canvas绘制每一帧的进度条
- 编码合成 — 用MediaMuxer把叠加后的帧写回新视频
下面这张图可以帮你快速理解整个流程:
看到这个流程,你可能已经发现了:音频轨道我们基本不动,直接复用原视频的音频数据。真正干活的是视频轨道这一路。
三、核心代码实现
好,理论说完了,咱们直接上代码。我会把关键步骤拆开讲,每个部分都附上我在项目中踩过的坑。
3.1 初始化:准备输入和输出
// 初始化提取器
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(inputPath);
// 找到视频轨道
int videoTrackIndex = -1;
MediaFormat videoFormat = null;
for (int i = 0; i < extractor.getTrackCount(); i++) {
MediaFormat format = extractor.getTrackFormat(i);
String mime = format.getString(MediaFormat.KEY_MIME);
if (mime != null && mime.startsWith("video/")) {
videoTrackIndex = i;
videoFormat = format;
break;
}
}
extractor.selectTrack(videoTrackIndex);
// 初始化编码器
MediaCodec encoder = MediaCodec.createEncoderByType(MediaFormat.MIME_TYPE_AVC);
MediaFormat outputFormat = MediaFormat.createVideoFormat(
MediaFormat.MIME_TYPE_AVC,
videoFormat.getInteger(MediaFormat.KEY_WIDTH),
videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
);
outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000);
outputFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
outputFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
encoder.start();
💡 小提示:编码器的宽高一定要跟原视频一致,否则合成后画面会变形。我刚开始做的时候,随手写了个720p,结果原视频是1080p的,输出画面被拉伸了——嗯,那次被测试小姐姐骂得不轻。
3.2 解码原视频帧
// 解码器配置
MediaCodec decoder = MediaCodec.createDecoderByType(
videoFormat.getString(MediaFormat.KEY_MIME)
);
decoder.configure(videoFormat, surface, null, 0);
decoder.start();
// 注意:这里用Surface方式解码效率更高
// 但我们需要拿到Bitmap做叠加,所以要用ImageReader
ImageReader imageReader = ImageReader.newInstance(
width, height, ImageFormat.YUV_420_888, 2
);
surface = imageReader.getSurface();
这里有个关键点:为什么不用Surface直接显示,而是要用ImageReader?
因为我们要在每一帧上画进度条,必须拿到像素数据。Surface方式虽然性能好,但拿不到Bitmap。ImageReader虽然慢一点,但能让我们操作每一帧的像素。
3.3 绘制进度条
这部分是核心中的核心。我直接贴一个我项目里用过的工具方法:
private Bitmap drawProgressBar(Bitmap originalFrame, float progress, int width, int height) {
// 创建可编辑的Bitmap
Bitmap overlay = originalFrame.copy(Bitmap.Config.ARGB_8888, true);
Canvas canvas = new Canvas(overlay);
// 进度条参数
int barHeight = 40; // 进度条高度
int barMargin = 60; // 底部边距
int barWidth = width - 120; // 进度条宽度
int barX = 60; // 起始X
int barY = height - barHeight - barMargin; // 起始Y
// 绘制背景条(灰色)
Paint bgPaint = new Paint();
bgPaint.setColor(Color.parseColor("#80000000")); // 半透明黑
bgPaint.setStyle(Paint.Style.FILL);
canvas.drawRoundRect(barX, barY, barX + barWidth, barY + barHeight,
20, 20, bgPaint);
// 绘制进度条(蓝色)
Paint progressPaint = new Paint();
progressPaint.setColor(Color.parseColor("#FF4A90D9"));
progressPaint.setStyle(Paint.Style.FILL);
int currentWidth = (int)(barWidth * progress);
canvas.drawRoundRect(barX, barY, barX + currentWidth, barY + barHeight,
20, 20, progressPaint);
// 绘制进度文字
Paint textPaint = new Paint();
textPaint.setColor(Color.WHITE);
textPaint.setTextSize(28);
textPaint.setAntiAlias(true);
String progressText = String.format("%.0f%%", progress * 100);
float textX = barX + barWidth / 2f - textPaint.measureText(progressText) / 2f;
float textY = barY + barHeight / 2f + 10f;
canvas.drawText(progressText, textX, textY, textPaint);
return overlay;
}
⚠️ 注意:Bitmap.Config一定要用ARGB_8888,不要用RGB_565。RGB_565不支持透明度,画出来的进度条边缘会有锯齿。我曾经因为这个被UI设计师追着改了三版...
3.4 用MediaMuxer合成输出
最后一步,把编码后的帧和原音频合到一起:
MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
// 添加视频轨道
int videoMuxerTrack = muxer.addTrack(encoder.getOutputFormat());
// 添加音频轨道(复用原视频的)
int audioMuxerTrack = -1;
// ... 这里需要单独提取音频轨道,代码略长,后面章节会详细讲
muxer.start();
// 循环处理每一帧
while (!isFinished) {
// 1. 从解码器获取原始帧
// 2. 调用drawProgressBar()叠加进度条
// 3. 送入编码器
// 4. 从编码器取出编码后的数据
// 5. 写入MediaMuxer
}
muxer.stop();
muxer.release();
四、避坑指南
这部分是我用真金白银换来的经验,你仔细看看:
| 常见问题 | 原因 | 解决方案 |
|---|---|---|
| 输出视频没有声音 | 只处理了视频轨道,忘了复用音频 | 用MediaExtractor单独提取音频轨道,通过MediaMuxer的addTrack加入 |
| 画面卡顿/掉帧 | Bitmap操作太慢,跟不上帧率 | 使用BitmapPool复用对象,减少GC;或者降低绘制精度 |
| 进度条位置不对 | 不同视频分辨率不同,硬编码坐标 | 所有坐标都基于视频宽高做百分比计算 |
| 内存溢出 | 每一帧都创建新的Bitmap | 复用Bitmap,用Canvas.drawBitmap直接覆盖 |
我曾经在一个4K视频上做进度条叠加,没做Bitmap复用,结果处理到第30秒直接OOM崩溃了。那次之后,我养成了所有临时Bitmap必须复用的习惯。
五、性能优化建议
如果你只是处理短视频(30秒以内),上面的代码够用了。但如果你想做长视频或者高分辨率,这几个优化点必须注意:
- 使用Surface + OpenGL渲染:绕过Bitmap,直接在GPU层面叠加,性能提升5-10倍
- 异步处理:解码、绘制、编码三个环节用HandlerThread分开跑,避免互相阻塞
- 降低绘制频率:进度条不需要每帧都重绘,每秒更新10-15次就够,肉眼根本看不出区别
- 预分配内存:启动时就把所有要用到的Bitmap、Buffer一次性分配好,运行时不再申请
💡 我的个人习惯:在正式编码前,先用一个小视频(5秒、720p)跑通整个流程,确认没问题了再上高分辨率。这样调试速度快很多,不用每次等好几分钟。
六、小结
今天这一章,我们把视频叠加进度条的完整流程走了一遍。核心就是三件事:解码原视频 → 绘制进度条 → 编码合成。MediaMuxer在这里扮演的是"组装车间"的角色,把处理好的视频轨道和原音频轨道合并成一个新文件。
说实话,这个功能看起来简单,但真正做起来坑不少。尤其是性能问题,稍不注意就会翻车。不过别担心,后面几章我会专门讲如何用OpenGL做高性能叠加,以及如何处理音频轨道复用的问题。
嗯,今天就到这里。代码你可以直接拿去用,但建议先跑通demo再集成到项目里。有什么问题,咱们评论区见。
公众号:蓝海资料掘金营,微信deep3321