用Qt和FFmpeg实现简单的YUV播放器

前面文章 FFmpeg像素格式转换 中我们使用 FFmpeg 实现了一个像素格式转换工具类,现在我们就可以在 Qt 中利用 QImage 很容易的实现一个简单的 YUV 播放器了。

播放器功能很简单,只有播放、暂停和停止。我们定义了一个播放器类 YuvPlayer,首先在 .h 文件中定义外部调用的函数,还需要一个设置播放文件的函数,既然是播放 yuv 文件,那么就需要额外再告诉播放器视频的宽高、像素格式以及帧率,我们定义了一个包括这些参数的结构体 Yuv

#ifndef YUVPLAYER_H
#define YUVPLAYER_H

#include <QWidget>

typedef struct {
    // 文件路径
    const char *filename;
    // yuv 的宽
    int width;
    // yuv 的高
    int height;
    // yuv 像素格式
    AVPixelFormat pixelFormat; 
    // 帧率
    int fps; 
} Yuv;

class YuvPlayer : public QWidget
{
    Q_OBJECT
public:

    // 播放器的状态
    typedef enum {
        Stopped = 0, // 停止
        Playing, // 播放中
        Paused, // 暂停
        Finished // 播放完成
    } State;

    explicit YuvPlayer(QWidget *parent = nullptr);
    ~YuvPlayer();

    // 播放
    void play();
    // 暂停
    void pause();
    // 停止
    void stop();
    // 播放器是否播放中
    bool isPlaying();
    // 获取播放器当前状态
    State getState();

    // 设置播放文件
    void setYuv(Yuv &yuv);
};

#endif // YUVPLAYER_H

setYuv 函数用来设置我们要播放的 yuv 文件,可以放到这个函数中的操作有:
1、打开 yuv 文件;
2、计算刷帧的时间间隔;
3、计算一帧图像的大小;
4、计算视频目标尺寸,在播放控件中居中显示视频;

void YuvPlayer::setYuv(Yuv &yuv)
{
    _yuv = yuv;

    // 打开文件
    _file = new QFile(yuv.filename);
    if (!_file->open(QFile::ReadOnly)) {
        qDebug() << "open file error:" << yuv.filename;
        return;
    }

    // 刷帧的时间间隔
    _interval = 1000 / _yuv.fps;

    // 计算一帧图像的大小
    _imageSize = av_image_get_buffer_size(yuv.pixelFormat, yuv.width, yuv.height, 1);

    // 组件的尺寸(播放器)
    int w = width();
    int h = height();

    // 原视频的宽度 yuv.width 高度 yuv.height
    int dstX = 0;
    int dstY = 0;
    int dstW = yuv.width;
    int dstH = yuv.height;

    // 缩放视频,计算目标尺寸
    if (dstW > w || dstH > h) {
        // 视频的宽高比 > 播放器的宽高比,(dstW / dstH)  > (w / h) 变换而来
        if ((dstW * h) > (w * dstH)) {
            dstH = dstH * w / dstW ;
            dstW = w;
        } else {
            dstW = dstW * h / dstH;
            dstH = h;
        }
    }

    // 居中视频,每种情况都有的操作
    dstX = (w - dstW) >> 1;
    dstY = (h - dstH) >> 1;
   // 计算后的视频宽高
    _dstRect = QRect(dstX, dstY, dstW, dstH);
}

在播放器中完整居中显示 YUV 视频,会遇到四种情况:1、视频宽高都小于等于播放器宽高;2、视频宽大于播放器宽,视频高小于播放器高;3、视频高大于播放器高,视频宽小于播放器宽;4、视频宽高都大于播放器宽高(等同于情况 2 或者 3);总结下来实际有下图三种情况,第 1 种情况,我们居中显示视频就可以,第 2、3、4 种情况需要视频宽高比不变的情况下对视频进行等比例伸缩,需要伸缩到视频可以在播放器中完整显示。

play 函数中开启了一个定时器,定时器执行间隔取决于帧率,执行间隔在 setYuv 中计算得到,startTimer 是 QObject 中的方法,只要继承 QObject 就可以使用这个函数:

void YuvPlayer::play() {
    // 防止多次调用 play 函数开启多个定时器
    if (_state == Playing) return;
    // 状态可能是:暂停、停止、正常完毕
    _timerId = startTimer(_interval);
    setState(Playing);
}

定时器开启后每隔一定间隔会调用 timerEvent 函数,这个函数中我们从文件读取一帧 yuv 数据,使用我们之前实现的像素格式转换工具将 yuv420p 格式数据转换成 rgb24 格式数据,然后将数据渲染到 QImage 上面,调用 update 函数刷新。此处需要注意一个问题,像素格式转换后的输出视频宽高不是 16 的倍数会降低转码速度,建议输出视频宽高是 16 倍数:

void YuvPlayer::timerEvent(QTimerEvent *event) {
    // 图片大小
    char data[_imgSize];
    if (_file->read(data, _imgSize) == _imgSize) {
        RawVideoFrame in = {
            data,
            _yuv.width, 
            _yuv.height,
            _yuv.pixelFormat
        };
        RawVideoFrame out = {
            nullptr,
            _yuv.width >> 4 << 4, 
            _yuv.height >> 4 << 4,
            AV_PIX_FMT_RGB24
        };
        FFmpegs::convertRawVideo(in, out);

        freeCurrentImage();
        _currentImage = new QImage((uchar *) out.pixels,
                                   out.width, out.height, QImage::Format_RGB888);

        // 刷新
        update();
    } else { // 文件数据已经读取完毕
        // 停止定时器
        stopTimer();
        // 正常播放完毕
        setState(Finished);
    }
}

当调用 update 函数的时候,就会触发 paintEvent,在这个函数中将图片绘制到当前组件上。当组件想重绘的时候,也会调用这个函数:

void YuvPlayer::paintEvent(QPaintEvent *event) {
    if (!_currentImage) return;
    // 将图片绘制到当前组件上
    QPainter(this).drawImage(_dstRect, *_currentImage);
}

接下来继续实现暂停和停止功能:

void YuvPlayer::pause() {
    if (_state != Playing) return;
    // 状态可能是:正在播放
    // 停止定时器
    stopTimer();
    // 改变状态
    setState(Paused);
}

void YuvPlayer::stop() {
    if (_state == Stopped) return;
    // 状态可能是:正在播放、暂停、正常完毕
    // 停止定时器
    stopTimer();
    // 释放图片
    freeCurrentImage();
    // 刷新
    update();
    // 改变状态
    setState(Stopped);
}

QFile 会记录上次读取文件的位置,当播放完毕时,要将读取指针回归到最初始的位置。作为一个播放器,需要时刻向外界发送一些消息,比如暂停或者继续播放等等需要通知外界,我们利用 Qt 信号和槽机制,在信号声明区下面定义了一个信号stateChange,当播放器状态发生改变时我们发送一个信号,外界与此信号关联的槽函数就会被调用:

void YuvPlayer::setState(State state) {
    if (state == _state) return;

    if (state == Stopped || state == Finished) {
        // 让文件读取指针回到文件首部
        _file->seek(0);
    }

    _state = state;
    emit stateChanged();
}
示例代码:

yuvplayer.h

#ifndef YUVPLAYER_H
#define YUVPLAYER_H
#include <QWidget>

#include <QFile>

extern "C" {
    #include <libavutil/avutil.h>
}

typedef struct {
    const char *filename;
    int width;
    int height;
    AVPixelFormat pixelFormat; 
    int fps; // 帧率
} Yuv;

class YuvPlayer : public QWidget
{
    Q_OBJECT
public:

    // 播放器的状态
    typedef enum {
        Stopped = 0,
        Playing,
        Paused,
        Finished
    } State;

    explicit YuvPlayer(QWidget *parent = nullptr);
    ~YuvPlayer();

    void play();
    void pause();
    void stop();
    bool isPlaying();
    State getState();
    void setYuv(Yuv &yuv);

private:

    QFile _file;
    int _timerId = 0; // 先写一个0,否则有可能是个垃圾值
    // 成员变量最好不要设置为引用,有可能引用外部的变量,如果引用的外部变量是一个临时变量(比如栈空间变量,函数销毁引用的内存就会被销毁),临时变量被销毁引用就会很危险,
    // Yuv &_yuv;
    Yuv _yuv;
    State _state = Stopped;
    QImage *_currentImage = nullptr;
    // 视频大小
    QRect _dstRect;
    // 一帧图片的大小
    int _imageSize = 0;
    int _imgSize;
    // 刷帧的时间间隔 
    int _interval;

    /** 改变状态 */
    void setState(State state);
    /** 释放QImage */
    void freeCurrentImage();
    /** 杀掉定时器 */
    void stopTimer();

    void timerEvent(QTimerEvent *event);
    void paintEvent(QPaintEvent *event);

signals:
    void stateChanged();
};

#endif // YUVPLAYER_H

yuvplayer.m

#include "yuvplayer.h"

#include <QDebug>

#include <QPainter>

#include <ffmpegutils.h>

extern "C" {
    #include <libavutil/imgutils.h>
}

YuvPlayer::YuvPlayer(QWidget *parent) : QWidget(parent)
{
    // 设置控件背景色
    setAttribute(Qt::WA_StyledBackground);
    setStyleSheet("background: black");
}

YuvPlayer::~YuvPlayer()
{
    _file->close();
    freeCurrentImage();
}

// 播放
void YuvPlayer::play()
{
    if (getState() == Playing) return;
    // 开启定时器
    _timerId = startTimer(_interval);
    setState(Playing);
}

// 暂停
void YuvPlayer::pause()
{
    if (getState() != Playing) return;
    stopTimer();
    setState(Paused);
}

// 停止
void YuvPlayer::stop()
{
    if (getState() == Stopped) return;
    // 状态可能是 正在播放 暂停 正常完毕
    stopTimer();
    // 清空屏幕
    freeCurrentImage();
    update();
    setState(Stopped);
}

// 设置播放器状态
void YuvPlayer::setState(State state)
{
    if (_state == state) return;
    // 停止/播放完成状态,需要从文件开始位置读取
    if (state == Stopped || state == Finished) {
        _file->seek(0);
    }
    _state = state;
    // 发送状态改变信号
    emit stateChanged();
}

void YuvPlayer::setYuv(Yuv &yuv)
{
    // 使用结构体,赋值相当于拷贝,引用的外部变量被销毁,当前结构体还是可以用的
    _yuv = yuv;

    // 打开文件
    _file = new QFile(yuv.filename);
    if (!_file->open(QFile::ReadOnly)) {
        qDebug() << "open file error:" << yuv.filename;
        return;
    }

    // 刷帧的时间间隔
    _interval = 1000 / _yuv.fps;

    // 计算一帧图片的大小
    _imageSize = av_image_get_buffer_size(yuv.pixelFormat, yuv.width, yuv.height, 1);

    // 组件的尺寸(播放器)
    int w = width();
    int h = height();

    // 原视频的宽度 _yuv.width
    int dstX = 0;
    int dstY = 0;
    int dstW = yuv.width;
    int dstH = yuv.height;

    // 计算目标尺寸
    if (dstW > w || dstH > h) {
        // (dstW / dstH) * h * dstH > (w / h) * h * dstH
        if ((dstW * h) > (w * dstH)) {
            dstH = dstH * w / dstW ;
            dstW = w;
        } else {
            dstW = dstW * h / dstH;
            dstH = h;
        }
    }

    // 居中视频
    dstX = (w - dstW) >> 1;
    dstY = (h - dstH) >> 1;

    qDebug() << "视频的Frame:" << dstX << dstY << dstW << dstH;
    _dstRect = QRect(dstX, dstY, dstW, dstH);
}

// 是否正在播放
bool YuvPlayer::isPlaying()
{
    return _state == Playing;
}

// 获取播放状态
YuvPlayer::State YuvPlayer::getState()
{
    return _state;
}

// 定时器回调函数,在此处播放 YUV
void YuvPlayer::timerEvent(QTimerEvent *event)
{

    char data[_imageSize];
    if (_file->read(data, _imageSize) > 0) {
        // 像素格式转换 yuv420p -> rgb24
        RawVideoFrame in = {
            data,
            _yuv.width, _yuv.height,
            _yuv.pixelFormat
        };
        RawVideoFrame out = {
            nullptr,
            _yuv.width >> 4 << 4, _yuv.height >> 4 << 4,
            AV_PIX_FMT_RGB24
        };
        FFmpegUtils::convretRawVideo(in, out);

        freeCurrentImage();
        _currentImage = new QImage((uchar *)out.pixels, out.width, out.height, QImage::Format_RGB888);

        // 刷新 调用 update 函数会调用 paintEvent
        update();
    } else {
        // 文件已经全部读取完毕
        stopTimer();
        setState(Finished);
    }
}

// 当组件需要重绘时会调用此函数
// 要绘制的内容在此函数中实现
void YuvPlayer::paintEvent(QPaintEvent *event)
{
    if (!_currentImage) return;
    // 将图片绘制到当前组件上
    QPainter(this).drawImage(_dstRect, *_currentImage);
}

// 释放图片资源
void YuvPlayer::freeCurrentImage()
{
    if (!_currentImage) return;
    free(_currentImage->bits());
    delete _currentImage;
    _currentImage = nullptr;
}

// 停止定时器
void YuvPlayer::stopTimer()
{
    if (_timerId == 0) return;
    killTimer(_timerId);
    _timerId = 0;
}

播放器函数调用:

#include "mainwindow.h"
#include "ui_mainwindow.h"

#include "yuvplayer.h"
#include <QDebug>

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow)
{
    ui->setupUi(this);

    // 创建播放器
    _player = new YuvPlayer(this);

    // 设置播放器的位置和尺寸
    int w = 500;
    int h = 400;
    int x = (width() - w) >> 1;
    int y = (height() - h) >> 1;
    _player->setGeometry(x, y, w, h);

    // 设置需要播放的文件
    Yuv yuv = {
        "/Users/mac/Downloads/pic/Dragon_Ball_640x480_yuv420p.yuv",
        640, 480,
        AV_PIX_FMT_YUV420P,
        30
    };
    _player->setYuv(yuv);

    // 监听播放器
    connect(_player, &YuvPlayer::stateChanged, this, &MainWindow::onPlayerStateChanged);
}

MainWindow::~MainWindow()
{
    delete _player;
    delete ui;
}

void MainWindow::on_playButton_clicked()
{
    if (_player->isPlaying()) { // 正在播放
        _player->pause();
        ui->playButton->setText("Play");
        qDebug() << "暂停";
    } else { // 暂停/停止播放
        _player->play();
        ui->playButton->setText("Pause");
        qDebug() << "播放";
    }
}

void MainWindow::on_stopButton_clicked()
{
    if (_player->isPlaying()) { // 正在播放
        _player->stop();
        ui->playButton->setText("Play");
        qDebug() << "停止";
    }
}

void MainWindow::onPlayerStateChanged()
{
    if (_player->getState() == YuvPlayer::Playing) { // 播放状态
        ui->playButton->setText("Pause");
    } else { // 非播放状态
        ui->playButton->setText("Play");
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容