经过前面四章的学习,现在我们已经掌握了如何使用 FFmpeg 进行视频解码,中间穿插了很多音视频相关的知识点,例如容器、编解码器、解封装、像素格式、格式转换等等。现在回看,音视频的入门门槛还是比较高的,一个最简单的任务就已经涉及到大量的知识点。但问题不大,本人希望通过一系列的文章来带你入门,通过完成一个播放器项目来不断地学习音视频内容。
在开始新的旅程前,重新审视下现有代码,发现有些模块可以被封装成更为内聚的类,具体的包括:
这些类的使用方式,你可以在单元测试中找到示例,此处不再赘述。
抽象封装成一些类的好处主要有几点:
好的,准备就绪,让我们进入今天的主题:播放视频。本文参考文章来自 An ffmpeg and SDL Tutorial - Tutorial 02: Outputting to the Screen。这个系列对新手较为友好,但 2015 后就不再更新了,以至于文章中的 ffmpeg api 已经被弃用了。幸运的是,有人对该教程的代码进行重写,使用了较新的 api,你可以在 rambodrahmani/ffmpeg-video-player 找到这些代码。
本文的代码在 ffmpeg_video_player_tutorial-my_tutorial02。
SDL2 是 Simple DirectMedia Layer(简单直接媒体层)的缩写,它是一个跨平台的 C/C++ 库,专为游戏和多媒体应用程序提供硬件抽象层的支持。SDL2 提供了对音频、键盘、鼠标、操纵杆和图形硬件的底层访问能力,它在 Windows、macOS、Linux、iOS 和 Android 等平台上都有广泛的应用。
FFmpeg 提供的命令行工具 FFplay 中使用了 SDL2 作为渲染图像的依赖库。FFplay 是 FFmpeg 项目中的一个简单的媒体播放器,它依赖于 FFmpeg 库来解码、解复用和处理媒体数据。FFmpeg 本身专注于视频和音频的编解码,以及其他媒体处理功能,但不涉及与硬件交互的部分,例如音视频的渲染和播放。
FFplay 使用 SDL 主要是因为 SDL 提供了易于使用的跨平台 API,用于访问音频、视频、键盘、鼠标和操纵杆等硬件设备。通过使用 SDL,FFplay 能在各种操作环境下实现音视频的同步播放,以及用户交互(如键盘和鼠标操作)。
SDL 提供了以下特性,使其成为 FFplay 使用的理想选择:
关于 SDL 的使用,我之前写过一些文章,供大家参考,此处不再赘述:
首先让我们快速过一下 SDL 显示图片的过程。使用SDL来显示一帧图像的步骤如下:
下面是C++代码示例,显示一张图像:
#include
#include
int main(int argc, char const *argv[])
{
SDL_Init(SDL_INIT_VIDEO);
SDL_Window* window = SDL_CreateWindow("SDL Example", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 640, 480, 0);
SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);
SDL_Surface* image = IMG_Load("example.png");
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, image);
SDL_RenderCopy(renderer, texture, NULL, NULL);
SDL_RenderPresent(renderer);
SDL_Delay(5000); //等待5秒
SDL_DestroyTexture(texture);
SDL_FreeSurface(image);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
IMG_Quit();
SDL_Quit();
return 0;
}
在SDL中,window、renderer、surface和texture是四个关键的图形和图像处理对象,它们之间有一定的联系。让我们一一分析它们的作用和关系。
SDL_Window: 窗口对象。用于在屏幕上创建、管理和显示一个简单的矩形窗口。窗口可以由操作系统进行管理,提供了标题栏、边框和其他窗口功能。SDL_Window代表了应用程序中使用的这个窗口实例。
SDL_Renderer: 渲染器对象。负责将多个图像(通常由SDL_Texture表示)绘制到屏幕上。渲染器可以使用不同的后端实现(如OpenGL,Direct3D等),并将其集成到SDL_Window中。与SDL_Window对象关联的渲染器用于将图像和图形绘制到窗口中。
SDL_Surface: 表面对象。表示原始的位图图像,每个像素由色彩值定义。表面可以包括一个或多个图层,用于绘制2D图像。然而,它们在处理上较慢,因为渲染操作通常在CPU端执行。表面通常用于加载、处理和创建图像资源,然后将它们转换成纹理用于高效渲染。
SDL_Texture: 纹理对象。代表了可以被硬件加速的图形。纹理被GPU管理,可以更高效地使用SDL_Renderer进行渲染。它们通常由SDL_Surface转换而来,在纹理上传到GPU之后,关联的表面对象可以被释放以节省内存。
这四个对象之间的关系如下:
简而言之,SDL_Window负责显示,SDL_Renderer负责绘制,SDL_Surface负责处理原始位图,SDL_Texture则可以高效地被渲染器绘制到窗口。在实际应用中,通常需要处理并将多个表面转换成纹理,然后使用渲染器将它们绘制到窗口中。
视频多数情况下使用 YUV 格式来存放像素数据,主要原因是压缩效率更高,同时还能保持较好的图像质量。具体来说,有以下几点原因:
在SDL中显示一张YUV图像,可以使用 SDL_Texture的SDL_PIXELFORMAT_YV12 或SDL_PIXELFORMAT_IYUV像素格式。首先,需要创建一个相应格式的纹理,然后通过SDL_UpdateYUVTexture()函数更新纹理数据。下面是一个简单的示例:
#include
int main(int argc, char *argv[])
{
// 初始化SDL
if (SDL_Init(SDL_INIT_VIDEO) != 0)
{
SDL_Log("Unable to initialize SDL: %s", SDL_GetError());
return -1;
}
// 创建窗口和渲染器
SDL_Window* window = SDL_CreateWindow("YUV Example", SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED, 640, 480, 0);
SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);
// 创建纹理
SDL_Texture* texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_YV12,
SDL_TEXTUREACCESS_STREAMING, 640, 480);
// 加载YUV数据
Uint8* yuvData = loadYUVData();
// 假设 yPlane, uPlane 和 vPlane 分别是Y,U和V分量的指针
Uint8 *yPlane;
Uint8 *uPlane;
Uint8 *vPlane;
int dataSize = width * height;
// 设置三个分量数据的跨度
int yPitch = width;
int uPitch = width / 2;
int vPitch = width / 2;
// 更新纹理数据
SDL_UpdateYUVTexture(texture,
nullptr, // 更新整个纹理
yPlane, yPitch,
uPlane, uPitch,
vPlane, vPitch);
// 渲染纹理
SDL_RenderClear(renderer);
SDL_RenderCopy(renderer, texture, NULL, NULL);
SDL_RenderPresent(renderer);
SDL_Delay(5000);
// 释放资源
SDL_DestroyTexture(texture);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
在文章 YUV 文件读取、显示、缩放、裁剪等操作教程
有更详细的说明,包括 SDL YUV 格式与 ffmpeg 像素格式之间的对应,Chroma subsampling 是什么,YUV Packed 与 Planar 的区别,如何正确读取一张 YUV 图片等等,强烈推荐先看这篇文章后再继续我们的教程。此外,你也可以直接看项目 simple_yuv_viewer 的 源码来学习 。
现在,我们的目标是取代上一个教程中的 saveFrame
函数,而直接将视频帧输出到屏幕。为了让视频解码与视频播放的逻辑独立开,这里引入一个类叫 SDLApp
,它负责 SDL 环境的创建、将 AVFrame 更新至纹理、处理事件等等。具体实现你可以在 ffmpeg_video_player_tutorial-my_tutorial02 中看到。
这里先简单介绍下 SDLApp 的主要方法:
SDLApp::onInit(int video_width, int video_height)
,负责 SDL 资源的初始化,包括窗口、纹理以及 Render。注意,在创建纹理时使用的格式 SDL_PIXELFORMAT_IYUV,对应 FFmpeg 中的 yuv420p 格式。这是最为常用的格式。SDLApp::onLoop(AVFrame *pict)
,负责将 YUV 数据更新至纹理。SDLApp::onRender(double sleep_time_s)
,负责渲染纹理,即将纹理显示在窗口中。其中 sleep_time_s
是一个等待时间,用于控制播放的速率。void onEvent(const SDL_Event &event)
负责对 SDL 窗口事件进行响应。在这里,这个函数其实没有起任何作用。可以忽略。SDLApp::onCleanup
,负责释放 SDL 资源。SDLApp 负责渲染图像,FFmpeg 负责解码视频,整体流程大致是这样的:
// 使用 demuxer 打开文件
FFmpegDmuxer demuxer;
demuxer.openFile(infile);
// 创建视频解码器
AVStream *video_stream = demuxer.getStream(video_stream_index);
FFmpegCodec video_codec;
auto codec_id = video_stream->codecpar->codec_id;
auto par = video_stream->codecpar;
video_codec.prepare(codec_id, par);
// 创建 ImageConverter,负责将解码后的图像转换为 yuv420p 格式。与 SDL 中的纹理格式匹配
auto dst_format = AVPixelFormat::AV_PIX_FMT_YUV420P;
auto codec_context = video_codec.getCodecContext();
FFMPEGImageConverter img_conv;
img_conv.prepare(codec_context->width, codec_context->height,
codec_context->pix_fmt, codec_context->width,
codec_context->height, dst_format, SWS_BILINEAR, nullptr,
nullptr, nullptr);
// sdl 环境初始化
auto video_width = video_codec.getCodecContext()->width;
auto video_height = video_codec.getCodecContext()->height;
SDLApp app;
app.onInit(video_width, video_height);
// 获取视频的 fps
double fps = av_q2d(video_stream->r_frame_rate);
double sleep_time = 1.0 / fps;
// 一直解码,直到满足某种条件退出
for(;!finished;)
{
AVFrame* frame = decodeNextFrame();
AVFRame* yuv_frame = convertToYUV(frame);
// 显示图片
app.onLoop(pict);
app.onRender(sleep_time);
}
app.onCleanup();
好的,以上就是使用 SDL 播放视频的所有逻辑。详细代码请参考 ffmpeg_video_player_tutorial-my_tutorial02。
本文介绍了 SDL 框架,使用 SDL 框架显示图片的流程,以及如何结合 FFmpeg 的解码能力使用 SDL 来播放视频。在我们的实现中,只显示显示了画面,但没有声音,这是没有灵魂的。在下一章中,我们将介绍如何使用 SDL 同时播放视频与音频。