25、MediaMuxer实战:视频加片头片尾(Intro/Outro)——为视频添加开场和结束片段
各位好,欢迎来到第25章。今天咱们聊点实战的东西——给视频加片头片尾。
说实话,这个需求太常见了。你拍了个Vlog,想加个酷炫的开场动画;或者做了个产品演示,结尾要放个Logo和联系方式。这些场景,说白了就是要把多个视频片段拼成一个完整的视频。
用MediaMuxer来做这件事,其实比你想的要简单。但坑也不少,我当年第一次做的时候,就栽在时间戳上……嗯,咱们慢慢聊。
核心思路:先拆后合
视频加片头片尾,本质上是一个「拼接」操作。但MediaMuxer不支持直接拼接,所以我们要换个思路:
- 第一步:用MediaExtractor分别读取片头、主视频、片尾的轨道数据
- 第二步:用MediaMuxer创建一个新的输出文件
- 第三步:按顺序写入片头→主视频→片尾的媒体数据
听起来简单?关键在时间戳的调整上。每个片段都有自己的时间轴,合并时必须重新计算。
核心公式:每个片段的写入时间戳 = 该片段原始时间戳 + 前面所有片段的总时长
流程图:三片段合并的完整流程
代码实现:一步一步来
好,咱们直接上代码。我习惯把逻辑封装成一个工具类,这样复用起来方便。
public class VideoConcatenator {
private static final String TAG = "VideoConcatenator";
private static final int TIMEOUT_USEC = 10000;
/**
* 拼接多个视频文件
* @param inputPaths 输入文件路径列表(按顺序)
* @param outputPath 输出文件路径
* @return 是否成功
*/
public static boolean concatenate(List<String> inputPaths, String outputPath) {
if (inputPaths == null || inputPaths.size() < 2) {
Log.e(TAG, "至少需要两个输入文件");
return false;
}
MediaMuxer muxer = null;
MediaExtractor extractor = null;
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
try {
// 先解析第一个文件,获取轨道格式
extractor = new MediaExtractor();
extractor.setDataSource(inputPaths.get(0));
int trackCount = extractor.getTrackCount();
// 这里简化处理:只处理第一个视频轨道和第一个音频轨道
int videoTrackIndex = -1;
int audioTrackIndex = -1;
MediaFormat videoFormat = null;
MediaFormat audioFormat = null;
for (int i = 0; i < trackCount; i++) {
MediaFormat format = extractor.getTrackFormat(i);
String mime = format.getString(MediaFormat.KEY_MIME);
if (mime.startsWith("video/") && videoTrackIndex == -1) {
videoTrackIndex = i;
videoFormat = format;
} else if (mime.startsWith("audio/") && audioTrackIndex == -1) {
audioTrackIndex = i;
audioFormat = format;
}
}
// 创建Muxer
muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int muxerVideoTrack = muxer.addTrack(videoFormat);
int muxerAudioTrack = -1;
if (audioFormat != null) {
muxerAudioTrack = muxer.addTrack(audioFormat);
}
muxer.start();
// 逐个处理每个输入文件
long totalTimeUs = 0;
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); // 1MB缓冲区
for (String inputPath : inputPaths) {
extractor = new MediaExtractor();
extractor.setDataSource(inputPath);
// 选择轨道
if (videoTrackIndex != -1) {
extractor.selectTrack(videoTrackIndex);
}
if (audioTrackIndex != -1) {
extractor.selectTrack(audioTrackIndex);
}
// 读取并写入数据
while (true) {
bufferInfo.offset = 0;
bufferInfo.size = extractor.readSampleData(buffer, 0);
if (bufferInfo.size < 0) {
break;
}
bufferInfo.presentationTimeUs = extractor.getSampleTime() + totalTimeUs;
bufferInfo.flags = extractor.getSampleFlags();
int trackIndex = extractor.getSampleTrackIndex();
int muxerTrack = (trackIndex == videoTrackIndex) ? muxerVideoTrack : muxerAudioTrack;
muxer.writeSampleData(muxerTrack, buffer, bufferInfo);
extractor.advance();
}
// 累加当前片段时长
totalTimeUs += getDurationUs(inputPath);
extractor.release();
}
muxer.stop();
muxer.release();
return true;
} catch (Exception e) {
Log.e(TAG, "拼接失败", e);
return false;
} finally {
if (extractor != null) {
extractor.release();
}
if (muxer != null) {
muxer.release();
}
}
}
private static long getDurationUs(String path) {
MediaExtractor extractor = null;
try {
extractor = new MediaExtractor();
extractor.setDataSource(path);
long duration = 0;
while (extractor.advance()) {
duration = extractor.getSampleTime();
}
return duration;
} finally {
if (extractor != null) {
extractor.release();
}
}
}
}
避坑指南:我踩过的那些坑
代码写完了,但直接跑大概率会出问题。我把自己踩过的坑列出来,你注意避开。
坑一:时间戳溢出
我曾经在拼接10个以上片段时,发现最后几个片段的时间戳变成了负数。为什么?因为long类型虽然大,但如果你用微秒累加,总时长超过2^63微秒才会溢出——嗯,这个其实不太可能。真正的问题是:我忘了每个片段内部的时间戳可能不是从0开始的!
解决方案:读取每个片段的第一个采样时间戳,作为基准偏移量。
坑二:轨道格式不一致
片头是1080p 30fps,主视频是720p 60fps,片尾是4K 24fps——三个片段轨道格式不同,MediaMuxer会直接崩溃。因为一个Muxer实例只能添加一组轨道格式。
解决方案:要么统一所有片段的编码参数,要么用MediaCodec做转码。我个人建议前者,省事。
小技巧:用MediaMetadataRetriever快速获取时长
上面代码里我用MediaExtractor遍历来获取时长,效率很低。其实可以用MediaMetadataRetriever直接读元数据:
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(path);
String durationStr = retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_DURATION);
long durationMs = Long.parseLong(durationStr);
long durationUs = durationMs * 1000;
retriever.release();
这样快得多,而且不会破坏Extractor的状态。
进阶:处理不同编码格式
如果片头和主视频编码格式不同怎么办?比如片头是H.264,主视频是H.265。
MediaMuxer不支持混流不同编码格式到同一个轨道。所以你必须做转码。我一般用MediaCodec做解码,然后用MediaMuxer重新编码。但这样性能开销很大,移动端慎用。
一个更实用的方案:在生成片头片尾素材时,就统一编码参数。比如都用H.264、相同分辨率、相同帧率。这样省去转码的麻烦。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 视频编码 | H.264 (AVC) | 兼容性最好,几乎所有设备都支持 |
| 分辨率 | 1920x1080 | 主流分辨率,兼顾画质和性能 |
| 帧率 | 30fps | 与大多数视频一致,避免帧率转换 |
| 音频编码 | AAC | 标准音频格式,采样率建议44100Hz |
性能优化:别让用户等太久
拼接操作本质上是I/O密集型的,CPU占用不高,但磁盘读写压力大。我建议:
- 使用BufferedInputStream:减少系统调用次数
- 异步执行:在子线程中运行,避免ANR
- 显示进度:通过回调通知UI更新进度条
- 控制缓冲区大小:1MB左右比较合适,太大浪费内存,太小增加I/O次数
嗯,说到进度回调,我习惯用接口的方式:
public interface OnProgressListener {
void onProgress(float progress); // 0.0 ~ 1.0
void onComplete(boolean success);
}
然后在循环中计算已处理片段的总时长占比,回调给UI层。这样用户就知道没卡死,心里踏实。
写在最后
视频拼接这个功能,说难不难,说简单也不简单。核心就是时间戳的计算和轨道格式的统一。你只要把这两个点抓住了,剩下的就是体力活。
我个人建议,在实际项目中,最好把片头片尾做成固定模板,用统一的编码参数生成。这样不仅拼接简单,而且用户体验也一致——片头动画的色调、节奏都能把控好。
好了,这一章就到这里。代码你可以直接拿去用,但记得根据自己的场景调整。有问题欢迎交流。