summaryrefslogtreecommitdiffstats
path: root/pica-client
diff options
context:
space:
mode:
authorAnton <anton@picapica.im>2026-10-03 20:33:04 +0400
committerAnton <anton@picapica.im>2026-10-03 20:33:04 +0400
commit835b124bd871afecd88c1e5e9105e3af4d4c5b27 (patch)
tree42ee00cb6f37472fe36c802530cdc9a288ba87bd /pica-client
parent76225a538e1e960eda9e31a34846f4bc62adef87 (diff)
downloadpica-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.cpp9
-rw-r--r--pica-client/audiovideocallcontroller.h4
-rw-r--r--pica-client/callwindow.cpp151
-rw-r--r--pica-client/callwindow.h42
-rw-r--r--pica-client/dialogs/settingsdialog.cpp104
-rw-r--r--pica-client/dialogs/settingsdialog.h32
-rw-r--r--pica-client/videodevice.cpp111
-rw-r--r--pica-client/videodevice.h42
-rw-r--r--pica-client/videoscaler.cpp195
-rw-r--r--pica-client/videoscaler.h128
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