C++/CLI:使用 C++/CLI、FFmpeg 打造影片撥放功能
前言
最近因為工作需要在 C/C++ 環境底下開發出影片檔讀取並撥放功能,由於有跨平台測試的需求 (Windows/Linux),最直覺想到的就是使用 FFmpeg。Windows 會用 C++/CLI CLR 開發視窗畫面;Linux 則是靠 MiniGUI 顯示到 LCD 模組上,這篇文章主要是紀錄我如何在 Visual Studio 中完成 CLR WinForm 簡易撥放功能,其實呼叫 FFmpeg 相關程式是參考網路上文章,幾乎是複製貼上了,只是還是需要修改許多東西才能正常執行,像是記憶體溢出,或是畫面顏色處理的問題等等。
這邊我不打算整理 FFmpeg 的用法,因為我也沒很熟悉,網路上查就有一堆大神分享,但會簡單紀錄一下呼叫了哪些 API。如果以後使用到一定程度,我應該會直接寫另外一篇文章吧www。
事前工作
- 安裝 Visual Studio 2019
- 在 Visual Studio 中導入 CLR WinForm 專案開發
- 下載 FFmpeg 的 Library 函式庫
這邊我是去網路上找 win32 版本,官網上現在只提供 x64 版本,不過配置方式其實一樣。
專案配置
CLR 專案詳細設定可以參考先前文章,這邊不再贅述。 (註:對應下文第 1、2 點)
1. 新增 CLR 專案
2. 新增 MyForm 檔案並進行屬性設定
3. 設定 FFmpeg
a. 下載 FFmpeg Windows Build
我是下載 x86 版本,檔名要找 ‘ffmpeg-master-latest-win32-shared.zip’。
(https://yang10001.yia.app/wp-content/uploads/2022/04/06.png)
b. 解壓縮並重新命名成 ‘ffmpeg’ 放到 ‘Win32Form’ 專案資料夾中
解壓路徑跟所需要的檔案可以參考下圖,原本還有一個 ‘doc’ 資料夾,但我刪掉了XD
c. ‘bin’ 資料夾裡的 *.dll
檔案需移至 ‘Win32Form’ 專案資料夾中
d. 打開屬性頁
這邊要設定 include、library 路徑。我是設定相對路徑,這樣就算移動整個專案也不用擔心路徑問題。
-
設定include
-
設定library
-
設定結果確認
到此即可編譯 ffmpeg 的檔案了,接下來快速介紹程式碼吧!
程式碼說明 (僅秀出部分程式碼)
我主要是參考這篇文章去做修改,這裡面有更詳細的內容,像是硬體解碼之類,有興趣可以看看。
以下解說我都只取部分程式來解釋,建議搭配原始程式碼去看。
Source.h
- 引入 ffmpeg
由於是 C Code,所以要用extern "C" {}
才能正確引入。
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")
#include <libswscale/swscale.h>
#pragma comment(lib, "swscale.lib")
}
- 解碼器參數的 struct
struct DecoderParam
{
AVFormatContext* fmtCtx;
AVCodecContext* vcodecCtx;
int width;
int height;
int videoStreamIndex;
};
Source.cpp
這裡就是實現 FFmpeg API 功能呼叫的程式,基本上讀取影片 Frame 就是看這個檔案。
- 初始化設定,並根據應片檔設定對應解碼器
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;
}
- 對影片解碼,並取出 Frame 畫面
av_packet_alloc
分配記憶體存放解碼用的資料。
av_frame_alloc
分配記憶體存放 Frame。
avcodec_receive_frame
取得 Frame 資料。
av_frame_unref
、avcodec_close
、av_packet_unref
都是釋放記憶體。
AVPacket* packet;
AVFrame* RequestFrame(DecoderParam& param) {
auto& fmtCtx = param.fmtCtx;
auto& vcodecCtx = param.vcodecCtx;
auto& videoStreamIndex = param.videoStreamIndex;
while (1) {
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);
}
}
set_video_status = 1;
}
else {
set_video_status = 0;
avcodec_close(vcodecCtx); // Release decoder
}
av_packet_unref(packet);
return nullptr;
}
}
- 轉換顏色的格式 – YUV轉RGB
我測試的檔案編碼分別是 H.264、Motion-JPEG,所以如果直接取用 frame->data
不會是正確的顏色 (頂多顯示成灰階),所以用 sws_scale
可以轉換顏色。
sws_getCachedContext
設定來源、目標格式大小給後面的 sws_scale
使用。
sws_scale
轉換 Frame 資料,除了顏色之外還有其他功能,有興趣可以去查查。
sws_freeContext
釋放 SwsContext
記憶體。
av_image_fill_arrays
其實就是開新的 Frame Array 存放 sws_scale
轉換過的資料。
void GetRGBPixels(AVFrame* src_frame, AVFrame* dst_frame, uint8_t* buf) {
SwsContext* swsctx = nullptr;
swsctx = sws_getCachedContext(
swsctx,
src_frame->width, src_frame->height, AVPixelFormat::AV_PIX_FMT_YUV420P,
src_frame->width, src_frame->height, AVPixelFormat::AV_PIX_FMT_RGB24, NULL, NULL, NULL, NULL
);
av_image_fill_arrays(
dst_frame->data, dst_frame->linesize, buf,
AV_PIX_FMT_RGB24, src_frame->width, src_frame->height,
1
);
sws_scale(
swsctx,
src_frame->data, src_frame->linesize, 0, src_frame->height,
dst_frame->data, dst_frame->linesize
);
sws_freeContext(swsctx);
}
MyForm.h
這邊是將讀取到的 Frame 轉成 UI 顯示的程式,在 C++/CLI CLR WinForm 中,顯示影像的元件叫做 PictureBox,有寫過 C# 應該不陌生,畢竟都是呼叫 .NET 的東西。
- 引入標頭檔、設置
DecoderParam
全域變數
#include <msclr/marshal.h>
#include "Source.h"
DecoderParam decoderParam;
- 設定 UI 繪圖的相關變數
public:
Bitmap^ vo_bmp1;
Graphics^ vo_gra1;
Pen^ vo_pen1;
SolidBrush^ vo_brush;
int encode_mode = 0; // 0:h264(mp4) 1:mjpeg
- 使用計時器來讀取每個 Frame 並繪製 UI 顯示
這邊比較長,所以相關說明直接寫在裡面,我的原始程式應該是沒這些註解www。
private: System::Void timer1_Tick(System::Object^ sender, System::EventArgs^ e) {
//// packet 宣告在 Source.cpp 中,在這邊引入是為了可以釋放記憶體
extern AVPacket* packet;
//// 宣告變數來存放 Frame 資料
int width, height, bufSize;
uint8_t* buf; AVFrame* frameOld; AVFrame* frameNew;
//// 用來取得 Frame (Source.cpp),此時顏色格式還不是 RGB
//// 因此變數名稱才會命名成 frameOld
frameOld = RequestFrame(decoderParam);
//// 有 Frame 進來才會執行 UI 繪圖
//// 如果讀到空指針 nullptr,則會繼續 RequestFrame
if (frameOld != nullptr) {
width = frameOld->width;
height = frameOld->height;
//// 初始化 `pictureBox->Image` 的記憶體
//// 設定 WinForm 視窗大小、pictureBox 元件大小、初始色
if (this->pictureBox1->Image == nullptr)
{
this->Width = this->Width + (width - this->panel1->Width);
this->Height = this->Height + (height - this->pictureBox1->Height);
this->pictureBox1->Width = width;
this->pictureBox1->Height = height;
vo_bmp1 = gcnew Bitmap(width, height);
vo_gra1 = Graphics::FromImage(vo_bmp1);
vo_gra1->Clear(Color::Black);
this->pictureBox1->Image = vo_bmp1;
}
//// 轉換 frameOld 的顏色 (YUV -> RGB)
//// 分配記憶體空間給 frameNew 來存放 RGB 像素
bufSize = av_image_get_buffer_size(AV_PIX_FMT_RGB24, width, height, 1 );
buf = (uint8_t*)av_malloc(bufSize);
frameNew = av_frame_alloc();
GetRGBPixels(frameOld, frameNew, buf);
////---------------------------------------------------------------------------------
//// 以下開始填 pictureBox 的資料
//// 這邊使用 LockBits 來提高繪製效能 (比 setPixel 還要快很多)
////---------------------------------------------------------------------------------
//// 鎖定 Bitmap 記憶體
System::Drawing::Rectangle rect = System::Drawing::Rectangle(0, 0, vo_bmp1->Width, vo_bmp1->Height);
System::Drawing::Imaging::BitmapData^ bmpData = vo_bmp1->LockBits(rect, System::Drawing::Imaging::ImageLockMode::ReadWrite, vo_bmp1->PixelFormat);
//// 設定 Bitmap 指標
IntPtr ptr = bmpData->Scan0;
//// 宣告 Array 來存放像素資料,名稱為 destination
//// 然後使用迴圈去設定 RGB 的像素資料,我習慣用兩個迴圈去填了
int bytes = bmpData->Stride * vo_bmp1->Height;
array<byte>^ destination = gcnew array<byte>(bytes);
System::Runtime::InteropServices::Marshal::Copy(ptr, destination, 0, bytes);
int nOffset = bmpData->Stride - bmpData->Width * 4; //通道位移,正常為BGRA。確保後面loc獲得正確像素位置
//// width、height 是指 pictureBox 的大小
int pb = 0; int pg = 0; int pr = 0; int loc = 0; int off = 0;
for (int j = 0; j < height; j++) {
for (int i = 0; i < width; i++) {
//// 取得 frameNew 的 RGB 資料
//// frameNew->linesize[0] 表示一行大小,這樣才可以正確讀取下一行資料
//// i 會乘 3 是因為位移 RGB 3 bytes
loc = i * 3 + j * frameNew->linesize[0];
pr = frameNew->data[0][loc];
pg = frameNew->data[0][loc + 1];
pb = frameNew->data[0][loc + 2];
//// 設定 destination 的 RGB資料
//// 這邊使用 ARGB,就是包含透明度 Alpha,所以其實位移 4 bytes
//// 所以一行的長度是 width * 4
loc = (i + j * width) * 4 + off;
destination[loc + 0] = pb;
destination[loc + 1] = pg;
destination[loc + 2] = pr;
destination[loc + 3] = 0xff;
}
off += nOffset;
}
//// 把 destination 的資料填回 bmpData
//// 解除鎖定 Bitmap 記憶體
//// 將 Bitmap 設定到 pictureBox->Image,並刷新畫面
//// 最後將 destination 清除掉
System::Runtime::InteropServices::Marshal::Copy(destination, 0, ptr, bytes);
vo_bmp1->UnlockBits(bmpData);
this->pictureBox1->Image = vo_bmp1;
this->pictureBox1->Refresh();
destination->Clear;
//// 後面都是釋放 FFmpag 所使用的記憶體
av_frame_free(&frameNew);
av_free(buf);
}
av_frame_free(&frameOld);
av_packet_free(&packet);
}
- 這是設定開始按鍵,主要是選擇檔案並呼叫
InitDecoder
來配置解碼器資訊。
(註:之後應該會 print 更多東西出來,目前先印出大小。)
private: System::Void button_play_Click(System::Object^ sender, System::EventArgs^ e) {
if (set_video_status == 0) {
if (this->radioButton_file_1->Checked) {
encode_mode = 0;
InitDecoder("..\\test_video.h264.mp4", decoderParam);
}
if (this->radioButton_file_2->Checked) {
encode_mode = 1;
InitDecoder("..\\test_video.mjpeg.avi", decoderParam);
}
auto& width = decoderParam.width;
auto& height = decoderParam.height;
//auto& fmtCtx = decoderParam.fmtCtx;
//auto& vcodecCtx = decoderParam.vcodecCtx;
printf("Width: %d\n", width);
printf("Height: %d\n", height);
printf("\n");
this->timer1->Enabled = true;
}
}
MyForm.cpp
- 新增主函式 main(),這個就是對應剛剛在屬性頁設定的進入點。
#include "MyForm.h"
using namespace System;
using namespace System::Windows::Forms;
[STAThreadAttribute]
void main(array<String^>^ args) {
Application::EnableVisualStyles();
Application::SetCompatibleTextRenderingDefault(false);
Win32Form::MyForm form; // MyProject 需根據專案名稱進行替換
Application::Run(% form);
}
程式運行結果
程式碼:Github 連結,檔案沒啥篩選就都放進去惹,而且也包含測試影片,所以有點大,如果 Visual Studio 有把 CLR 安裝好,理論上應該可以直接跑…
結語
這次應該久違寫這麼詳細的文章,我打字太慢寫一篇文章都很久…,但如果要把平常筆記、工作內容整理起來,對我來說還是寫成文章比較有系統,同時也會重新思考是如何實作,有很多東西自己整理過後分享出來,我覺得才算是真正完成一個功能,即便是小小的功能都是自己的一個大進步XD。
之後會不會增加功能我還不知道,但是可以肯定的是下面參考資料幫了我很多,各位一定要看看。
(參考資料其實只有看第一個的前半段,後面實在太難,我看不懂了QAQ,但是這位大神分享的實在太牛B了。而且只看前半段就讓我完成簡易影片撥放功能,這還不好好寫筆記,怎麼可以呢?各位說對不對壓XDD)
假設要加功能,估計是音訊、影片控制而已,其他畫面速度處理優化,硬體解碼巴拉巴拉的估計是無法了。