最近在升级音视频的项目 Qt 版本,从 5.15.0 升级到 6.4.3(6.5 也一样),除了一些 QML 中删除了一些 Qt Quick Controls 1 的控件以外,最重要的就是自定义视频渲染的改进。

QAbstractVideoSurface 变为 QVideoSink

Qt5 中在 QML 上渲染自定义视频帧时需要在 C++ 层实现一个派生于 QObject 的子类,内部使用 QAbstractVideoSurface 来给 VideoOutput 提供数据,具体方法这里就不讨论了,可以参考我之前写的文章 Qt QML VideoOutput 显示自定义的 YUV420P 数据流 在 Qt6 中,QAbstractVideoSurface 被 QVideoSink 替代,提供了更简单的方式来投递一个 QVideoFrame,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class FrameProvider : public QObject {
Q_OBJECT
Q_PROPERTY(QVideoSink* videoSink READ videoSink WRITE setVideoSink NOTIFY videoSinkChanged)

public:
explicit FrameProvider(QObject* parent = nullptr);
~FrameProvider();

QVideoSink* videoSink() const { return m_videoSink; }
void setVideoSink(QVideoSink* videoSink);

signals:
void videoSinkChanged();

public slots:
void deliverFrame(const QVideoFrame& frame);

private:
QPointer<QVideoSink> m_videoSink;
};

类声明一个槽函数 deliverFrame 提供视频帧提供的模块绑定并投递帧数据。在 cpp 实现中只如果有新的视频流,则直接调用 m_videoSink 的 setVideoFrame 方法就可以了:

1
2
3
4
5
void FrameProvider::deliverFrame(const QVideoFrame& frame) {
if (!m_videoSink)
return;
m_videoSink->setVideoFrame(frame);
}

将 FrameProvider 按上面文章中的方法一样,注册给到 QML 端,与 VideoOutput 配合使用时也稍微有一些变动:

1
2
3
4
5
6
7
8
9
FrameProvider {
id: frameProvider
videoSink: videoContainer.videoSink
}
VideoOutput {
id: videoContainer
anchors.fill: parent
fillMode: VideoOutput.PreserveAspectFit
}

这样 VideoOutput 与新的 FrameProvider 配合使用就完成了,接下来我们说一下 QVideoFrame 的变动:

QVideoFrame 数据拷贝方式的变动

在 Qt5 中,如拷贝 YUV 数据到 QVideoFrame 的方式非常暴力,通过 videoFrame.bits() 拿到地址算好位置无脑拷贝就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int frameSize = static_cast<int>(frame.width * frame.height * frame.count / 2);
QVideoFrame videoFrame(frameSize, QSize(static_cast<int>(rotationWidth), static_cast<int>(rotationHeight)), static_cast<int>(rotationWidth),
QVideoFrame::Format_YUV420P);

if (videoFrame.map(QAbstractVideoBuffer::WriteOnly)) {
auto src = reinterpret_cast<uint8_t*>(frame.data);
auto dest = reinterpret_cast<uint8_t*>(videoFrame.bits());

libyuv::I420Rotate(src + frame.offset[0], static_cast<int>(frame.stride[0]),
src + frame.offset[1], static_cast<int>(frame.stride[1]),
src + frame.offset[2], static_cast<int>(frame.stride[2]),
dest, static_cast<int>(rotationWidth),
dest + rotationWidth * rotationHeight, rotationWidth / 2,
dest + rotationWidth * rotationHeight + rotationWidth * rotationHeight / 4, rotationWidth / 2,
static_cast<int>(frame.width), static_cast<int>(frame.height), rotate_mode);

videoFrame.setStartTime(0);
videoFrame.unmap();

QSize size = QSize(static_cast<int>(rotationWidth), static_cast<int>(rotationHeight));
emit VideoManager::m_videoFrameDelegate->receivedVideoFrame(QString::fromStdString(accountId), videoFrame, size, bSub);
}

但 Qt6 中出现了较大的变动,首先 bits 函数要求传递目标数据的 plane,比如 Y plane 为 0,U 和 V 依次为 1 和 2。这看起来跟 Qt5 中没有什么太大区别,但如果你按 bits(0)、bits(1)、bits(1) 的地址按原来的逻辑拷贝时会发现部分分辨率的图像会渲染错乱,这基本上是因为原始的 YUV 数据宽度并不是 16 的倍数。而 QVideoFrame 一旦调用了 map 函数,则每个 plane 的 stride(在 Qt 中称为 bytesPerLine) 将会是 16 的倍数,如果你按原始数据宽度拷贝,就会导致画面错乱。 正确的做法是通过 QVideoFrame 提供的 bytesPerLine() 方法算出具体每个 plane 的宽度,按需拷贝,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
QVideoFrameFormat format(QSize(rotationWidth, rotationHeight), QVideoFrameFormat::Format_YUV420P);
format.setViewport(QRect(0, 0, rotationWidth, rotationHeight));
QVideoFrame videoFrame(format);
if (videoFrame.map(QVideoFrame::WriteOnly)) {
auto src = reinterpret_cast<uint8_t*>(frame.data);
// If the aspect ratio of the original data is not a multiple of 16,
// when mappedBytes(n) is called after frame mapping, the returned size will be expanded to the nearest multiple of 16.
// When copying the data, bytesPerLine(n) should be used to get the actual stride that needs to be copied.
libyuv::I420Rotate(src + frame.offset[0], frame.stride[0],
src + frame.offset[1], frame.stride[1],
src + frame.offset[2], frame.stride[2],
videoFrame.bits(0), videoFrame.bytesPerLine(0),
videoFrame.bits(1), videoFrame.bytesPerLine(1),
videoFrame.bits(2), videoFrame.bytesPerLine(2),
frame.width, frame.height, rotate_mode);
videoFrame.setStartTime(0);
videoFrame.unmap();
QSize size = QSize(static_cast<int>(rotationWidth), static_cast<int>(rotationHeight));
emit VideoManager::m_videoFrameDelegate->receivedVideoFrame(QString::fromStdString(accountId), videoFrame, size, bSub);
}

其中 frame.data 是 YUV 的原始数据。通过改动后的 QVideoFrame API 我们可以看到,Qt 对视频处理数据的要求更加严谨了,虽然处理问题过程中浪费了比较多的时间,但总算总结下了一些宝贵的经验。

2023-05-30 更新

以上拷贝方式当使用 Qt 6.x 版本默认的渲染引擎(OpenGL)时一些奇葩的分辨率会出现花屏的问题。修改 Qt 的渲染引擎为各平台特有引擎后得以解决:

1
2
3
4
5
6
7
8
9
10
int main(int argc, char* argv[]) {
QGuiApplication app(argc, argv);

#if defined(Q_OS_MACX)
QQuickWindow::setGraphicsApi(QSGRendererInterface::Metal);
#else
QQuickWindow::setGraphicsApi(QSGRendererInterface::Direct3D11);
#endif
..... other code
}