diff options
| author | Anton <anton@picapica.im> | 2026-10-03 20:33:04 +0400 |
|---|---|---|
| committer | Anton <anton@picapica.im> | 2026-10-03 20:33:04 +0400 |
| commit | 835b124bd871afecd88c1e5e9105e3af4d4c5b27 (patch) | |
| tree | 42ee00cb6f37472fe36c802530cdc9a288ba87bd /pica-client | |
| parent | 76225a538e1e960eda9e31a34846f4bc62adef87 (diff) | |
| download | pica-pica-835b124bd871afecd88c1e5e9105e3af4d4c5b27.tar.gz pica-pica-835b124bd871afecd88c1e5e9105e3af4d4c5b27.zip | |
pica-client: callwindow layout, video resize
Diffstat (limited to 'pica-client')
| -rw-r--r-- | pica-client/audiovideocallcontroller.cpp | 9 | ||||
| -rw-r--r-- | pica-client/audiovideocallcontroller.h | 4 | ||||
| -rw-r--r-- | pica-client/callwindow.cpp | 151 | ||||
| -rw-r--r-- | pica-client/callwindow.h | 42 | ||||
| -rw-r--r-- | pica-client/dialogs/settingsdialog.cpp | 104 | ||||
| -rw-r--r-- | pica-client/dialogs/settingsdialog.h | 32 | ||||
| -rw-r--r-- | pica-client/videodevice.cpp | 111 | ||||
| -rw-r--r-- | pica-client/videodevice.h | 42 | ||||
| -rw-r--r-- | pica-client/videoscaler.cpp | 195 | ||||
| -rw-r--r-- | pica-client/videoscaler.h | 128 |
10 files changed, 769 insertions, 49 deletions
diff --git a/pica-client/audiovideocallcontroller.cpp b/pica-client/audiovideocallcontroller.cpp index 9b4ae4d..a6da3f5 100644 --- a/pica-client/audiovideocallcontroller.cpp +++ b/pica-client/audiovideocallcontroller.cpp @@ -374,6 +374,7 @@ void AudioVideoCallController::start_call(QByteArray peer_id) /* decoded frames arrive from remotevideo's thread, so this is a queued connection; it dies with the window, which is deleted on close */ connect(remotevideo, SIGNAL(frameReady(QImage)), callwindow, SLOT(showRemoteFrame(QImage))); + connect(callwindow, SIGNAL(video_area_changed(QSize)), this, SLOT(video_area_changed(QSize))); #ifdef HAVE_VAAPI connect(remotevideo, SIGNAL(hwFrameReady(AVFramePtr)), callwindow, SLOT(showRemoteHwFrame(AVFramePtr))); connect(callwindow, SIGNAL(video_rendering_failed()), this, SLOT(video_rendering_failed())); @@ -397,6 +398,7 @@ void AudioVideoCallController::call_from(QByteArray peer_id) connect(callwindow, SIGNAL(callwindow_closed(CallWindow*)), this, SLOT(callwindow_closed(CallWindow*))); /* see the same connections in start_call() */ connect(remotevideo, SIGNAL(frameReady(QImage)), callwindow, SLOT(showRemoteFrame(QImage))); + connect(callwindow, SIGNAL(video_area_changed(QSize)), this, SLOT(video_area_changed(QSize))); #ifdef HAVE_VAAPI connect(remotevideo, SIGNAL(hwFrameReady(AVFramePtr)), callwindow, SLOT(showRemoteHwFrame(AVFramePtr))); connect(callwindow, SIGNAL(video_rendering_failed()), this, SLOT(video_rendering_failed())); @@ -595,6 +597,13 @@ void AudioVideoCallController::incoming_video_packet(QByteArray peer_id, quint16 remotevideo->enqueueFrame(frame); } +void AudioVideoCallController::video_area_changed(QSize area) +{ + // Direct call rather than invokeMethod() - same reason as in + // incoming_video_packet(): remotevideo's thread is blocked inside Play(). + remotevideo->setDisplaySize(area); +} + #ifdef HAVE_VAAPI void AudioVideoCallController::video_rendering_failed() { diff --git a/pica-client/audiovideocallcontroller.h b/pica-client/audiovideocallcontroller.h index 0743601..079ebc8 100644 --- a/pica-client/audiovideocallcontroller.h +++ b/pica-client/audiovideocallcontroller.h @@ -123,6 +123,10 @@ private slots: void incoming_video_packet(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data); void media_transport_changed(QByteArray peer_id, bool direct_udp, QString ciphersuitename, quint32 max_payload); + + // Passes the window's video area on to the decoder, which produces frames + // at that size rather than leaving the window to resize each one. + void video_area_changed(QSize area); #ifdef HAVE_VAAPI void video_rendering_failed(); #endif diff --git a/pica-client/callwindow.cpp b/pica-client/callwindow.cpp index 81959e3..0b7d309 100644 --- a/pica-client/callwindow.cpp +++ b/pica-client/callwindow.cpp @@ -19,10 +19,19 @@ #include <QHBoxLayout> #include <QVBoxLayout> #include <QCloseEvent> +#include <QEvent> +#include <QGuiApplication> +#include <QScreen> #include <QDebug> +// Smallest the video area is allowed to become. Low enough that the window +// stays freely resizable - the video area is the part that gives, so a floor +// here is a floor under the whole window - while still leaving something +// recognizable on screen. +static const QSize kMinVideoSize(160, 120); + CallWindow::CallWindow(QByteArray peer_id, bool incoming) - : m_peer_id(peer_id), is_incoming(incoming) + : m_peer_id(peer_id), is_incoming(incoming), videoAreaOpened(false) { setAttribute(Qt::WA_DeleteOnClose); @@ -40,30 +49,55 @@ CallWindow::CallWindow(QByteArray peer_id, bool incoming) lbTransport->hide(); lbVideo = new QLabel(this); lbVideo->setAlignment(Qt::AlignCenter); - lbVideo->setMinimumSize(320, 240); + // A label holding a pixmap reports that pixmap's size as both its + // preferred and its minimum size, which would make the video area's size + // depend on the picture currently in it: the window could not be made + // smaller than the last frame drawn, and the layout would be recomputed on + // every frame. Ignored breaks that - the label is given whatever space the + // stretch factors below say and never asks for any. + lbVideo->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); + // Small enough to let the window be resized freely; the size the video + // area actually opens at is set once by openVideoArea(). + lbVideo->setMinimumSize(kMinVideoSize); lbVideo->hide(); #ifdef HAVE_VAAPI videoGpu = VaapiRenderWidget::create(this); - videoGpu->widget()->setMinimumSize(320, 240); + videoGpu->widget()->setMinimumSize(kMinVideoSize); videoGpu->widget()->hide(); connect(videoGpu->widget(), SIGNAL(renderingFailed(QString)), this, SLOT(gpu_rendering_failed(QString))); #endif callTimer = new QTimer(this); callElapsedSeconds = 0; - lv->addWidget(lbTimer); - lv->addWidget(lbTransport); - lv->addWidget(lbVideo); + // Only the video gets a stretch factor, so it takes all the height the + // rows around it do not need. Left at the default of zero, every item + // whose size policy allows it to grow gets an equal share of the spare + // height instead - which for two QLabels and the video meant the video + // got about a third of it and the timer and the transport line sat in + // tall empty bands. + // + // Only one of the two video widgets is ever visible and a hidden widget is + // given no space, so both can be stretched. + lv->addWidget(lbTimer, 0); + lv->addWidget(lbTransport, 0); + lv->addWidget(lbVideo, 1); #ifdef HAVE_VAAPI - lv->addWidget(videoGpu->widget()); + lv->addWidget(videoGpu->widget(), 1); #endif - lh->addWidget(pbAccept, Qt::AlignLeft); - lh->addWidget(pbCall, Qt::AlignLeft); - lh->addWidget(pbHang, Qt::AlignRight); - lv->addLayout(lh); + // Note that the second argument of addWidget() is a stretch factor and not + // an alignment - passing Qt::AlignLeft here, as this did, compiles, since + // Qt::Alignment converts to int, and quietly means a stretch of 1. A + // spacer does what the alignment was meant to. + lh->addWidget(pbAccept); + lh->addWidget(pbCall); + lh->addStretch(1); + lh->addWidget(pbHang); + lv->addLayout(lh, 0); setLayout(lv); + lbVideo->installEventFilter(this); + connect(pbCall, SIGNAL(clicked()), this, SLOT(call())); connect(pbHang, SIGNAL(clicked()), this, SLOT(hang())); connect(pbAccept, SIGNAL(clicked()), this, SLOT(accept())); @@ -112,12 +146,78 @@ void CallWindow::showRemoteFrame(QImage frame) if (frame.isNull()) return; + lastRemoteFrame = frame; + if (lbVideo->isHidden()) + { + openVideoArea(frame.size()); lbVideo->show(); + reportVideoArea(); + } + + updateRemoteFrame(); +} + +void CallWindow::updateRemoteFrame() +{ + if (lastRemoteFrame.isNull() || lbVideo->isHidden()) + return; + + // Normally a no-op: the decoder was told the size of this area and the + // frame already arrived at it, and scale() returns the frame untouched + // when there is nothing to do. It earns its place across a resize, when + // the frames already decoded at the old size have still to be drawn at + // the new one. + QImage scaled = videoScaler.scale(lastRemoteFrame, lbVideo->size()); + + if (scaled.isNull()) + return; + + lbVideo->setPixmap(QPixmap::fromImage(scaled)); +} + +void CallWindow::reportVideoArea() +{ + // While the label is hidden there is nothing to size frames for, and its + // geometry is meaningless anyway. isHidden() rather than isVisible(): what + // matters is whether this label is the renderer in use, not whether the + // window happens to be on screen right now. When the GPU widget has the + // video area it scales frames itself, on the GPU, and wants them at their + // own resolution - see showRemoteHwFrame(). + if (lbVideo->isHidden()) + return; - lbVideo->setPixmap(QPixmap::fromImage(frame).scaled(lbVideo->size(), - Qt::KeepAspectRatio, - Qt::SmoothTransformation)); + emit video_area_changed(lbVideo->size()); +} + +void CallWindow::openVideoArea(QSize frame) +{ + if (videoAreaOpened || frame.isEmpty()) + return; + + videoAreaOpened = true; + + // The window has been sized for a call with no video in it, so the video + // area is about to appear into whatever the layout can spare - which is + // its minimum. Grow the window by what the video needs on top of that. + // + // Only the first frame does this. After it the window's size is the user's + // business, and a later frame must not undo a deliberate resize. + QSize wanted = frame; + QScreen *screen = QGuiApplication::primaryScreen(); + + if (screen) + { + // Leave the rows above and below the video the height they have + // now - a peer sending 1080p to a 1080 high screen should not open + // a window taller than the desktop. + QSize available = screen->availableGeometry().size() - QSize(0, height()); + + wanted = VideoScaler::fitted(frame, available.expandedTo(kMinVideoSize)); + } + + resize(qMax(width(), wanted.width()), + height() + qMax(0, wanted.height() - lbVideo->height())); } #ifdef HAVE_VAAPI @@ -138,8 +238,14 @@ void CallWindow::showRemoteHwFrame(AVFramePtr frame) // show them - the GL widget takes over the video area instead. if (videoGpu->widget()->isHidden()) { + openVideoArea(QSize(frame->width, frame->height)); lbVideo->hide(); videoGpu->widget()->show(); + + // The widget scales the surface itself, on the GPU, so the decoder + // has no reason to produce frames at any particular size - and it + // is not producing these ones at all. + emit video_area_changed(QSize()); } videoGpu->setFrame(frame); @@ -155,6 +261,12 @@ void CallWindow::gpu_rendering_failed(QString message) videoGpu->widget()->hide(); lbVideo->show(); + // Frames are about to start arriving in system memory, so the decoder + // needs the size to produce them at. Sent explicitly rather than left to + // the label's resize, which does not fire if showing it did not change its + // geometry. + reportVideoArea(); + emit video_rendering_failed(); } #endif @@ -198,6 +310,17 @@ void CallWindow::accept() emit accept_call_pressed(); } +bool CallWindow::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == lbVideo && event->type() == QEvent::Resize) + { + reportVideoArea(); + updateRemoteFrame(); + } + + return QWidget::eventFilter(watched, event); +} + void CallWindow::closeEvent(QCloseEvent *e) { emit callwindow_closed(this); diff --git a/pica-client/callwindow.h b/pica-client/callwindow.h index fbdcf60..548b391 100644 --- a/pica-client/callwindow.h +++ b/pica-client/callwindow.h @@ -23,10 +23,14 @@ #include <QLabel> #include <QTimer> #include <QImage> +#include <QSize> +#include "videoscaler.h" #ifdef HAVE_VAAPI #include "vaapi.h" #endif +class QEvent; + class CallWindow : public QWidget { Q_OBJECT @@ -37,6 +41,12 @@ signals: void accept_call_pressed(); void hang_call_pressed(); void callwindow_closed(CallWindow *sender_window); + + // The area the remote video has to be drawn in, whenever it changes. The + // decoder produces frames at this size directly rather than the window + // resizing them after the fact - see VideoScaler for why that is worth + // routing across threads for. + void video_area_changed(QSize area); #ifdef HAVE_VAAPI // Drawing frames straight out of GPU memory did not work on this system. // The call controller answers by having them decoded into system memory @@ -65,8 +75,40 @@ private: QTimer *callTimer; int callElapsedSeconds; + // The last frame received, kept so that the picture can follow a resize + // instead of staying as it was until the next frame arrives. + QImage lastRemoteFrame; + + // Resizes a frame that does not already match the video area. Normally + // does nothing: the decoder is told what size to produce, and only the + // frames in flight across a resize arrive at another size. + VideoScaler videoScaler; + + // Whether the video area has been given room yet, see showRemoteFrame(). + bool videoAreaOpened; + + // Draws lastRemoteFrame at the size the video area is now. + void updateRemoteFrame(); + + // Tells the decoder what size to produce frames at, if that has changed. + void reportVideoArea(); + + // Enlarges the window to fit the first frame's own resolution, once. An + // audio call keeps the compact window it had before, so when video does + // turn up there is no room for it; without this it would appear in + // whatever few pixels the layout could spare. + void openVideoArea(QSize frame); + void closeEvent(QCloseEvent *e); + // Watches lbVideo for its own resizes, which is where the video area's + // size is really decided. Doing this from the window's resizeEvent() + // instead would read the label's geometry before the layout has had a + // chance to update it, and would miss the resizes the layout causes by + // itself - lbTransport appearing shortens the video area without the + // window changing size at all. + bool eventFilter(QObject *watched, QEvent *event) override; + public slots: void call_started(); void call_ended(); diff --git a/pica-client/dialogs/settingsdialog.cpp b/pica-client/dialogs/settingsdialog.cpp index 15c7c96..8205c35 100644 --- a/pica-client/dialogs/settingsdialog.cpp +++ b/pica-client/dialogs/settingsdialog.cpp @@ -34,6 +34,7 @@ #include <QNetworkInterface> #include <QHostAddress> #include <QSize> +#include <QEvent> #include <QTabWidget> #include <QTimer> @@ -93,6 +94,13 @@ SettingsDialog::SettingsDialog(QWidget *parent) : videoRes = nullptr; videoFps = nullptr; + // Same problem, one step further along: videoPreview is watched for its own + // resizes and answers them by telling testDecoder what size to decode to, + // but the preview is built - and the layout that can resize it applied - + // before the decoder exists. + testDecoder = nullptr; + videoTestRunning = false; + QVBoxLayout *settingsLayout = new QVBoxLayout(); QTabWidget *tabW = new QTabWidget(this); @@ -378,12 +386,21 @@ SettingsDialog::SettingsDialog(QWidget *parent) : videoPreview->setAlignment(Qt::AlignCenter); videoPreview->setMinimumSize(320, 240); videoPreview->setFrameShape(QFrame::StyledPanel); + // A label holding a pixmap reports that pixmap's size as its minimum, which + // would stop the dialog being made smaller than the last frame drawn - and + // since each frame is drawn at the size of this label, the label could then + // never shrink either. Ignored breaks that: the label takes the space the + // layout gives it and asks for none. The status text it shows between tests + // has the 320x240 minimum above to sit in. + videoPreview->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); + videoPreview->installEventFilter(this); videoTestStatus = new QLabel(this); videoTestStatus->setAlignment(Qt::AlignCenter); #ifdef HAVE_VAAPI videoPreviewGpu = VaapiRenderWidget::create(this); + // Letterboxes the surface itself, so it only needs to be given the room. videoPreviewGpu->widget()->setMinimumSize(320, 240); videoPreviewGpu->widget()->hide(); connect(videoPreviewGpu->widget(), SIGNAL(renderingFailed(QString)), this, SLOT(videoTestRenderFailed(QString))); @@ -399,19 +416,21 @@ SettingsDialog::SettingsDialog(QWidget *parent) : videodevLayout->addWidget(cbVaapiRendering); #endif videodevLayout->addWidget(btVideoTest); - videodevLayout->addWidget(videoPreview); + // The preview takes the tab's spare height, where it used to go to a spacer + // at the bottom. Only one of the two preview widgets is ever visible and a + // hidden widget is given no space, so both can be stretched. + videodevLayout->addWidget(videoPreview, 1); #ifdef HAVE_VAAPI - videodevLayout->addWidget(videoPreviewGpu->widget()); + videodevLayout->addWidget(videoPreviewGpu->widget(), 1); #endif videodevLayout->addWidget(videoTestStatus); - videodevLayout->addStretch(1); videodevtab->setLayout(videodevLayout); // Capture+encode and decode ends of the test pipeline, each blocking its // own thread while running, exactly as they do during a call. They stay - // idle until the Test button is pressed. - videoTestRunning = false; + // idle until the Test button is pressed. videoTestRunning is already false + // from the top of this constructor, where the preview needed it. testVideoSeq = 0; testCam = new VideoDevice(); @@ -838,6 +857,10 @@ void SettingsDialog::toggleVideoTest() btVideoTest->setText(tr("Stop ⏹")); videoPreview->setText(tr("Starting...")); + // Before the decoder is started, so that its very first frame comes back + // at the size the preview wants rather than the camera's own. + reportVideoPreviewArea(); + bool vaapiEncoding = false; #ifdef HAVE_VAAPI vaapiEncoding = cbVaapiEncoding->isChecked(); @@ -918,11 +941,18 @@ void SettingsDialog::stopVideoTest() btVideoTest->setText(tr("Test 📷")); videoPreview->clear(); videoTestStatus->clear(); + lastTestFrame = QImage(); #ifdef HAVE_VAAPI videoPreviewGpu->clearFrame(); videoPreviewGpu->widget()->hide(); videoPreview->show(); #endif + + // Last, once the preview label is back in place: drops the display size so + // that a test started again does not begin against whatever the previous + // one left behind. videoTestRunning is already false, which is what makes + // this clear it rather than set it. + reportVideoPreviewArea(); } void SettingsDialog::videoTestFragment(QByteArray data, bool is_last_fragment) @@ -964,9 +994,56 @@ void SettingsDialog::videoTestFrame(QImage frame) ? videoTestFormat : tr("%1 - %2").arg(videoTestFormat, videoTestPathReport)); - videoPreview->setPixmap(QPixmap::fromImage(frame).scaled(videoPreview->size(), - Qt::KeepAspectRatio, - Qt::SmoothTransformation)); + lastTestFrame = frame; + updateVideoPreview(); +} + +void SettingsDialog::updateVideoPreview() +{ + // Only while the test is running: with it stopped the preview is showing + // status text instead, and putting the last frame back over that on a + // resize would be wrong. + if (!videoTestRunning || lastTestFrame.isNull() || videoPreview->isHidden()) + return; + + // Normally a no-op - testDecoder was told the size of this area and the + // frame arrived at it already. It earns its place across a resize, when + // frames converted at the old size have still to be drawn at the new one. + QImage scaled = videoTestScaler.scale(lastTestFrame, videoPreview->size()); + + if (scaled.isNull()) + return; + + videoPreview->setPixmap(QPixmap::fromImage(scaled)); +} + +void SettingsDialog::reportVideoPreviewArea() +{ + // The preview can be resized by the layout before the decoder has been + // built; see the note at the top of the constructor. + // + // isHidden() rather than isVisible(): what matters is whether this label is + // the renderer in use, not whether the dialog is on screen. When the GPU + // widget has the preview it scales frames itself, on the GPU, and wants + // them at their own resolution - see videoTestHwFrame(). + if (!testDecoder || videoPreview->isHidden()) + return; + + // Direct call rather than invokeMethod() - testDecoder's thread is blocked + // inside Play(), so a queued call would sit behind the very loop it is + // meant to reach. Same rule as the Close() calls in stopVideoTest(). + testDecoder->setDisplaySize(videoTestRunning ? videoPreview->size() : QSize()); +} + +bool SettingsDialog::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == videoPreview && event->type() == QEvent::Resize) + { + reportVideoPreviewArea(); + updateVideoPreview(); + } + + return QDialog::eventFilter(watched, event); } #ifdef HAVE_VAAPI @@ -989,6 +1066,11 @@ void SettingsDialog::videoTestHwFrame(AVFramePtr frame) { videoPreview->hide(); videoPreviewGpu->widget()->show(); + + // The widget scales the surface itself, on the GPU, so the decoder + // has no reason to produce frames at any particular size - and it is + // not producing these ones at all. + testDecoder->setDisplaySize(QSize()); } videoTestStatus->setText(videoTestPathReport.isEmpty() @@ -1010,6 +1092,12 @@ void SettingsDialog::videoTestRenderFailed(QString message) videoPreview->show(); videoPreview->setText(message); + // Frames are about to start arriving in system memory, so the decoder needs + // the size to produce them at. Sent explicitly rather than left to the + // label's resize, which does not fire if showing it did not change its + // geometry. + reportVideoPreviewArea(); + // Direct call, for the same reason as in videoTestFragment(). testDecoder->disableHardwareRendering(); } diff --git a/pica-client/dialogs/settingsdialog.h b/pica-client/dialogs/settingsdialog.h index 2167f5f..cd0e6b7 100644 --- a/pica-client/dialogs/settingsdialog.h +++ b/pica-client/dialogs/settingsdialog.h @@ -33,6 +33,7 @@ #include "../mediadevice.h" #include "../audiodevice.h" #include "../videodevice.h" +#include "../videoscaler.h" #include "../toneplayer/toneplayer.h" #ifdef HAVE_VAAPI #include "../vaapi.h" @@ -45,6 +46,14 @@ public: explicit SettingsDialog(QWidget *parent = 0); ~SettingsDialog(); + // Watches videoPreview for its own resizes, which is where the size the + // test's frames should be decoded to is decided. Doing this from the + // dialog's resizeEvent() instead would read the label's geometry before + // the layout has updated it, and would miss the resizes the layout causes + // on its own - the tab's other rows changing height moves the preview + // without the dialog changing size at all. + bool eventFilter(QObject *watched, QEvent *event) override; + signals: public slots: @@ -200,8 +209,31 @@ private: quint16 testVideoSeq; bool videoTestRunning; + // The last frame the preview drew, kept so that it can follow a resize of + // the dialog rather than staying as it was until the next frame. Cleared + // when the test stops, since the preview goes back to showing status text + // and must not have a stale picture put back over it. + QImage lastTestFrame; + + // Resizes a frame that does not already match the preview. Normally does + // nothing: testDecoder is told what size to produce - see + // reportVideoPreviewArea() - and only the frames in flight across a resize + // arrive at another size. Same arrangement as CallWindow's; the reasoning + // is in videoscaler.h. + VideoScaler videoTestScaler; + void stopVideoTest(); + // Draws lastTestFrame at the size the preview is now. + void updateVideoPreview(); + + // Tells testDecoder what size to decode frames to, so that the resize + // happens inside the colour conversion it already runs instead of in a + // second pass here. The preview is smaller than the camera's picture, so + // this is the case that gains most - nothing gets converted only to be + // thrown away. + void reportVideoPreviewArea(); + QPushButton *btOk; QPushButton *btCancel; diff --git a/pica-client/videodevice.cpp b/pica-client/videodevice.cpp index 3fb468c..f657706 100644 --- a/pica-client/videodevice.cpp +++ b/pica-client/videodevice.cpp @@ -16,6 +16,9 @@ */ #include "videodevice.h" #include "callsettings.h" +// For the shared decision about what size a frame is drawn at - the decode loop +// produces frames at exactly the size CallWindow expects, see VideoScaler. +#include "videoscaler.h" #include "../PICA_proto.h" #include "../PICA_media.h" @@ -587,10 +590,39 @@ VideoDevice::VideoDevice(QObject *parent) m_frameRate(kDefaultCaptureFrameRate), m_bitrate(kDefaultVideoBitrateKbps * 1000), m_preferCompressed(false), m_useVaapi(false), m_useVaapiRender(false), m_abort(0), - m_maxFragmentSize(kMaxFragmentSize), m_decoderStarted(0) + m_maxFragmentSize(kMaxFragmentSize), m_displaySize(0), m_decoderStarted(0) { } +void VideoDevice::setDisplaySize(QSize size) +{ + // Packed into one atomic so that the decode loop always reads a width and + // a height that belong together - see m_displaySize. Sizes beyond what + // half an int holds are not real displays, and clamping them is enough: + // the frame is fitted inside this area, so an area larger than the frame + // simply means no resizing. + int width = qBound(0, size.width(), 0xFFFF); + int height = qBound(0, size.height(), 0xFFFF); + + if (width == 0 || height == 0) + { + m_displaySize.storeRelaxed(0); + return; + } + + m_displaySize.storeRelaxed((width << 16) | height); +} + +QSize VideoDevice::displaySize() const +{ + int packed = m_displaySize.loadRelaxed(); + + if (packed == 0) + return QSize(); + + return QSize((packed >> 16) & 0xFFFF, packed & 0xFFFF); +} + void VideoDevice::setMaxFragmentSize(int size) { if (size <= 0 || size > kMaxFragmentSize) @@ -1356,8 +1388,11 @@ void VideoDevice::Play() SwsContext *sws = nullptr; int sws_width = 0, sws_height = 0; AVPixelFormat sws_fmt = AV_PIX_FMT_NONE; + // Size the scaler is producing, which is the size of the area the window + // reported through setDisplaySize() with the frame fitted into it - not + // the size of the decoded frame. + int sws_dst_width = 0, sws_dst_height = 0; AVFrame *dec_frame = av_frame_alloc(); - AVFrame *rgb_frame = av_frame_alloc(); bool loggedDecodeError = false; bool loggedScaleError = false; bool loggedTransferError = false; @@ -1470,33 +1505,54 @@ void VideoDevice::Play() } #endif - // Rebuilt whenever the incoming picture format or size changes - - // including the first frame, when they first become known. - if (!sws || display_frame->width != sws_width || display_frame->height != sws_height || - (AVPixelFormat)display_frame->format != sws_fmt) + // The picture is converted straight to the size it is going to + // be drawn at, rather than to its own size for the window to + // resize afterwards. The resize costs almost nothing here, + // inside a colour conversion that has to run regardless, and + // nothing is converted only to be discarded when the window is + // smaller than the frame - between 1.1 and 4.6 times cheaper + // over the whole pipeline, see tests/test_videoscaler. + // + // Read once per frame: the window reports a new area on every + // resize, and mid-call resolution changes move the frame size + // underneath it as well. + QSize frameSize(display_frame->width, display_frame->height); + QSize displayArea = displaySize(); + QSize target = displayArea.isEmpty() ? frameSize + : VideoScaler::fitted(frameSize, displayArea); + + if (target.isEmpty()) + target = frameSize; + + // Rebuilt whenever the incoming picture format or size changes, + // or the window is resized - including on the first frame, when + // they first become known. + if (!sws || frameSize.width() != sws_width || frameSize.height() != sws_height || + (AVPixelFormat)display_frame->format != sws_fmt || + target.width() != sws_dst_width || target.height() != sws_dst_height) { if (sws) sws_freeContext(sws); - sws_width = display_frame->width; - sws_height = display_frame->height; + sws_width = frameSize.width(); + sws_height = frameSize.height(); sws_fmt = (AVPixelFormat)display_frame->format; + sws_dst_width = target.width(); + sws_dst_height = target.height(); + // AV_PIX_FMT_0RGB32 is laid out exactly as + // QImage::Format_RGB32 on either endianness, so the + // window gets a 32 bit image it can draw without a + // conversion of its own - which Format_RGB888 needed. sws = sws_getContext(sws_width, sws_height, sws_fmt, - sws_width, sws_height, AV_PIX_FMT_RGB24, + sws_dst_width, sws_dst_height, AV_PIX_FMT_0RGB32, SWS_BILINEAR, nullptr, nullptr, nullptr); - av_frame_unref(rgb_frame); - rgb_frame->format = AV_PIX_FMT_RGB24; - rgb_frame->width = sws_width; - rgb_frame->height = sws_height; - - if (!sws || av_frame_get_buffer(rgb_frame, 0) < 0) + if (!sws) { if (!loggedScaleError) { qWarning() << "Could not set up playback scaler"; loggedScaleError = true; } - if (sws) { sws_freeContext(sws); sws = nullptr; } #ifdef HAVE_VAAPI if (sw_frame) av_frame_free(&sw_frame); #endif @@ -1505,7 +1561,15 @@ void VideoDevice::Play() } } - if (av_frame_make_writable(rgb_frame) < 0) + // Allocated per frame rather than reused, which is what lets it + // go to the GUI thread without being copied: the queued signal + // takes a reference to this buffer and the image frees itself + // once the window is done with it. A single reused buffer would + // have to be deep copied instead, since the next frame would + // overwrite it while the window was still drawing. + QImage img(target, QImage::Format_RGB32); + + if (img.isNull()) { #ifdef HAVE_VAAPI if (sw_frame) av_frame_free(&sw_frame); @@ -1514,15 +1578,13 @@ void VideoDevice::Play() continue; } + uint8_t *dst_data[4] = { img.bits(), nullptr, nullptr, nullptr }; + int dst_linesize[4] = { (int)img.bytesPerLine(), 0, 0, 0 }; + sws_scale(sws, display_frame->data, display_frame->linesize, 0, display_frame->height, - rgb_frame->data, rgb_frame->linesize); + dst_data, dst_linesize); - // Deep copy: the QImage outlives this iteration (it travels to - // the GUI thread through a queued signal), while rgb_frame's - // buffer gets reused by the next scale. - QImage img(rgb_frame->data[0], sws_width, sws_height, - rgb_frame->linesize[0], QImage::Format_RGB888); - emit frameReady(img.copy()); + emit frameReady(img); #ifdef HAVE_VAAPI if (sw_frame) av_frame_free(&sw_frame); @@ -1532,7 +1594,6 @@ void VideoDevice::Play() } av_frame_free(&dec_frame); - av_frame_free(&rgb_frame); if (sws) sws_freeContext(sws); avcodec_free_context(&dec_ctx); } diff --git a/pica-client/videodevice.h b/pica-client/videodevice.h index fe218ec..f65895d 100644 --- a/pica-client/videodevice.h +++ b/pica-client/videodevice.h @@ -26,6 +26,7 @@ #include <QQueue> #include <QMutex> #include <QWaitCondition> +#include <QSize> #include <QAtomicInt> // Reassembles the fragments of one encoded video frame, as carried by 0x77 @@ -155,12 +156,29 @@ public: void setMaxFragmentSize(int size); // Stops keeping decoded frames in GPU memory, for when the renderer that - // was to draw them turns out not to be able to: decoding carries on, but + // was to draw them turns out to not be able to: decoding carries on, but // frames come back through system memory and are emitted by frameReady() // from then on. Safe to call from any thread, and called directly rather // than queued for the same reason enqueueFrame() is. void disableHardwareRendering(); + // The size of the area the decoded frames are going to be drawn in, so + // that Play() can produce them at that size directly. A decoded frame has + // to be resized to the window either way, and doing it inside the colour + // conversion the decode loop already runs costs far less than a second + // pass over the picture afterwards - between 1.1 and 4.6 times less over + // the whole pipeline, see tests/test_videoscaler. + // + // Frames keep their aspect ratio: this is the area to fit inside, not the + // size to produce. An empty size means the display size is not known and + // frames come back at their own resolution. + // + // Changes while a call is running, on every window resize, and is safe to + // call from any thread. Called directly rather than queued for the same + // reason enqueueFrame() is - the decode loop it affects is already + // running. + void setDisplaySize(QSize size); + public slots: // Blocking capture+encode loop: opens the configured camera, encodes // captured frames, and emits packetReady() for each encoded frame, split @@ -189,7 +207,16 @@ signals: // onto the 0x77 sequence number's last fragment marker bit. void packetReady(QByteArray data, bool isLastFragment); - // One decoded picture from the remote side, ready to be displayed. + // One decoded picture from the remote side, ready to be displayed, as a + // Format_RGB32 image - the format a widget draws without converting it + // first. + // + // Already scaled to fit the area last given to setDisplaySize(), keeping + // its aspect ratio, so the receiver can draw it as it stands. At the + // frame's own resolution while no display size has been set. A receiver + // still has to cope with the occasional frame at another size: the ones + // already decoded or in flight when the window is resized arrive at the + // size that was current when they were converted. void frameReady(QImage frame); #ifdef HAVE_VAAPI @@ -248,6 +275,13 @@ private: // capture loop is running. QAtomicInt m_maxFragmentSize; + // Likewise set from another thread, by setDisplaySize(), while the decode + // loop is running. Both dimensions live in the one atomic - width in the + // upper half, height in the lower - so that the decode loop cannot read a + // new width against an old height and scale one frame to a wrong aspect + // ratio. Zero means not known yet. + QAtomicInt m_displaySize; + QMutex m_queueMutex; QWaitCondition m_queueCond; QQueue<QByteArray> m_frameQueue; @@ -272,6 +306,10 @@ private: // packets, hence atomic. QAtomicInt m_decoderStarted; + // What setDisplaySize() was last given, unpacked; an empty size if it has + // not been called. Read by the decode loop, once per frame. + QSize displaySize() const; + // Sends one encoded frame out as one or more protocol sized fragments. void emitFragments(const unsigned char *data, int size); diff --git a/pica-client/videoscaler.cpp b/pica-client/videoscaler.cpp new file mode 100644 index 0000000..676a78f --- /dev/null +++ b/pica-client/videoscaler.cpp @@ -0,0 +1,195 @@ +/* + (c) Copyright 2012 - 2026 Anton Sviridenko + https://picapica.im + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +#include "videoscaler.h" + +#include <QDebug> + +// FFmpeg headers +extern "C" { +#include <libavutil/pixfmt.h> +#include <libswscale/swscale.h> +} + +// The QImage formats libswscale can be pointed at directly. Anything missing +// here is not an error - scale() just uses Qt for those frames. Premultiplied +// formats are deliberately absent: libswscale has no equivalent, and feeding +// it premultiplied pixels as if they were plain ones would wash the colours +// out. +static AVPixelFormat av_pixel_format(QImage::Format format) +{ + switch (format) + { + case QImage::Format_RGB888: + return AV_PIX_FMT_RGB24; + case QImage::Format_RGB32: + return AV_PIX_FMT_0RGB32; + case QImage::Format_ARGB32: + return AV_PIX_FMT_RGB32; + case QImage::Format_RGBX8888: + return AV_PIX_FMT_RGB0; + case QImage::Format_RGBA8888: + return AV_PIX_FMT_RGBA; + case QImage::Format_Grayscale8: + return AV_PIX_FMT_GRAY8; + default: + return AV_PIX_FMT_NONE; + } +} + +VideoScaler::VideoScaler(Backend backend) + : m_backend(backend), m_lastUsed(backend), m_sws(nullptr), + m_srcWidth(0), m_srcHeight(0), m_srcFormat(AV_PIX_FMT_NONE), + m_dstWidth(0), m_dstHeight(0), m_loggedFailure(false) +{ +} + +VideoScaler::~VideoScaler() +{ + if (m_sws) + sws_freeContext(m_sws); +} + +QSize VideoScaler::fitted(QSize frame, QSize area) +{ + if (frame.isEmpty() || area.isEmpty()) + return QSize(); + + QSize result = frame; + result.scale(area, Qt::KeepAspectRatio); + + // An odd width costs libswscale a factor of four and a half, because it + // drops off its SIMD path and converts the picture a pixel at a time - + // measured, and far and away the largest effect of any of the geometry + // here: destination stride alignment and an odd height both turned out not + // to matter at all. Window widths being whatever the user drags them to, + // half of them would land on the slow path. + // + // So the width is rounded down to even. That loses at most one pixel of + // picture and bends the aspect ratio by well under a tenth of a percent, + // which is less than the rounding above has already done. + // + // It matters that this is the one place the target size is decided. + // VideoDevice::Play() sizes the frames it produces by it and CallWindow + // checks arriving frames against it - if the two disagreed by a pixel, the + // window would resize every frame instead of none of them. + result.setWidth(result.width() & ~1); + + // A picture far thinner than the area it goes into can round down to + // nothing, and neither scaler accepts a zero sized target. + if (result.width() < 2) + result.setWidth(2); + if (result.height() < 1) + result.setHeight(1); + + return result; +} + +QImage VideoScaler::scale(const QImage &frame, QSize area) +{ + QSize target = fitted(frame.size(), area); + + if (target.isEmpty()) + return QImage(); + + if (m_backend == Swscale) + { + QImage scaled = scaleWithSwscale(frame, target); + + if (!scaled.isNull()) + { + m_lastUsed = Swscale; + return scaled; + } + } + + m_lastUsed = QtScaled; + return scaleWithQt(frame, target); +} + +QImage VideoScaler::scaleWithSwscale(const QImage &frame, QSize target) +{ + AVPixelFormat srcFormat = av_pixel_format(frame.format()); + + if (srcFormat == AV_PIX_FMT_NONE) + return QImage(); + + if (!m_sws || frame.width() != m_srcWidth || frame.height() != m_srcHeight || + (int)srcFormat != m_srcFormat || target.width() != m_dstWidth || + target.height() != m_dstHeight) + { + if (m_sws) + { + sws_freeContext(m_sws); + m_sws = nullptr; + } + + // SWS_BILINEAR to match what Qt's SmoothTransformation looks + // like, and what the playback path already converts colours + // with in videodevice.cpp. + m_sws = sws_getContext(frame.width(), frame.height(), srcFormat, + target.width(), target.height(), AV_PIX_FMT_0RGB32, + SWS_BILINEAR, nullptr, nullptr, nullptr); + + if (!m_sws) + { + if (!m_loggedFailure) + { + qWarning() << "Could not set up video display scaler, using Qt instead"; + m_loggedFailure = true; + } + return QImage(); + } + + m_srcWidth = frame.width(); + m_srcHeight = frame.height(); + m_srcFormat = (int)srcFormat; + m_dstWidth = target.width(); + m_dstHeight = target.height(); + + // AV_PIX_FMT_0RGB32 is defined to be exactly this layout on + // either endianness, so libswscale can write into the image + // without a conversion in between - and Qt draws a 32 bit + // image without one either. + m_dst = QImage(target.width(), target.height(), QImage::Format_RGB32); + + if (m_dst.isNull()) + return QImage(); + } + + const uint8_t *srcData[4] = { frame.constBits(), nullptr, nullptr, nullptr }; + int srcStride[4] = { (int)frame.bytesPerLine(), 0, 0, 0 }; + + // bits() would deep copy if m_dst were shared with anyone, which is why + // what scale() hands back only points at this buffer instead of sharing + // it. + uint8_t *dstData[4] = { m_dst.bits(), nullptr, nullptr, nullptr }; + int dstStride[4] = { (int)m_dst.bytesPerLine(), 0, 0, 0 }; + + if (sws_scale(m_sws, srcData, srcStride, 0, frame.height(), dstData, dstStride) <= 0) + return QImage(); + + return QImage(m_dst.constBits(), m_dst.width(), m_dst.height(), + m_dst.bytesPerLine(), m_dst.format()); +} + +QImage VideoScaler::scaleWithQt(const QImage &frame, QSize target) +{ + // The aspect ratio is already accounted for in target, so scaling to it + // exactly avoids rounding the size a second time. + return frame.scaled(target, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); +} diff --git a/pica-client/videoscaler.h b/pica-client/videoscaler.h new file mode 100644 index 0000000..4d05d16 --- /dev/null +++ b/pica-client/videoscaler.h @@ -0,0 +1,128 @@ +/* + (c) Copyright 2012 - 2026 Anton Sviridenko + https://picapica.im + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ +#ifndef VIDEOSCALER_H +#define VIDEOSCALER_H + +#include <QImage> +#include <QSize> + +struct SwsContext; + +// Decides what size a video frame should be drawn at, and resizes one when +// something else has not already done it. +// +// Frames arrive at whatever resolution the sender negotiated, which has nothing +// to do with the size of the window they end up in, so each one has to be +// resized before it is drawn - at the frame rate of the call, for as long as it +// lasts. Where that resizing happens turned out to matter a great deal, and +// tests/test_videoscaler is the measurement it was settled by: +// +// - Resizing packed RGB in the window, which is what used to happen, is the +// worst of the options. libswscale is especially bad at it - it has no +// scaler for packed formats, so it unpacks to planar YUV, scales, and packs +// the result back, losing to QImage::scaled() by four to five times. +// +// - Resizing inside the colour conversion the decode loop already runs is +// between 1.1 and 4.6 times faster than the whole two pass pipeline, best +// when the picture is being shrunk, since nothing is converted only to be +// thrown away. libswscale is good at this one: the source is planar YUV, +// which is what it wants, and the resize rides along inside a pass over +// the picture that had to happen anyway. +// +// - Handing the result over without copying it saves another pass again. See +// VideoDevice::Play(), which allocates each frame's QImage rather than +// reusing one buffer, so the queued signal that carries it to the GUI +// thread only has a reference count to bump. +// +// So VideoDevice::Play() does the resizing, and this class is left with two +// jobs. fitted() is the single authority on what size to aim for, shared so +// that the decoder and the window cannot disagree about it. scale() is the +// window's fallback for the frames that arrive at the wrong size anyway - +// during a resize, there are always a few already in flight - and is a no-op +// when the size is already right, which is the steady state. +// +// The libswscale backend is kept for the benchmark to compare against, and to +// keep the conclusion above honest if any of these libraries change. It only +// understands a handful of QImage formats; anything else, and any failure to +// build a scaler, falls back to Qt rather than losing the picture. +class VideoScaler +{ +public: + // Not named after Qt itself - a value called Qt would shadow the + // namespace of the same name inside this class. + enum Backend + { + QtScaled, // QImage::scaled(), the default - measurably the faster one here + Swscale // libswscale, for comparison; falls back to the above when it cannot help + }; + + explicit VideoScaler(Backend backend = QtScaled); + ~VideoScaler(); + + // The size `frame` becomes when fitted into `area` without cropping or + // changing its aspect ratio - never larger than `area`, and never + // smaller than 2x1 so long as both arguments are valid. An empty size + // if either argument is empty. + // + // The width is always even, which is worth a factor of four and a half + // downstream; the reason is with the implementation. Every part of the + // call that needs to know the display size of a frame goes through here, + // so that they all arrive at the same answer. + static QSize fitted(QSize frame, QSize area); + + // Returns `frame` resized to fitted(frame.size(), area), ready to be + // handed to QPixmap::fromImage(). A null image means nothing could be + // produced and the previous picture should be left alone. + // + // The returned image may reference a buffer this object reuses, so it + // is only valid until the next call to scale() - copy it if it needs to + // outlive that. + QImage scale(const QImage &frame, QSize area); + + // Which implementation the last scale() actually used. Not the same as + // the backend asked for: Swscale falls back to QtScaled when it meets a + // frame format it has no mapping for. + Backend lastBackendUsed() const { return m_lastUsed; } + +private: + QImage scaleWithSwscale(const QImage &frame, QSize target); + static QImage scaleWithQt(const QImage &frame, QSize target); + + Backend m_backend; + Backend m_lastUsed; + + // Building a scaler means computing filter tables, so the context is + // kept across frames and only rebuilt when the geometry or the format + // it was built for changes - in practice on the first frame and on a + // window resize. + SwsContext *m_sws; + int m_srcWidth; + int m_srcHeight; + int m_srcFormat; // AVPixelFormat, kept as int to keep ffmpeg out of this header + int m_dstWidth; + int m_dstHeight; + + // Destination of the libswscale path, reused frame to frame. Qt images + // are copy on write, and writing through the raw pointer libswscale is + // given would go behind that, so scale() makes sure this is not shared + // before handing the pointer over. + QImage m_dst; + + bool m_loggedFailure; +}; + +#endif |
