• 基于 FFmpeg 的跨平台视频播放器简明教程(九):Seek 策略


    系列文章目录

    1. 基于 FFmpeg 的跨平台视频播放器简明教程(一):FFMPEG + Conan 环境集成
    2. 基于 FFmpeg 的跨平台视频播放器简明教程(二):基础知识和解封装(demux)
    3. 基于 FFmpeg 的跨平台视频播放器简明教程(三):视频解码
    4. 基于 FFmpeg 的跨平台视频播放器简明教程(四):像素格式与格式转换
    5. 基于 FFmpeg 的跨平台视频播放器简明教程(五):使用 SDL 播放视频
    6. 基于 FFmpeg 的跨平台视频播放器简明教程(六):使用 SDL 播放音频和视频
    7. 基于 FFmpeg 的跨平台视频播放器简明教程(七):使用多线程解码视频和音频
    8. 基于 FFmpeg 的跨平台视频播放器简明教程(八):音画同步


    前言

    经过前面八章的学习与代码实现,我们的播放器已经能够正常播放视频了,接下来我们将加入最常用的 seek 能力,让你能够快进/快退。

    本文参考文章来自 An ffmpeg and SDL Tutorial -Tutorial 07: Seeking。这个系列对新手较为友好,但 2015 后就不再更新了,以至于文章中的 ffmpeg api 已经被弃用了。幸运的是,有人对该教程的代码进行重写,使用了较新的 api,你可以在 rambodrahmani/ffmpeg-video-player 找到这些代码。

    本文的代码在 ffmpeg_video_player_tutorial-my_tutorial07.cppffmpeg_video_player_tutorial-my_tutorial07_01_accurate_seek.cpp

    FFmpeg API 中的 Seek 方法

    我们想做的事情很简单:当用户按下左右键时,播放进度快进 5s 或者回退 5s。例如当前播放进度为 1s,当按下右键时,期望播放进度能够跳跃到 6s。

    FFmpeg 提供了 seek 的接口,但它没法精确的跳跃到我们期望的位置,它有一些限制。关于精准 seek 我们稍后会进行讨论,目前让我们将目光关注到 FFmpeg 提供的 seek api 上。

    avformat_seek_file

    FFmpeg 提供了 avformat_seek_file 进行 seek,函数原型:

    int avformat_seek_file(AVFormatContext *s, int stream_index, int64_t min_ts, int64_t ts, int64_t max_ts, int flags);
    
    • 1

    函数的参数和返回值说明如下:

    • AVFormatContext *s: 媒体文件句柄。它是函数的主要输入,指向处理的媒体文件的上下文。
    • int stream_index: 用作时间基准参考的流的索引。如果流索引为-1,则所有的时间戳都以AV_TIME_BASE单位处理。如果此标志包含AVSEEK_FLAG_FRAME,则流索引中的所有时间戳都以帧为单位。
    • int64_t min_ts: 可接受的最小时间戳。此时间戳定义了ts可能到达的范围下限。
    • int64_t ts: 目标时间戳。这是函数尝试寻找并尽可能接近的时间戳。
    • int64_t max_ts: 可接受的最大时间戳。这个时间戳定义了ts可能到达的范围上限。
    • int flags: 表示寻址标志,包含 AVSEEK_FLAG_BYTE、AVSEEK_FLAG_FRAME、AVSEEK_FLAG_ANY、AVSEEK_FLAG_BACKWARD 四个选项,用于指定时间戳的单位、是否将非关键帧视为关键帧,等等。

    首先明确一点,seek 到目标时间 ts 上,这个 ts 的时间单位是什么?在 avformat_seek_file 注释中对 ts 的时间单位做了说明,总结下来:

    1. 如果“flags”包含 AVSEEK_FLAG_BYTE,那么所有时间戳都是以字节为单位的,它们代表的是文件位置(不过这种方式可能不会被所有的demuxers所支持)
    2. 如果“flags”包含 AVSEEK_FLAG_FRAME,那么所有的时间戳都是以帧为单位的,这些帧在由stream_index 参数指定的那个流中(这种方式同样可能不会被所有的demuxers所支持)
    3. 如果 stream_index 为 -1,则以 AV_TIME_BASE 为单位
    4. 否则,所有时间戳都以选定的流单元中的时间基为单位表示

    在我们的代码实现中并不会使用到 AVSEEK_FLAG_BYTE 或者 AVSEEK_FLAG_FRAME,因此忽略上面的 1、2 点。将 stream_index 设置为 -1 是一种常见的选择,使得 ts 时间单位为 AV_TIME_BASE(也就是 1us)。

    注意到 avformat_seek_file 作用的对象是 AVFormatContext,这也就说它影响的是解封装的结果:经过 seek 之后,av_read_frame 将读取到新位置的 AVPacket。

    Seek 到关键帧

    虽然在 “flags” 中包含 AVSEEK_FLAG_BYTE 或者 AVSEEK_FLAG_FRAME 使得可以 seek 到任意位置(帧)上,但在实际使用上这两个 flag 很少被使用。我们最常用的还是 flags = 0 或者 flags = AVSEEK_FLAG_BACKWARD,这种情况下 avformat_seek_file 将会跳转至符合要求的关键帧(I帧)位置上。

    在跳转时,如果指定了 AVSEEK_FLAG_BACKWARD 标志,则会优先跳转到前一个关键帧,否则会优先跳转到后一个关键帧。如果没有关键帧,则会跳转到最接近的非关键帧。但是,跳转到非关键帧可能会导致解码器出现错误或画面不完整的情况,因此在实际应用中,一般会尽量跳转到关键帧。

    由于avformat_seek_file的特性,它会跳转到最接近指定时间戳的关键帧,因此在实际应用中,你可能会发现跳转的位置并不完全符合预期。例如,如果当前播放位置在1秒,你希望快进到6秒,但是在执行avformat_seek_file后,播放位置可能会跳转到8秒。这是因为在8秒处有一个关键帧,而在预期的6秒处没有关键帧。所以,avformat_seek_file会选择最近的关键帧进行跳转。「精准 seek」中将说明如何处理这种情况,此处不表。

    GOP

    两个关键帧的距离,有一个专业的名词来描述,即 GOP。GOP,全称为Group of Pictures,中文译为“图像组”,是视频编码中的一个重要概念。

    在视频编码中,为了提高压缩效率,通常会采用帧间预测的方式,即利用前后帧之间的相关性,只编码和前后帧的差异部分。这样可以大大减少需要编码的数据量,从而提高压缩效率。而GOP就是帧间预测的基本单位。

    一个GOP由一个I帧开始,后面跟随若干个P帧和B帧。I帧是关键帧,可以独立解码,而P帧和B帧则需要依赖其他帧进行解码。GOP的长度,即一个GOP中包含的帧数,是可以调整的,它直接影响到视频的压缩效率和错误恢复能力。GOP长度越短,错误恢复能力越强,但压缩效率较低;反之,GOP长度越长,压缩效率越高,但错误恢复能力较弱。

    此外 GOP 还与视频画面质量有关,详细说明请参考:

    Show me the Code

    讲解往 ffmpeg 中关于 seek 的 api 后,现在来说明如何在代码中实现 seek 的逻辑。大体步骤为:

    1. 通过键盘发出 seek 命令,例如按下左右键等
    2. Demux 线程收到 seek 的请求后,调用 avformat_seek_file 进行 seek 操作;Demux 完成 seek 后,还需要通知视频/音频解码线程发生了 seek 操作
    3. 解码线程收到 seek 通知后,清理解码器上下文中的缓存信息

    接下来对上述步骤做详细的说明

    发出 seek 命令

    void onEvent(const SDL_Event &event) {
        switch (event.type) {
        case SDL_KEYDOWN: {
          switch (event.key.keysym.sym) {
          case SDLK_LEFT: {
            play_ctx->doSeekRelative(-5.0);break;
          }
          case SDLK_RIGHT: {
            play_ctx->doSeekRelative(5.0);break;
          }
          case SDLK_DOWN: {
            play_ctx->doSeekRelative(-60.0);break;
          }
          case SDLK_UP: {
            play_ctx->doSeekRelative(60.0);break;
          }
          }
          break;
        }
    	// ....
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    SDL 支持键盘事件,当我们按了某些按键时,通过 SDL_Event 中的信息来判断所按的键是哪个。在上述代码中,支持左右上下键来快进和快退。

    void doSeekRelative(double incr) {
        if (!seek_req) {
          std::lock_guard lg(seek_mut);
          auto pos = getAudioClock();
          pos += incr;
          if (pos < 0) {
            pos = 0;
          }
    
          seek_rel = (int64_t)(incr * AV_TIME_BASE);
          seek_pos = (int64_t)(pos * AV_TIME_BASE);
          seek_flags = (incr < 0) ? AVSEEK_FLAG_BACKWARD : 0;
          seek_req = true;
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    doSeekRelative函数中:

    • getAudioClock 获取当前的音频时钟,即播放到第几秒了。
    • pos += incr; 计算快进快退的目标位置
    • 接着计算 seek_relseek_pos,这里将时间单位转换到 AV_TIME_BASE 是为了后面处理更方面;seek_flags 如果是快退的话设置为 AVSEEK_FLAG_BACKWARD
    • 最后设置 seek_req = true 等待 demux 线程消费

    Demux 线程进行 seek 操作

    if (ctx.seek_req) {
      // seek stuff goes here
      int64_t seek_pos = 0;
      int64_t seek_rel = 0;
      int seek_flags = 0;
      {
        std::lock_guard lg(ctx.seek_mut);
        seek_pos = ctx.seek_pos;
        seek_rel = ctx.seek_rel;
        seek_flags = ctx.seek_flags;
      }
      auto min_ts = (seek_rel > 0) ? (seek_pos - seek_rel + 2) : (INT64_MIN);
      auto max_ts = (seek_rel < 0) ? (seek_pos - seek_rel - 2) : (INT64_MAX);
      ret = avformat_seek_file(ctx.decode_ctx->demuxer.getFormatContext(), -1,
                               min_ts, seek_pos, max_ts, seek_flags);
      if (ret < 0) {
        fprintf(stderr, "%s: error while seeking %s\n",
                decode_ctx.demuxer.getFormatContext()->url, av_err2str(ret));
      } else {
        if (ctx.decode_ctx->video_stream_index >= 0) {
          decode_ctx.video_packet_sync_que.clear();
          decode_ctx.video_packet_sync_que.tryPush(&flush_packet);
        }
        if (ctx.decode_ctx->audio_stream_index >= 0) {
          decode_ctx.audio_packet_sync_que.clear();
          decode_ctx.audio_packet_sync_que.tryPush(&flush_packet);
        }
      }
      ctx.setClock(ctx.audio_clock_t, seek_pos / (double)AV_TIME_BASE);
      ctx.seek_req = false;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    上述代码在 demux 线程中,它展示了处理 seek 命令的逻辑操作。

    • 在收到 seek 请求后,将 seek_posseek_relseek_flags 保存起来(为了线程安全)
    • 接着调用 avformat_seek_file 接口,至于 min_tsmax_ts 为啥这样算?是直接抄的 ffplay 中代码(哈哈哈哈)。注意 avformat_seek_file 第二个参数是 -1,这也意味着所有输入的时间戳单位是 AV_TIME_BASE 为单位的。
    • 接着,清空视频和音频的 packet queue,并向它们都发送了一个 flush packet,通过这个 flush packet 去通知解码线程发生了 seek 操作。

    解码线程进行 seek 操作

    // seek stuff here
    if (std::strcmp((char *)pkt->data, FLUSH_DATA) == 0) {
      avcodec_flush_buffers(codec.getCodecContext());
      out_frame_queue.clear();
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在解码线程中,要做的事情非常简单:

    1. 判断当前 packet 是否是 flush packet
    2. 如果是,那么调用 avcodec_flush_buffers 清理解码器上下文缓存,且清空 out_frame_queue 里的数据

    精准 Seek

    回到之前的那个问题:avformat_seek_file 只能跳转到关键帧,在实际应用中,你可能会发现跳转的位置并不完全符合预期。例如,如果当前播放位置在1秒,你希望快进到6秒,但是在执行avformat_seek_file后,播放位置可能会跳转到8秒。这是因为在8秒处有一个关键帧,而在预期的6秒处没有关键帧。

    如何解决这个问题?要做两件事情:

    1. 调用 avformat_seek_file 时,确保跳转到目标位置(target position)的前面的关键帧。例如 target_pos = 2s 时,跳转到关键帧应该满足 pos <= 2s。调用 avformat_seek_file 时,将 flag 设置为 AVSEEK_FLAG_BACKWARD 即可,如果没有设置这个标志,那么找到的关键帧可能会超过目标时间戳。
    2. 从关键帧开始解码,直到当前位置(current position)大于目标位置。满足该条件后,意味着我们 seek 到了目标位置,可以进行该视频帧的播放。

    接下来让我们看具体实现的代码 ffmpeg_video_player_tutorial-my_tutorial07_01_accurate_seek.cpp

    Show Me The Code

    使用 SDL 处理键盘事件的代码与之前是一样的,此处不再赘述。直接看 demux 线程与解码线程的修改。

    Demux Thread:

    if (ctx.seek_req) {
      // seek stuff goes here
      int64_t seek_pos = 0;
      int64_t seek_rel = 0;
      int seek_flags = 0;
      {
        std::lock_guard lg(ctx.seek_mut);
        seek_pos = ctx.seek_pos;
        seek_rel = ctx.seek_rel;
        seek_flags = ctx.seek_flags;
        seek_flags = AVSEEK_FLAG_BACKWARD;
      }
      auto min_ts = (seek_rel > 0) ? (seek_pos - seek_rel + 2) : (INT64_MIN);
      auto max_ts = (seek_rel < 0) ? (seek_pos - seek_rel - 2) : (seek_pos);
      ret = avformat_seek_file(ctx.decode_ctx->demuxer.getFormatContext(), -1,
                               min_ts, seek_pos, max_ts, seek_flags);
      if (ret < 0) {
        fprintf(stderr, "%s: error while seeking %s\n",
                decode_ctx.demuxer.getFormatContext()->url, av_err2str(ret));
      } else {
        seek_packet.pos = seek_pos;
        // ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    Demux 线程代码与之前有两个差异点:

    1. seek_flags 被设置为 AVSEEK_FLAG_BACKWARD
    2. 设置 seek_packet 的 pos 为目标位置,告诉解码线程目标位置的值

    Decode Thread:

    // seek stuff here
    if (pkt->stream_index == FF_SEEK_PACKET_INDEX) {
     avcodec_flush_buffers(codec.getCodecContext());
     out_frame_queue.clear();
     seeking_flag = true;
     target_seek_pos_avtimebase = pkt->pos;
     return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    解码线程收到 seek packet 后,首先做了两件事情:

    1. 清理缓存。调用 avcodec_flush_buffers 清理解码器上下文缓存;清理 out_frame_queue 中的缓存帧
    2. 保存 seek 信息,以便后续的循环解码
    while (ret >= 0) {
      ret = codec.receiveFrame(out_frame);
      ON_SCOPE_EXIT([&out_frame] { av_frame_unref(out_frame); });
      // need more packet
      if (ret == AVERROR(EAGAIN)) {
        break;
      } else if (ret == AVERROR_EOF || ret == AVERROR(EINVAL)) {
        // EOF exit loop
        break;
      } else if (ret < 0) {
        printf("Error while decoding.\n");
        return -1;
      }
      if (seeking_flag) {
        auto cur_frame_pts_avtimebase =
            av_rescale_q(out_frame->pts, stream_time_base, AV_TIME_BASE_Q);
        if (cur_frame_pts_avtimebase < target_seek_pos_avtimebase) {
          break;
        } else {
          seeking_flag = false;
        }
      }
      out_frame_queue.waitAndPush(out_frame);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    在原有的解码逻辑中,我们加入了对 seek 的判断:

    1. 如果命中 seeking_flag,判断当前解码帧的 pts 是否小于目标位置。如果是,以为还没有解码到目标帧,继续解码;如果否,那么当前帧已经满足目标位置。
    2. 满足条件后,将 seeking_flag 设置为 false,将解码的视频帧放入 queue 中,让 sdl 去播放。

    Seek 性能优化

    实现精准 seek 后,可以发现在某些情况下需要解码很多很多帧,才能到达 seek 的目标位置。举个例子,假设 GOP=100,目标位置在 GOP 的最后一帧,调用 avformat_seek_file 后,seek 到了当前 GOP 的第一帧,于是需要解码 100 帧。有啥优化的办法吗?这里大致描述下,具体代码留给各位自行实现了。

    解码前丢弃非参考帧。

    AVPacket 的 flags 标志位中可以知道当前的 packet 是否可以被解码器丢弃。以下是对上述标志位的解释:

    • AV_PKT_FLAG_KEY:这个标志表示该数据包包含一个关键帧。在视频编码中,关键帧是完整的帧,可以独立于其他帧进行解码。
    • AV_PKT_FLAG_CORRUPT:这个标志表示该数据包的内容已经损坏。这可能是由于数据传输错误或者编码错误导致的。
    • AV_PKT_FLAG_DISCARD:这个标志表示这个数据包在解码后可以被丢弃。这些数据包对于维持解码器的状态是必要的,但是对于输出来说并不需要。
    • AV_PKT_FLAG_TRUSTED:这个标志表示这个数据包来自一个可信的源。这意味着即使数据包中包含一些不安全的结构,如指向数据包外部数据的任意指针,也可以被接受。
    • AV_PKT_FLAG_DISPOSABLE:这个标志表示这个数据包包含的帧可以被解码器丢弃,也就是说这些帧不是参考帧。在视频编码中,参考帧是其他帧在预测编码时需要参考的帧,而非参考帧则不需要被其他帧参考。

    或者使用 AVDiscard 来丢弃某些帧,具体参考 百倍变速–解码到底能不能丢 非参考帧 ?FFmpeg 有话说!!!

    GOP 内向后 seek 逻辑优化

    如果 seek 的目标位置与当前位置属于同一个 GOP,且为向后 seek,那么可以优化现有 seek 逻辑,无需做其他操作只需等待解码器解码到目标位置即可。

    总结

    本文介绍了播放器中如何实现快进、快退功能,并给出了具体的实现代码,还讨论了如何实现精准 seek 逻辑,并在最后给出了一些优化的思路。本文的代码在 ffmpeg_video_player_tutorial-my_tutorial07.cppffmpeg_video_player_tutorial-my_tutorial07_01_accurate_seek.cpp

    参考

  • 相关阅读:
    TRC天然拟胆碱生物碱丨艾美捷TRC盐酸乙环胺
    excel表格损坏修复要注意事项
    十 动手学深度学习v2 ——卷积神经网络之NiN + GoogLeNet
    12.MYSQL基础-常见函数
    安装shap-e(openai开源的3D模型生成框架)踩过的一些坑
    数字IC设计笔试常见大题整理(简答+手撕)
    hitTest的基本用法
    SQL注入初了解
    MySQL集群高可用架构之MHA
    洞悉微服务构建流程,以实战角度出发,详解微服务架构
  • 原文地址:https://blog.csdn.net/weiwei9363/article/details/132307253