【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(一)

首先要说的是,在项目属性 - 链接器 - 系统 - 子系统 选择 窗口 (/SUBSYSTEM:WINDOWS),就可以让程序启动的时候,不出现控制台窗口。当然,这其实也无关紧要,即使是使用 控制台 (/SUBSYSTEM:CONSOLE),也不妨碍程序功能正常运行。

创建窗口的核心函数,是 CreateWindow(准确的说:是CreateWindowA或者CreateWindowW,这两个才是 User32.dll 的导出函数名字,但为了方便,之后我都会用引入 Windows 头文件定义的宏作为函数名称,这个务必注意),但它足足有 11 个参数要填,十分劝退。

auto window = CreateWindow(className, L"Hello World 标题", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInstance, NULL);
className 是窗口类名,待会再细说,L"Hello World 标题" 就是将会出现在窗口标题栏的文字,WS_OVERLAPPEDWINDOW是一个宏,代表窗口样式,比如当你想要一个无边框无标题栏的窗口时,就要用另外一些样式。CW_USEDEFAULT, CW_USEDEFAULT, 800, 600分别代表窗口出现的位置坐标和宽高,位置我们使用默认就行,大小可以自己指定,剩下的参数在目前不太重要,全部是NULL也完全没有问题。

在调用 CreateWindow 之前,通常还要调用 RegisterClass,注册一个窗口类,类名可以随便取。

auto className = L"MyWindow";
WNDCLASSW wndClass = {};
wndClass.hInstance = hInstance;
wndClass.lpszClassName = className;
wndClass.lpfnWndProc = [](HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {
return DefWindowProc(hwnd, msg, wParam, lParam);
};

RegisterClass(&wndClass);
WNDCLASSW结构体也有很多需要设置的内容,但其实必不可少的就是两个,lpszClassName 和 lpfnWndProc,hInstance 这里也不是必须的。lpszClassName 就是是类名,而 lpfnWndProc 是一个函数指针,每当窗口接收到消息时,就会调用这个函数。这里我们可以使用 C++ 11 的 Lambda 表达式,赋值到 lpfnWndProc 的时候它会自动转换为纯函数指针,而且你无需担心 stdcall cdecl 调用约定问题,前提是我们不能使用变量捕捉特性。

return DefWindowProc(hwnd, msg, wParam, lParam);的作用是把消息交给Windows作默认处理,比如点击标题栏右上角的×会关闭窗口,以及最大化最小化等等默认行为,这些行为都可以由用户自行接管,后面我们就会在这里处理鼠标键盘等消息了。

默认刚刚创建的窗口是隐藏的,所以我们要调用 ShowWindow 显示窗口,最后使用消息循环让窗口持续接收消息。

ShowWindow(window, SW_SHOW);

MSG msg;
while (GetMessage(&msg, window, 0, 0) > 0) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
最后别忘了在程序最开头调用 SetProcessDPIAware(),防止Windows在显示缩放大于100%时,自行拉伸窗体导致显示模糊。

完整的代码看起来就是这样:

#include <stdio.h>
#include <Windows.h>

int WINAPI WinMain (
In HINSTANCE hInstance,
In_opt HINSTANCE hPrevInstance,
In LPSTR lpCmdLine,
In int nShowCmd
) {
SetProcessDPIAware();

auto className = L"MyWindow";
WNDCLASSW wndClass = {};
wndClass.hInstance = NULL;
wndClass.lpszClassName = className;
wndClass.lpfnWndProc = [](HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {
	return DefWindowProc(hwnd, msg, wParam, lParam);
};

RegisterClass(&wndClass);
auto window = CreateWindow(className, L"Hello World 标题", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, NULL, NULL);

ShowWindow(window, SW_SHOW);

MSG msg;
while (GetMessage(&msg, window, 0, 0) > 0) {
	TranslateMessage(&msg);
	DispatchMessage(&msg);
}

return 0;

}
效果:

image

引入FFmpeg
我们就不费心从源码编译了,直接下载编译好的文件就行:https://github.com/BtbN/FFmpeg-Builds/releases,注意下载带shared的版本,例如:ffmpeg-N-102192-gc7c138e411-win64-gpl-shared.zip,解压后有三个文件夹,分别是 bin, include, lib,这分别对应了三个需要配置的东西。

接下来建立两个环境变量,注意目录改为你的实际解压目录:

FFMPEG_INCLUDE = D:\Download\ffmpeg-N-102192-gc7c138e411-win64-gpl-shared\include
FFMPEG_LIB = D:\Download\ffmpeg-N-102192-gc7c138e411-win64-gpl-shared\lib
注意每次修改环境变量,都需要重启Visual Studio。然后配置 VC++目录 中的包含目录和库目录

image

然后就可以在代码中引入FFmpeg的头文件,并且正常编译了:

extern “C” {
#include <libavcodec/avcodec.h>
#pragma comment(lib, “avcodec.lib”)

#include <libavformat/avformat.h>
#pragma comment(lib, “avformat.lib”)

#include <libavutil/imgutils.h>
#pragma comment(lib, “avutil.lib”)

}
最后还要在环境变量PATH加入路径 D:\Download\ffmpeg-N-102192-gc7c138e411-win64-gpl-shared\bin,以便让程序运行时正确载入FFmpeg的dll。

解码第一帧画面
接下来我们编写一个函数,获取到第一帧的像素集合。

AVFrame* getFirstFrame(const char* filePath) {
AVFormatContext* fmtCtx = nullptr;
avformat_open_input(&fmtCtx, filePath, NULL, NULL);
avformat_find_stream_info(fmtCtx, NULL);

int videoStreamIndex;
AVCodecContext* vcodecCtx = nullptr;
for (int i = 0; i < fmtCtx->nb_streams; i++) {
	AVStream* stream = fmtCtx->streams[i];
	if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
		const AVCodec* codec = avcodec_find_decoder(stream->codecpar->codec_id);
		videoStreamIndex = i;
		vcodecCtx = avcodec_alloc_context3(codec);
		avcodec_parameters_to_context(vcodecCtx, fmtCtx->streams[i]->codecpar);
		avcodec_open2(vcodecCtx, codec, NULL);
	}
}

while (1) {
	AVPacket* packet = av_packet_alloc();
	int ret = av_read_frame(fmtCtx, packet);
	if (ret == 0 && packet->stream_index == videoStreamIndex) {
		ret = avcodec_send_packet(vcodecCtx, packet);
		if (ret == 0) {
			AVFrame* frame = av_frame_alloc();
			ret = avcodec_receive_frame(vcodecCtx, frame);
			if (ret == 0) {
				av_packet_unref(packet);
				avcodec_free_context(&vcodecCtx);
				avformat_close_input(&fmtCtx);
				return frame;
			}
			else if (ret == AVERROR(EAGAIN)) {
				av_frame_unref(frame);
				continue;
			}
		}
	}

	av_packet_unref(packet);
}

}
流程简单来说,就是:

获取 AVFormatContext,这个代表这个视频文件的容器
获取 AVStream,一个视频文件会有多个流,视频流、音频流等等其他资源,我们目前只关注视频流,所以这里有一个判断 stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO
获取 AVCodec,代表某个流对应的解码器
获取 AVCodecContext,代表解码器的解码上下文环境
进入解码循环,调用用 av_read_frame 获取 AVPacket,判断是否是视频流的数据包,是则调用 avcodec_send_packet 发送给 AVCodecContext 进行解码,有时一个数据包是不足以解码出完整的一帧画面的,此时就要获取下一个数据包,再次调用 avcodec_send_packet 发送到解码器,尝试是否解码成功。
最后通过 avcodec_receive_frame 得到的 AVFrame 里面就包含了原始画面信息
很多视频画面第一帧都是全黑的,不方便测试,所以可以稍微改改代码,多读取后面的几帧。

AVFrame* getFirstFrame(const char* filePath, int frameIndex) {
// …
n++;
if (n == frameIndex) {
av_packet_unref(packet);
avcodec_free_context(&vcodecCtx);
avformat_close_input(&fmtCtx);
return frame;
}
else {
av_frame_unref(frame);
}
// …
}
可以直接通过AVFrame读取到画面的width, height

AVFrame* firstframe = getFirstFrame(filePath.c_str(), 10);

int width = firstframe->width;
int height = firstframe->height;
咱们关注的原始画面像素信息在 AVFrame::data 中,他的具体结构,取决于 AVFrame::format,这是视频所使用的像素格式,目前大多数视频都是用的YUV420P(AVPixelFormat::AV_PIX_FMT_YUV420P),为了方便,我们就只考虑它的处理。

渲染第一帧画面
与我们设想的不同,大多数视频所采用的像素格式并不是RGB,而是YUV,Y代表亮度,UV代表色度、浓度。最关键是的它有不同的采样方式,最常见的YUV420P,每一个像素,都单独存储1字节的Y值,每4个像素,共用1个U和1个V值,所以,一幅1920x1080的图像,仅占用 1920 * 1080 * (1 + (1 + 1) / 4) = 3110400 字节,是RGB编码的一半。这里利用了人眼对亮度敏感,但对颜色相对不敏感的特性,即使降低了色度带宽,感官上也不会过于失真。

但Windows没法直接渲染YUV的数据,因此需要转换。这里为了尽快看到画面,我们先只使用Y值来显示出黑白画面,具体做法如下:

struct Color_RGB
{
uint8_t r;
uint8_t g;
uint8_t b;
};

AVFrame* firstframe = getFirstFrame(filePath.c_str(), 30);

int width = firstframe->width;
int height = firstframe->height;

vector<Color_RGB> pixels(width * height);
for (int i = 0; i < pixels.size(); i++) {
uint8_t r = firstframe->data[0][i];
uint8_t g = r;
uint8_t b = r;
pixels[i] = { r, g, b };
}
YUV420P格式会把Y、U、V三个值分开存储到三个数组,AVFrame::data[0] 就是Y通道数组,我们简单的把亮度值同时放进RGB就可以实现黑白画面了。接下来写一个函数对处理出来的RGB数组进行渲染,我们这里先使用最传统的GDI绘图方式:

void StretchBits (HWND hwnd, const vector<Color_RGB>& bits, int width, int height) {
auto hdc = GetDC(hwnd);
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
auto& pixel = bits[x + y * width];
SetPixel(hdc, x, y, RGB(pixel.r, pixel.g, pixel.b));
}
}
ReleaseDC(hwnd, hdc);
}
在 ShowWindow 调用之后,调用上面写的 StretchBits 函数,就会看到画面逐渐出现在窗口中了:

//…
ShowWindow(window, SW_SHOW);

StretchBits(window, pixels, width, height);

MSG msg;
while (GetMessage(&msg, window, 0, 0) > 0) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// …
image

一个显而易见的问题,就是渲染效率太低了,显示一帧就花了好几秒,对于普通每秒24帧的视频来说这完全不能接受,所以我们接下来尝试逐渐优化 StretchBits 函数。

优化GDI渲染
SetPixel 函数很显然效率太低了,一个更好的方案是使用 StretchDIBits 函数,但是他用起来没有那么简单直接。

void StretchBits (HWND hwnd, const vector<Color_RGB>& bits, int width, int height) {
auto hdc = GetDC(hwnd);
BITMAPINFO bitinfo = {};
auto& bmiHeader = bitinfo.bmiHeader;
bmiHeader.biSize = sizeof(bitinfo.bmiHeader);
bmiHeader.biWidth = width;
bmiHeader.biHeight = -height;
bmiHeader.biPlanes = 1;
bmiHeader.biBitCount = 24;
bmiHeader.biCompression = BI_RGB;

StretchDIBits(hdc, 0, 0, width, height, 0, 0, width, height, &bits[0], &bitinfo, DIB_RGB_COLORS, SRCCOPY);
ReleaseDC(hwnd, hdc);

}
注意 bmiHeader.biHeight = -height; 这里必须要使用加一个负号,否则画面会发生上下倒转,在 BITMAPINFOHEADER structure 里有详细说明。这时我们渲染一帧画面的时间就缩短到了几毫秒了。

播放连续的画面
首先我们要拆解 getFirstFrame 函数,把循环解码的部分单独抽出来,分解为两个函数:InitDecoder 和 RequestFrame

struct DecoderParam
{
AVFormatContext* fmtCtx;
AVCodecContext* vcodecCtx;
int width;
int height;
int videoStreamIndex;
};

void InitDecoder(const char* filePath, DecoderParam& param) {
AVFormatContext* fmtCtx = nullptr;
avformat_open_input(&fmtCtx, filePath, NULL, NULL);
avformat_find_stream_info(fmtCtx, NULL);

AVCodecContext* vcodecCtx = nullptr;
for (int i = 0; i < fmtCtx->nb_streams; i++) {
	const AVCodec* codec = avcodec_find_decoder(fmtCtx->streams[i]->codecpar->codec_id);
	if (codec->type == AVMEDIA_TYPE_VIDEO) {
		param.videoStreamIndex = i;
		vcodecCtx = avcodec_alloc_context3(codec);
		avcodec_parameters_to_context(vcodecCtx, fmtCtx->streams[i]->codecpar);
		avcodec_open2(vcodecCtx, codec, NULL);
	}
}

param.fmtCtx = fmtCtx;
param.vcodecCtx = vcodecCtx;
param.width = vcodecCtx->width;
param.height = vcodecCtx->height;

}

AVFrame* RequestFrame(DecoderParam& param) {
auto& fmtCtx = param.fmtCtx;
auto& vcodecCtx = param.vcodecCtx;
auto& videoStreamIndex = param.videoStreamIndex;

while (1) {
	AVPacket* packet = av_packet_alloc();
	int ret = av_read_frame(fmtCtx, packet);
	if (ret == 0 && packet->stream_index == videoStreamIndex) {
		ret = avcodec_send_packet(vcodecCtx, packet);
		if (ret == 0) {
			AVFrame* frame = av_frame_alloc();
			ret = avcodec_receive_frame(vcodecCtx, frame);
			if (ret == 0) {
				av_packet_unref(packet);
				return frame;
			}
			else if (ret == AVERROR(EAGAIN)) {
				av_frame_unref(frame);
			}
		}
	}

	av_packet_unref(packet);
}

return nullptr;

}
然后在 main 函数中这样写:

// …
DecoderParam decoderParam;
InitDecoder(filePath.c_str(), decoderParam);
auto& width = decoderParam.width;
auto& height = decoderParam.height;
auto& fmtCtx = decoderParam.fmtCtx;
auto& vcodecCtx = decoderParam.vcodecCtx;

auto window = CreateWindow(className, L"Hello World 标题", WS_OVERLAPPEDWINDOW, 0, 0, decoderParam.width, decoderParam.height, NULL, NULL, hInstance, NULL);

ShowWindow(window, SW_SHOW);

MSG msg;
while (GetMessage(&msg, window, 0, 0) > 0) {
AVFrame* frame = RequestFrame(decoderParam);

vector<Color_RGB> pixels(width * height);
for (int i = 0; i < pixels.size(); i++) {
	uint8_t r = frame->data[0][i];
	uint8_t g = r;
	uint8_t b = r;
	pixels[i] = { r, g, b };
}

av_frame_free(&frame);

StretchBits(window, pixels, width, height);

TranslateMessage(&msg);
DispatchMessage(&msg);

}
// …
此时运行程序,发现画面还是不动,只有当我们的鼠标在窗口不断移动时,画面才会连续播放。这是因为我们使用了 GetMessage,当窗口没有任何消息时,该函数会一直阻塞,直到有新的消息才会返回。当我们用鼠标在窗口上不断移动其实就相当于不断向窗口发送鼠标事件消息,才得以让while循环不断执行。

解决办法就是用 PeekMessage 代替,该函数不管有没有接收到消息,都会返回。我们稍微改改消息循环代码:

// …
wndClass.lpfnWndProc = [](HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {
switch (msg)
{
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
return DefWindowProc(hwnd, msg, wParam, lParam);
}
};
// …
while (1) {
BOOL hasMsg = PeekMessage(&msg, NULL, 0, 0, PM_REMOVE);
if (hasMsg) {
if (msg.message == WM_QUIT) {
break;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
else {
AVFrame* frame = RequestFrame(decoderParam);

	vector<Color_RGB> pixels(width * height);
	for (int i = 0; i < pixels.size(); i++) {
		uint8_t r = frame->data[0][i];
		uint8_t g = r;
		uint8_t b = r;
		pixels[i] = { r, g, b };
	}

	av_frame_free(&frame);

	StretchBits(window, pixels, width, height);
}

}
USB Microphone https://www.soft-voice.com/
Wooden Speakers https://www.zeshuiplatform.com/
亚马逊测评 www.yisuping.cn
深圳网站建设www.sz886.com

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值