23、提取特定时间段:seekTo()的精确控制与边界处理

做音视频开发,绕不开一个场景:用户只想截取视频中的某一段。比如从第10秒到第20秒,或者从某个关键帧开始往后读10帧。这时候,seekTo()就是你的核心武器。

但说实话,seekTo()没那么简单。我早期做短视频剪辑工具时,就踩过它的坑。用户明明选了第10秒作为起点,结果提取出来的画面却跳到了第12秒。嗯,这就是seek精度的问题。今天我们就把它彻底讲透。

seekTo()的基本用法

MediaExtractorseekTo()方法,作用是把读取位置移动到指定的时间点。它的签名很简单:

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秒之前的关键帧——不存在,所以返回了无效数据。

处理边界,记住三条原则:

  1. seek到0:直接调用seekTo(0, SEEK_TO_CLOSEST_SYNC),系统会自动定位到第一个关键帧。
  2. seek到文件末尾附近:如果目标时间接近或超过时长,readSampleData()会返回false。记得检查返回值。
  3. 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()的决策逻辑

seekTo() 决策流程 调用 seekTo(timeUs, mode) 选择搜索模式 SEEK_TO_PREVIOUS_SYNC SEEK_TO_CLOSEST_SYNC SEEK_TO_NEXT_SYNC 跳到之前最近的关键帧 跳到最近的关键帧 跳到之后最近的关键帧 注意:所有模式都只能定位到关键帧(I帧),无法精确到任意帧

常见问题与避坑指南

问题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