25、MediaMuxer实战:视频加片头片尾(Intro/Outro)——为视频添加开场和结束片段

各位好,欢迎来到第25章。今天咱们聊点实战的东西——给视频加片头片尾。

说实话,这个需求太常见了。你拍了个Vlog,想加个酷炫的开场动画;或者做了个产品演示,结尾要放个Logo和联系方式。这些场景,说白了就是要把多个视频片段拼成一个完整的视频。

用MediaMuxer来做这件事,其实比你想的要简单。但坑也不少,我当年第一次做的时候,就栽在时间戳上……嗯,咱们慢慢聊。

核心思路:先拆后合

视频加片头片尾,本质上是一个「拼接」操作。但MediaMuxer不支持直接拼接,所以我们要换个思路:

  • 第一步:用MediaExtractor分别读取片头、主视频、片尾的轨道数据
  • 第二步:用MediaMuxer创建一个新的输出文件
  • 第三步:按顺序写入片头→主视频→片尾的媒体数据

听起来简单?关键在时间戳的调整上。每个片段都有自己的时间轴,合并时必须重新计算。

核心公式:每个片段的写入时间戳 = 该片段原始时间戳 + 前面所有片段的总时长

流程图:三片段合并的完整流程

MediaMuxer 视频拼接流程(片头 + 主视频 + 片尾) 片头(Intro) 时长:T1 主视频(Main) 时长:T2 片尾(Outro) 时长:T3 MediaExtractor MediaExtractor MediaExtractor 时间戳偏移:0 时间戳偏移:T1 时间戳偏移:T1+T2 MediaMuxer 输出 完整视频(时长:T1+T2+T3)

代码实现:一步一步来

好,咱们直接上代码。我习惯把逻辑封装成一个工具类,这样复用起来方便。

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层。这样用户就知道没卡死,心里踏实。

写在最后

视频拼接这个功能,说难不难,说简单也不简单。核心就是时间戳的计算和轨道格式的统一。你只要把这两个点抓住了,剩下的就是体力活。

我个人建议,在实际项目中,最好把片头片尾做成固定模板,用统一的编码参数生成。这样不仅拼接简单,而且用户体验也一致——片头动画的色调、节奏都能把控好。

好了,这一章就到这里。代码你可以直接拿去用,但记得根据自己的场景调整。有问题欢迎交流。


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