系列文章目录
- 基于 FFmpeg 的跨平台视频播放器简明教程(一):FFMPEG + Conan 环境集成
- 基于 FFmpeg 的跨平台视频播放器简明教程(二):基础知识和解封装(demux)
- 基于 FFmpeg 的跨平台视频播放器简明教程(三):视频解码
- 基于 FFmpeg 的跨平台视频播放器简明教程(四):像素格式与格式转换
@TOC
前言
经过前面四章的学习,现在我们已经掌握了如何使用 FFmpeg 进行视频解码,中间穿插了很多音视频相关的知识点,例如容器、编解码器、解封装、像素格式、格式转换等等。现在回看,音视频的入门门槛还是比较高的,一个最简单的任务就已经涉及到大量的知识点。但问题不大,本人希望通过一系列的文章来带你入门,通过完成一个播放器项目来不断地学习音视频内容。
在开始新的旅程前,重新审视下现有代码,发现有些模块可以被封装成更为内聚的类,具体的包括:
- FFmpegDemuxer,用于解封装相关的任务
- FFmpegCodec,用于解码相关的任务
- FFmpegImageConverter,用于 AVFrame 格式转换
这些类的使用方式,你可以在单元测试中找到示例,此处不再赘述。
抽象封装成一些类的好处主要有几点:
- 资源管理。FFmpeg 是 C 接口,很多资源需要手动的管理,这样的代码写多了难免会出现内存泄漏的问题。因此用 C++ 的 "资源获取即初始化" 理念来管理这些资源。
- 减少代码冗余。将某些任务封装成更为简便的接口,减少代码冗余。
好的,准备就绪,让我们进入今天的主题:播放视频。本文参考文章来自 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,跨平台多媒体开发库
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 可在多个平台(如 Windows、macOS、Linux 等)上运行,这意味着基于 SDL 的 FFplay 可以很容易地移植到其他环境。
- 音频和视频渲染:SDL 提供了对音频和视频的播放支持,使得 FFplay 可以正确地渲染音频和视频数据。
- 事件处理:SDL 提供了对各类输入设备事件(如键盘、鼠标等)的处理,这使得 FFplay 可以对用户的操作做出响应,例如暂停、快进等。
关于 SDL 的使用,我之前写过一些文章,供大家参考,此处不再赘述:
- SDL2 简明教程(一):使用 Cmake 和 Conan 构建 SDL2 编程环境
- SDL2 简明教程(二):创建一个空的窗口
- SDL2 简明教程(三):显示图片
- SDL2 简明教程(四):用 SDL_IMAGE 库导入图片
- SDL2 简明教程(五):OpenGL 绘制
SDL 显示图像
首先让我们快速过一下 SDL 显示图片的过程。使用SDL来显示一帧图像的步骤如下:
- 初始化SDL:调用SDL_Init函数来初始化SDL库。
- 创建一个窗口和渲染器:调用SDL_CreateWindow和SDL_CreateRenderer函数来创建一个窗口和渲染器。
- 加载图像:使用SDL_image库中的函数(如IMG_Load)加载需要显示的图像。
- 创建一个纹理:使用加载的图像创建一个纹理,使用SDL_CreateTextureFromSurface函数。
- 将纹理渲染到屏幕:使用SDL_RenderCopy函数将纹理渲染到屏幕上。
- 刷新屏幕:使用SDL_RenderPresent函数来刷新屏幕。
- 释放资源:使用SDL_DestroyTexture、SDL_DestroyRenderer、SDL_DestroyWindow等函数释放分配的资源。
下面是C++代码示例,显示一张图像:
cpp
#include <SDL.h>
#include <SDL_image.h>
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_Renderer负责处理和绘制SDL_Texture对象。
- SDL_Surface用于创建、处理和存储原始的位图图像,然后将它们转换成硬件加速的SDL_Texture以供渲染器绘制。
简而言之,SDL_Window负责显示,SDL_Renderer负责绘制,SDL_Surface负责处理原始位图,SDL_Texture则可以高效地被渲染器绘制到窗口。在实际应用中,通常需要处理并将多个表面转换成纹理,然后使用渲染器将它们绘制到窗口中。
SDL 显示 YUV 图像
视频多数情况下使用 YUV 格式来存放像素数据,主要原因是压缩效率更高,同时还能保持较好的图像质量。具体来说,有以下几点原因:
- 符合人眼特性: YUV格式将图像的亮度信息(Y分量)和色度信息(U和V分量)分开存储。而人眼对亮度信息(黑白图像)的敏感度要高于色度信息(彩色信息)。在进行压缩时,可以减少色度信息的分辨率,从而降低数据量,这符合人类视觉系统的特性,不会明显降低观感质量。这种削减色度分辨率的方式叫色度子采样(如4:2:0、4:2:2、4:4:4)。
- 节省存储空间和带宽: 相比于像RGB这种直接存储色彩值的格式,YUV格式可以利用色度子采样来有效地减少数据量,在同样的图像质量下,YUV格式需要的存储空间和传输带宽更小。这在视频传输、压缩、存储等场景中十分重要。
- 兼容性和广泛应用: YUV格式已经被广泛应用于许多视频压缩标准,如H.264(AVC)、H.265(HEVC)等,以及各种视频设备和传输系统。这意味着使用YUV格式可以显著提高视频系统的通用性和兼容性。
- 易于处理: YUV格式把亮度信息与色度信息分开存储,方便视频图像处理和编辑,如调整亮度、对比度、色彩平衡等。
在SDL中显示一张YUV图像,可以使用 SDL_Texture的SDL_PIXELFORMAT_YV12 或SDL_PIXELFORMAT_IYUV像素格式。首先,需要创建一个相应格式的纹理,然后通过SDL_UpdateYUVTexture()函数更新纹理数据。下面是一个简单的示例:
cpp
#include <SDL.h>
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 的 源码来学习 。
SDL 播放视频
现在,我们的目标是取代上一个教程中的 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 负责解码视频,整体流程大致是这样的:
cpp
// 使用 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 同时播放视频与音频。