基于 FFmpeg 的跨平台视频播放器简明教程(七):使用多线程解码视频和音频

系列文章目录

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

文章目录


前言

在上篇文章中 基于 FFmpeg 的跨平台视频播放器简明教程(六):使用 SDL 播放音频和视频,我们能够同时播放画面和音频。其中 SDL 启动了一个音频线程,每次需要音频数据时都会回调到我们定义的函数。现在,我们需要对视频显示做同样的事情。这么做能让我们的代码更加模块化,更容易使用。

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

本文的代码在 ffmpeg_video_player_tutorial-my_tutorial04_02_threads

线程模型

回看目前实现的代码,它在主线程做了非常多的事情,包括:

  1. 处理事件循环
  2. 读取 packet,并进行解码
  3. 显示 frame

因此,我们需要做的是让这些工作分开,具体的:

  1. 解封装线程:负责从文件中读取 packet,并把这些 packet 分配到不同的 packet 队列中
  2. 视频解码线程:从 video packet 队列中读取 packet,解码为 frame,然后将解码后的 frame 放入 video frame 队列中
  3. 音频解码线程:从 audio packet 队列中读取 packet,解码为 frame,然后将解码后的 frame 放入 audio frame 队列中
  4. 定时器线程:隔一段时间(例如 30 毫秒)发送一个事件,通知主线程显示视频
  5. SDL 音频线程:由 SDL 创建,通过回调方式获取音频数据进行播放
  6. 主线程:负责各模块的初始化及事件循环。

比较上一章节,虽然线程 1 到 4 使事情看上去似乎更复杂了,但你可以放心,这些线程只是将原来复杂的任务拆分开,整体上并没有比之前的代码更复杂。

代码说明

让我们看看每个线程都在做些什么,进行代码层面上的解释

解封装线程

cpp 复制代码
std::thread demux_thread([&]() {
  AVPacket *packet{nullptr};
  for (; sdl_app.running;) {
    std::tie(ret, packet) = decoder_ctx.demuxer.readPacket();
    ON_SCOPE_EXIT([&packet] { av_packet_unref(packet); });
    // read end of file, just exit this thread
    if (ret == AVERROR_EOF || packet == nullptr) {
      break;
    }

    if (packet->stream_index == decoder_ctx.video_stream_index) {
      decoder_ctx.video_packet_queue.cloneAndPush(packet);
    } else if (packet->stream_index == decoder_ctx.audio_stream_index
      decoder_ctx.audio_packet_queue.cloneAndPush(packet);
    }
  }
});

它不停地从 demuxer 中读取 packet,并将 packet 放入不同的 packet queue 中

视频解码线程

cpp 复制代码
std::thread video_decode_thread([&]() {
  AVFrame *frame = av_frame_alloc();
  if (frame == nullptr) {
    printf("Could not allocate frame.\n");
    return -1;
  }
  ON_SCOPE_EXIT([&frame] {
    av_frame_unref(frame);
    av_frame_free(&frame);
  });
  for (; sdl_app.running;) {
    if (decoder_ctx.video_packet_queue.size() != 0) {
      ret = decodePacketAndPushToFrameQueue(decoder_ctx.video_packet_queue,
                                            decoder_ctx.video_codec, frame,
                                            decoder_ctx.video_frame_queue);
      RETURN_IF_ERROR_LOG(ret, "decode video packet failed\n");
    }
  }
  return 0;
});

它不停地从 video packet queue 中读取 packet 并进行解码,并将解码后的数据放入 video frame queue 中

音频解码线程

cpp 复制代码
std::thread audio_decode_thread([&]() {
  AVFrame *frame = av_frame_alloc();
  if (frame == nullptr) {
    printf("Could not allocate frame.\n");
    return -1;
  }
  ON_SCOPE_EXIT([&frame] {
    av_frame_unref(frame);
    av_frame_free(&frame);
  });
  for (; sdl_app.running;) {
    if (decoder_ctx.audio_packet_queue.size() != 0) {
      ret = decodePacketAndPushToFrameQueue(decoder_ctx.audio_packet_queue,
                                            decoder_ctx.audio_codec, frame,
                                            decoder_ctx.audio_frame_queue);
      printf("%zd \n", decoder_ctx.audio_frame_queue.size());
      RETURN_IF_ERROR_LOG(ret, "decode audio packet failed\n");
    }
  }
  return 0;
});

它不停地从 audio packet queue 中读取 packet 并进行解码,并将解码后的数据放入 audio frame queue 中

定时器线程

我们使用 SDL_AddTimer 来创建一个定时器,参数解释:

  1. interval:定时器的间隔时间,单位为毫秒。

  2. callback:定时器结束时调用的函数。这个函数的原型必须如下:Uint32 callback(Uint32 interval, void *param);

  3. param:传递给回调函数的参数。

    static Uint32 sdlRefreshTimerCallback(Uint32 interval, void *param) {
    (void)(interval);

    复制代码
     SDL_Event event;
     event.type = FF_REFRESH_EVENT;
     event.user.data1 = param;
    
     SDL_PushEvent(&event);
    
     return 0;

    }

我们的定时器回调函数 sdlRefreshTimerCallback 它向 SDL 发送一个 FF_REFRESH_EVENT 事件,主线程在接收到 FF_REFRESH_EVENT 事件后,将会从 video frame queue 中 pop 一帧数据,进行图像格式转换操作,并使用 SDL Render 将其渲染到屏幕上。最后会再次启动一个定时器,用来刷新下一帧。

小小的优化

现在各自线程处理各自的事情,解封装线程是数据源头,该线程在一个 for 循环中源源不断地读取 packet,后续的解码线程也在源源不断地解码数据。我们播放一个 30fps 的视频,大约每 33.33ms 播放一帧视频,而解码的速度比 33.33 快多了,也就是说现在的线程模型会会囤积非常多视频数据,等待被播放。这是对内存的一种浪费,我们不需要缓存这么多的视频帧。

解封装线程是所有数据的源头,我们只要控制住源头的速度,就能够控制整个 Pipeline 的速度。因此我们在解封装时对 packet queue 中的数据存量进行检查,如果超过某个阈值,那么就让解封装线程 sleep 一会,控制下 pipeline 的速度。

cpp 复制代码
std::thread demux_thread([&]() {
    AVPacket *packet{nullptr};

    for (; sdl_app.running;) {

      // sleep if packet size in queue is very large
      if (decoder_ctx.video_packet_sync_que.totalPacketSize() >=
              DecoderContext::MAX_VIDEOQ_SIZE ||
          decoder_ctx.audio_packet_sync_que.totalPacketSize() >=
              DecoderContext::MAX_AUDIOQ_SIZE) {
        std::this_thread::sleep_for(10ms);
        continue;
      }

      std::tie(ret, packet) = decoder_ctx.demuxer.readPacket();
      ON_SCOPE_EXIT([&packet] { av_packet_unref(packet); });

      // read end of file, just exit this thread
      if (ret == AVERROR_EOF || packet == nullptr) {
        sdl_app.running = false;
        break;
      }

      if (packet->stream_index == decoder_ctx.video_stream_index) {
        decoder_ctx.video_packet_sync_que.tryPush(packet);
      } else if (packet->stream_index == decoder_ctx.audio_stream_index) {
        decoder_ctx.audio_packet_sync_que.tryPush(packet);
      }
    }
  });

参考

相关推荐
小柯南敲键盘5 小时前
跨马翻译:跨境电商批量图片翻译与视频字幕一站式工具
人工智能·python·音视频
weixin_446260857 小时前
AVA-Encoder:面向智能体原生视频表征学习框架
学习·音视频
cdprinter7 小时前
信刻——留置谈话音视频数据集中自动刻录归档解决方案
自动化·音视频
美狐美颜sdk9 小时前
直播APP开发技术架构解析:从音视频流到第三方美颜SDK接入的完整方案
大数据·人工智能·音视频·美颜sdk·美颜api
阿童木写作10 小时前
跨境电商图片翻译工具推荐:跨马翻译批量处理视频字幕与抠图
python·音视频
奈斯先生Vector11 小时前
AI 视频不是会动的图片:用 Shot Contract、时间码与音画质检构建可交付流水线
人工智能·重构·架构·prompt·aigc·音视频
ai产品老杨11 小时前
AI视频分析并发优化参数配置说明
人工智能·音视频
怪奇云呼军11 小时前
G.711、Opus 和重采样会拖慢识别吗?闪电智能VoiceAgent 的音频入口怎么选
java·人工智能·python·算法·云计算·音视频
DogDaoDao12 小时前
MSU 2026 视频编解码大赛放榜深度解读
音视频·视频编解码·hevc·av1·h266·msu·avs
caimouse13 小时前
ReactOS 图形系统分析(2):视频端口驱动 videoprt.sys
音视频