23、提取特定时间段:seekTo()的精确控制与边界处理
做音视频开发,绕不开一个场景:用户只想截取视频中的某一段。比如从第10秒到第20秒,或者从某个关键帧开始往后读10帧。这时候,seekTo()就是你的核心武器。
但说实话,seekTo()没那么简单。我早期做短视频剪辑工具时,就踩过它的坑。用户明明选了第10秒作为起点,结果提取出来的画面却跳到了第12秒。嗯,这就是seek精度的问题。今天我们就把它彻底讲透。
seekTo()的基本用法
MediaExtractor的seekTo()方法,作用是把读取位置移动到指定的时间点。它的签名很简单:
public void seekTo(long timeUs, int mode)
两个参数:
- timeUs:目标时间,单位是微秒。1秒 = 1_000_000微秒。
- mode:搜索模式,决定了seek的精确度。
mode有3个可选值:
| 模式常量 | 值 | 行为说明 |
|---|---|---|
| SEEK_TO_PREVIOUS_SYNC | 0 | 跳到目标时间之前最近的关键帧 |
| SEEK_TO_NEXT_SYNC | 1 | 跳到目标时间之后最近的关键帧 |
| SEEK_TO_CLOSEST_SYNC | 2 | 跳到离目标时间最近的关键帧 |
核心要点:seekTo()只能跳到关键帧(I帧),不能跳到任意帧。这是由视频编码格式决定的。你想想看,非关键帧依赖前面的帧才能解码,如果直接跳到非关键帧,解码器根本不知道画面是什么。
三种模式的实际表现
假设视频的关键帧分布在:0s、5s、10s、15s、20s。你想seek到第12秒:
- SEEK_TO_PREVIOUS_SYNC → 跳到10s
- SEEK_TO_NEXT_SYNC → 跳到15s
- SEEK_TO_CLOSEST_SYNC → 跳到10s(因为10s离12s更近)
我个人习惯用SEEK_TO_CLOSEST_SYNC。为什么?因为它最符合直觉——用户选了一个时间点,你给他最近的关键帧,误差最小。
小技巧:如果你需要精确到帧级别,可以seek到目标时间之前的关键帧,然后逐帧读取直到到达目标时间。代价是性能开销大一些。
边界处理:开头和结尾
边界情况往往是最容易出bug的地方。我曾在项目中遇到过用户反馈:明明选了第0秒作为起点,结果提取出来的视频开头有几帧黑屏。原因就是seek到了第0秒之前的关键帧——不存在,所以返回了无效数据。
处理边界,记住三条原则:
- seek到0:直接调用
seekTo(0, SEEK_TO_CLOSEST_SYNC),系统会自动定位到第一个关键帧。 - seek到文件末尾附近:如果目标时间接近或超过时长,
readSampleData()会返回false。记得检查返回值。 - seek到负值:不要传负数。虽然不会崩溃,但行为未定义。我建议在调用前做一次
Math.max(0, timeUs)。
实战代码:提取指定时间段
下面是一个完整的示例,从视频中提取第10秒到第20秒的音频数据:
public void extractSegment(String inputPath, String outputPath,
long startSec, long endSec) throws IOException {
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(inputPath);
// 选择音频轨道
int audioTrackIndex = selectAudioTrack(extractor);
if (audioTrackIndex < 0) {
throw new IllegalStateException("没有找到音频轨道");
}
extractor.selectTrack(audioTrackIndex);
// 获取轨道格式信息
MediaFormat format = extractor.getTrackFormat(audioTrackIndex);
String mime = format.getString(MediaFormat.KEY_MIME);
// 创建Muxer用于写入输出文件
MediaMuxer muxer = new MediaMuxer(outputPath,
MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int outputTrackIndex = muxer.addTrack(format);
muxer.start();
// seek到起始位置
long startUs = startSec * 1_000_000L;
long endUs = endSec * 1_000_000L;
extractor.seekTo(startUs, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
ByteBuffer buffer = ByteBuffer.allocate(512 * 1024); // 512KB缓冲区
int sampleCount = 0;
while (true) {
int sampleSize = extractor.readSampleData(buffer, 0);
if (sampleSize < 0) {
break; // 没有更多数据
}
long sampleTime = extractor.getSampleTime();
if (sampleTime > endUs) {
break; // 超出结束时间
}
// 跳过起始时间之前的数据(因为seek可能跳到前面的关键帧)
if (sampleTime < startUs) {
extractor.advance();
continue;
}
// 写入数据
bufferInfo.set(0, sampleSize, sampleTime,
extractor.getSampleFlags());
muxer.writeSampleData(outputTrackIndex, buffer, bufferInfo);
sampleCount++;
extractor.advance();
}
muxer.stop();
muxer.release();
extractor.release();
Log.d("ExtractSegment", "共提取 " + sampleCount + " 个样本");
}
private int selectAudioTrack(MediaExtractor extractor) {
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("audio/")) {
return i;
}
}
return -1;
}
注意:上面的代码中,我用了sampleTime < startUs来跳过起始时间之前的数据。这是因为seekTo()可能跳到目标时间之前的关键帧,那些帧的时间戳小于startUs,需要丢弃。我曾经漏掉这一步,结果提取出来的音频开头多了一段不需要的内容。
seekTo()的精度问题
为什么seekTo()不能精确到任意帧?说白了,这是由视频编码的本质决定的。
视频编码使用帧间压缩。I帧(关键帧)是完整的画面,P帧和B帧只记录变化。如果你跳到P帧,解码器没有前面的I帧作为参考,根本解不出来。所以seekTo()只能落在I帧上。
那音频呢?音频通常没有关键帧的概念。大多数音频编码(如AAC)的每一帧都可以独立解码。所以音频的seek精度通常比视频高得多。但要注意,seekTo()对音频轨道的行为也是跳到最近的音频帧起始位置,这个位置可能和你的目标时间有几毫秒的偏差。
SVG流程图:seekTo()的决策逻辑
常见问题与避坑指南
问题1:seek后读取到的第一帧时间戳小于目标时间
这是正常现象。因为seekTo()跳到了目标时间之前的关键帧。解决方案就是我在代码里写的:判断sampleTime < startUs时跳过。
问题2:seek到接近文件末尾时,readSampleData返回-1
我曾经在这个问题上debug了半天。后来发现,如果目标时间超过了视频时长,seekTo()不会报错,但后续读取会立即返回-1。所以最好在seek之前先获取视频时长:
long durationUs = format.getLong(MediaFormat.KEY_DURATION);
if (targetUs > durationUs) {
targetUs = durationUs;
}
问题3:多轨道seek的同步问题
如果你同时提取视频和音频,需要分别对每个轨道调用seekTo()。但要注意,视频和音频的关键帧位置不同,seek后两个轨道的时间戳可能不一致。我建议的做法是:先seek视频轨道,然后根据视频第一帧的时间戳,再去seek音频轨道到相同的时间点。
个人经验:在提取短视频片段时(比如10秒以内),我习惯把缓冲区设大一点,比如1MB。这样可以减少循环次数,提升性能。但如果是提取长片段,512KB就够用了,避免内存浪费。
总结
seekTo()是MediaExtractor的核心操作之一。理解它的三种模式,处理好边界情况,你的音视频提取功能就能稳定运行。记住:
- 永远检查
readSampleData()的返回值 - 永远过滤掉目标时间之前的数据
- 永远在seek前验证目标时间是否在有效范围内
做到这三点,大部分坑你都能避开。
公众号:蓝海资料掘金营,微信deep3321