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里的音频轨道和视频轨道拆成独立的文件。为什么要做这个?我举个例子:你想给视频换个背景音乐,就得先把原视频的音频分离出来,替换掉,再合成回去。
分离的核心思路:
- 解析moov box,找到所有的track box
- 根据track的handler_type区分音频轨道和视频轨道
- 读取mdat中的数据,按sample为单位提取
- 分别封装成新的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。
合成的关键步骤:
- 创建MediaMuxer实例,指定输出格式为MP4
- 分别添加音频轨道和视频轨道
- 从音频文件和视频文件中读取数据,写入到Muxer
- 注意保持时间戳的同步
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文件处理的内容就讲到这里。这些操作在实际项目中非常常见,特别是做视频编辑、直播录制、短视频应用的时候。你只要把Box结构搞清楚了,剩下的就是数据搬运的工作。