27、音视频文件处理:MP4文件解析、音视频分离与合成、转码与裁剪

各位同学,今天我们来聊聊音视频文件处理。说实话,这部分内容在实际开发中碰到的频率非常高。我记得刚入行那会儿,接手一个视频编辑SDK的项目,天天跟MP4文件打交道。那时候对MP4的box结构理解不深,踩了不少坑。今天我把这些经验整理出来,希望能帮你少走弯路。

MP4文件结构:从盒子说起

MP4文件,说白了就是一个盒子套盒子的结构。每个盒子叫"Box"或者"Atom"。你想想看,就像俄罗斯套娃一样,大盒子里面装小盒子,小盒子里面还能再装更小的盒子。

一个典型的MP4文件包含这些关键Box:

Box类型 作用 是否必须
ftyp 文件类型标识,告诉播放器这是MP4
moov 元数据信息,包含时长、分辨率、轨道信息等
mdat 实际的音视频数据
moof 分片信息,用于流式传输

这里有个关键点:moov box的位置决定了文件的启动速度。如果moov在文件末尾,播放器必须下载完整个文件才能开始播放。我在项目中遇到过这种情况,用户反馈视频加载太慢,后来发现就是moov位置的问题。

核心要点:解析MP4文件,本质上就是遍历这些Box,读取每个Box的type和size,然后根据type做不同的处理。

MP4解析实战:手写一个简易解析器

嗯,这里我直接上代码。解析MP4的第一步,就是读取Box头信息:

public class MP4Parser {
    
    public static class Box {
        public long size;      // Box大小
        public String type;    // Box类型,如"ftyp", "moov"
        public long dataOffset; // 数据起始位置
        
        public Box(long size, String type, long dataOffset) {
            this.size = size;
            this.type = type;
            this.dataOffset = dataOffset;
        }
    }
    
    public static Box readBox(InputStream is) throws IOException {
        DataInputStream dis = new DataInputStream(is);
        
        // 读取4字节的size
        int sizeInt = dis.readInt();
        // 读取4字节的type
        byte[] typeBytes = new byte[4];
        dis.readFully(typeBytes);
        String type = new String(typeBytes, "ISO-8859-1");
        
        long size = sizeInt & 0xFFFFFFFFL;
        long dataOffset = 8; // 默认Box头占8字节
        
        // 如果size为0,表示这个Box延伸到文件末尾
        if (sizeInt == 0) {
            size = Long.MAX_VALUE;
        }
        // 如果size为1,表示使用64位扩展size
        else if (sizeInt == 1) {
            size = dis.readLong();
            dataOffset = 16;
        }
        
        return new Box(size, type, dataOffset);
    }
}

这段代码看起来简单,但有个坑:size字段可能是0或1。size为0表示这个Box一直延伸到文件末尾,通常出现在最后一个Box。size为1表示后面还有8字节的扩展size。我曾经在这个地方吃过亏,解析出来的size不对,导致整个文件解析失败。

音视频分离:把音频和视频拆开

音视频分离,说白了就是把MP4里的音频轨道和视频轨道拆成独立的文件。为什么要做这个?我举个例子:你想给视频换个背景音乐,就得先把原视频的音频分离出来,替换掉,再合成回去。

分离的核心思路:

  1. 解析moov box,找到所有的track box
  2. 根据track的handler_type区分音频轨道和视频轨道
  3. 读取mdat中的数据,按sample为单位提取
  4. 分别封装成新的MP4文件

用MediaCodec实现分离,代码大致是这样:

public void demuxMp4(String inputPath, String videoOutput, String audioOutput) {
    try {
        MediaExtractor extractor = new MediaExtractor();
        extractor.setDataSource(inputPath);
        
        int videoTrackIndex = -1;
        int audioTrackIndex = -1;
        
        // 遍历所有轨道,找到音视频轨道
        for (int i = 0; i < extractor.getTrackCount(); i++) {
            MediaFormat format = extractor.getTrackFormat(i);
            String mime = format.getString(MediaFormat.KEY_MIME);
            
            if (mime.startsWith("video/")) {
                videoTrackIndex = i;
            } else if (mime.startsWith("audio/")) {
                audioTrackIndex = i;
            }
        }
        
        // 分别提取视频和音频
        if (videoTrackIndex >= 0) {
            extractor.selectTrack(videoTrackIndex);
            writeToFile(extractor, videoOutput);
            extractor.unselectTrack(videoTrackIndex);
        }
        
        if (audioTrackIndex >= 0) {
            extractor.selectTrack(audioTrackIndex);
            writeToFile(extractor, audioOutput);
            extractor.unselectTrack(audioTrackIndex);
        }
        
        extractor.release();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

小技巧:分离时要注意时间戳的连续性。有些视频的PTS(显示时间戳)不是从0开始的,分离后需要做归一化处理,否则合成时会出现音画不同步。

音视频合成:把音频和视频合在一起

合成是分离的逆操作。说白了,就是把独立的音频文件和视频文件合并成一个MP4。我习惯用MediaMuxer来做这件事,它是Android官方提供的合成API。

合成的关键步骤:

  1. 创建MediaMuxer实例,指定输出格式为MP4
  2. 分别添加音频轨道和视频轨道
  3. 从音频文件和视频文件中读取数据,写入到Muxer
  4. 注意保持时间戳的同步
public void muxMp4(String videoFile, String audioFile, String outputFile) {
    try {
        MediaExtractor videoExtractor = new MediaExtractor();
        videoExtractor.setDataSource(videoFile);
        
        MediaExtractor audioExtractor = new MediaExtractor();
        audioExtractor.setDataSource(audioFile);
        
        MediaMuxer muxer = new MediaMuxer(outputFile, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
        
        // 添加视频轨道
        MediaFormat videoFormat = videoExtractor.getTrackFormat(0);
        int videoTrack = muxer.addTrack(videoFormat);
        
        // 添加音频轨道
        MediaFormat audioFormat = audioExtractor.getTrackFormat(0);
        int audioTrack = muxer.addTrack(audioFormat);
        
        muxer.start();
        
        // 写入视频数据
        videoExtractor.selectTrack(0);
        writeTrackToMuxer(videoExtractor, muxer, videoTrack);
        
        // 写入音频数据
        audioExtractor.selectTrack(0);
        writeTrackToMuxer(audioExtractor, muxer, audioTrack);
        
        muxer.stop();
        muxer.release();
        videoExtractor.release();
        audioExtractor.release();
        
    } catch (IOException e) {
        e.printStackTrace();
    }
}

注意:MediaMuxer要求所有轨道添加完成后才能调用start()。而且写入数据时,必须保证每个sample的flags设置正确,特别是关键帧的BUFFER_FLAG_SYNC_FRAME标志。否则合成出来的视频可能无法正常播放。

转码:改变编码格式

转码,就是把视频从一种编码格式转换成另一种。比如把H.264转成H.265,或者把AAC转成MP3。为什么要转码?说白了就是为了兼容性或者压缩率。

转码的流程:

  • 解码阶段:用MediaCodec将原始数据解码成原始YUV或PCM数据
  • 处理阶段:可以对原始数据进行缩放、滤镜等处理
  • 编码阶段:用另一个MediaCodec将原始数据重新编码成目标格式

这里有个性能问题:转码是非常耗时的操作。我记得有一次给客户做视频压缩,一个10分钟的1080p视频转成720p,在低端手机上跑了将近20分钟。后来我优化了编码参数,把码率控制从CBR改成VBR,速度提升了不少。

裁剪:截取视频片段

裁剪,说白了就是只保留视频的某一段。实现方式有两种:

方式 原理 优点 缺点
关键帧裁剪 只复制关键帧之间的数据 速度快,不需要重新编码 精度低,只能到关键帧边界
重新编码裁剪 解码后重新编码 精度高,可以精确到帧 速度慢,画质有损失

我个人的建议是:如果对精度要求不高,用关键帧裁剪。速度快,而且没有画质损失。但如果需要精确到毫秒级别,那就只能重新编码了。

避坑指南:我曾经做过一个裁剪功能,用户反馈裁剪出来的视频时长不对。后来发现是时间戳计算的问题。裁剪时,起始时间戳要从0开始重新计算,否则播放器会按照原始时间戳来播放,导致时长显示错误。

知识体系总览

下面这张图总结了本章的核心内容,你可以对照着梳理一下思路:

MP4文件处理 MP4解析 音视频分离 音视频合成 转码与裁剪 读取Box结构 解析moov 提取轨道信息 识别轨道类型 提取Sample数据 封装独立文件 添加轨道 写入Sample数据 同步时间戳 解码→处理→编码 关键帧/重编码 核心:理解Box结构 → 操作轨道数据 → 控制时间戳

好了,关于MP4文件处理的内容就讲到这里。这些操作在实际项目中非常常见,特别是做视频编辑、直播录制、短视频应用的时候。你只要把Box结构搞清楚了,剩下的就是数据搬运的工作。


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