summaryrefslogtreecommitdiffstats
path: root/pica-client
diff options
context:
space:
mode:
authorAnton <anton@picapica.im>2026-08-13 01:28:32 +0400
committerAnton <anton@picapica.im>2026-08-13 01:28:32 +0400
commitd10deee6b3143fffd0d4d55b87bf6167c34dd459 (patch)
treed10fc58c4d8382b37046062830010d305094ee69 /pica-client
parent07697978227d9d938a2ce2b72010d3b9cebea041 (diff)
downloadpica-pica-d10deee6b3143fffd0d4d55b87bf6167c34dd459.tar.gz
pica-pica-d10deee6b3143fffd0d4d55b87bf6167c34dd459.zip
pica-client: video call
Diffstat (limited to 'pica-client')
-rw-r--r--pica-client/audiovideocallcontroller.cpp142
-rw-r--r--pica-client/audiovideocallcontroller.h27
-rw-r--r--pica-client/callwindow.cpp18
-rw-r--r--pica-client/callwindow.h5
-rw-r--r--pica-client/dialogs/settingsdialog.cpp128
-rw-r--r--pica-client/dialogs/settingsdialog.h25
-rw-r--r--pica-client/skynet.cpp44
-rw-r--r--pica-client/skynet.h12
-rw-r--r--pica-client/videodevice.cpp485
-rw-r--r--pica-client/videodevice.h127
10 files changed, 1004 insertions, 9 deletions
diff --git a/pica-client/audiovideocallcontroller.cpp b/pica-client/audiovideocallcontroller.cpp
index 964b15c..0df0a79 100644
--- a/pica-client/audiovideocallcontroller.cpp
+++ b/pica-client/audiovideocallcontroller.cpp
@@ -22,8 +22,16 @@
#include <QDebug>
#include <QMetaObject>
+// Capture/encode resolution announced to the peer in the 0x75 message. Fixed
+// for now - a resolution setting is a planned follow-up, see VideoDevice for
+// the rest of the encoding parameters.
+static const quint16 kVideoWidth = 640;
+static const quint16 kVideoHeight = 480;
+
+
AudioVideoCallController::AudioVideoCallController(QObject *parent)
- : QObject(parent), callwindow(0), ringdevice(0), is_active(false), m_audioSeq(0)
+ : QObject(parent), callwindow(0), ringdevice(0), is_active(false), m_audioSeq(0),
+ m_videoSeq(0), m_videoFrameTimestamp(0), m_videoFrameStarted(false)
{
connect(skynet, SIGNAL(IncomingCall(QByteArray)), this, SLOT(call_from(QByteArray)));
connect(skynet, SIGNAL(CallFailed(QByteArray,QString)), this, SLOT(call_failed(QByteArray,QString)));
@@ -34,6 +42,9 @@ AudioVideoCallController::AudioVideoCallController(QObject *parent)
connect(skynet, SIGNAL(IncomingAudioParams(QByteArray,QString,quint16)), this, SLOT(incoming_audio_params(QByteArray,QString,quint16)));
connect(skynet, SIGNAL(IncomingAudioPacket(QByteArray,quint16,quint32,QByteArray)), this, SLOT(incoming_audio_packet(QByteArray,quint16,quint32,QByteArray)));
+ connect(skynet, SIGNAL(IncomingVideoParams(QByteArray,QString,quint16,quint16)), this, SLOT(incoming_video_params(QByteArray,QString,quint16,quint16)));
+ connect(skynet, SIGNAL(IncomingVideoPacket(QByteArray,quint16,quint32,QByteArray)), this, SLOT(incoming_video_packet(QByteArray,quint16,quint32,QByteArray)));
+
// Earpiece tones (ringback/busy/unreachable) and the incoming call bell
// are played by two TonePlayer instances, each living on its own
// QThread since TonePlayer::play() blocks the calling thread for the
@@ -58,12 +69,24 @@ AudioVideoCallController::AudioVideoCallController(QObject *parent)
output = new AudioDevice();
output->moveToThread(&output_thread);
output_thread.start();
+
+ // Camera capture+encode and remote video decode - same arrangement as
+ // the audio devices above.
+ cam = new VideoDevice();
+ cam->moveToThread(&cam_thread);
+ connect(cam, SIGNAL(packetReady(QByteArray,bool)), this, SLOT(send_video_packet(QByteArray,bool)));
+ cam_thread.start();
+
+ remotevideo = new VideoDevice();
+ remotevideo->moveToThread(&remotevideo_thread);
+ remotevideo_thread.start();
}
AudioVideoCallController::~AudioVideoCallController()
{
stopTones();
stopAudioPipeline();
+ stopVideoPipeline();
toneplayer_thread.quit();
toneplayer_thread.wait();
@@ -80,6 +103,14 @@ AudioVideoCallController::~AudioVideoCallController()
output_thread.quit();
output_thread.wait();
delete output;
+
+ cam_thread.quit();
+ cam_thread.wait();
+ delete cam;
+
+ remotevideo_thread.quit();
+ remotevideo_thread.wait();
+ delete remotevideo;
}
void AudioVideoCallController::playEarpieceTone(void (TonePlayer::*tone)())
@@ -150,6 +181,53 @@ void AudioVideoCallController::stopAudioPipeline()
output->Close();
}
+void AudioVideoCallController::startVideoPipeline()
+{
+ Settings st(config_dbname);
+ QString camDev = st.loadValue("video.capture_device", QString()).toString();
+
+ // Unlike the audio devices, which fall back to the platform's "default"
+ // device, there is no such name for cameras - so with nothing configured
+ // yet (the setting is only written once the settings dialog has been
+ // visited) pick the first camera found.
+ if (camDev.isEmpty())
+ {
+ VideoDevice enumerator;
+ QList<MediaDeviceInfo> cams = enumerator.Enumerate(CAPTURE);
+
+ if (!cams.isEmpty())
+ camDev = cams.first().device;
+ }
+
+ // No camera at all - the call simply carries no outgoing video. Incoming
+ // video is unaffected, it only depends on what the peer sends.
+ if (camDev.isEmpty())
+ return;
+
+ m_videoSeq = 0;
+ m_videoFrameTimestamp = 0;
+ m_videoFrameStarted = false;
+
+ skynet->SendVideoParams(m_peer_id, QStringLiteral("h264"), kVideoWidth, kVideoHeight);
+
+ QMetaObject::invokeMethod(cam, "configureCapture", Qt::QueuedConnection,
+ Q_ARG(QString, camDev), Q_ARG(QString, QStringLiteral("h264")),
+ Q_ARG(int, kVideoWidth), Q_ARG(int, kVideoHeight));
+ QMetaObject::invokeMethod(cam, "Capture", Qt::QueuedConnection);
+
+ // remotevideo is configured lazily, once the peer's own 0x75 (see
+ // incoming_video_params()) tells us what it is sending.
+}
+
+void AudioVideoCallController::stopVideoPipeline()
+{
+ // Direct calls, for the same reason stopAudioPipeline() uses them.
+ cam->Close();
+ remotevideo->Close();
+
+ m_videoAssembler.reset();
+}
+
void AudioVideoCallController::call_failed(QByteArray peer_id, QString reason)
{
qDebug() << "Call failed: " << reason << "\n";
@@ -175,6 +253,7 @@ void AudioVideoCallController::call_accepted(QByteArray peer_id)
is_active = true;
callwindow->call_started();
startAudioPipeline();
+ startVideoPipeline();
}
void AudioVideoCallController::call_rejected(QByteArray peer_id)
@@ -192,6 +271,7 @@ void AudioVideoCallController::call_hungup(QByteArray peer_id)
return;
stopAudioPipeline();
+ stopVideoPipeline();
/* queued from the SkyNet thread as well - the window may already be closed */
if (callwindow)
@@ -221,6 +301,9 @@ void AudioVideoCallController::start_call(QByteArray peer_id)
connect(callwindow, SIGNAL(start_call_pressed()), this, SLOT(initiate_call()));
connect(callwindow, SIGNAL(hang_call_pressed()), this, SLOT(end_call()));
connect(callwindow, SIGNAL(callwindow_closed(CallWindow*)), this, SLOT(callwindow_closed(CallWindow*)));
+ /* 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)));
callwindow->show();
}
@@ -238,6 +321,8 @@ void AudioVideoCallController::call_from(QByteArray peer_id)
connect(callwindow, SIGNAL(accept_call_pressed()), this, SLOT(accept_call()));
connect(callwindow, SIGNAL(hang_call_pressed()), this, SLOT(end_call()));
connect(callwindow, SIGNAL(callwindow_closed(CallWindow*)), this, SLOT(callwindow_closed(CallWindow*)));
+ /* see the same connection in start_call() */
+ connect(remotevideo, SIGNAL(frameReady(QImage)), callwindow, SLOT(showRemoteFrame(QImage)));
callwindow->show();
/* play ring tone */
@@ -258,12 +343,14 @@ void AudioVideoCallController::accept_call()
is_active = true;
callwindow->call_started();
startAudioPipeline();
+ startVideoPipeline();
}
void AudioVideoCallController::end_call()
{
stopTones();
stopAudioPipeline();
+ stopVideoPipeline();
if (is_active)
skynet->HangupCall(m_peer_id);
@@ -320,3 +407,56 @@ void AudioVideoCallController::incoming_audio_packet(QByteArray peer_id, quint16
output->enqueuePacket(data);
}
+void AudioVideoCallController::send_video_packet(QByteArray data, bool is_last_fragment)
+{
+ if (!is_active)
+ return;
+
+ // Every fragment of one encoded frame carries the timestamp taken when
+ // that frame's first fragment was sent, so the receiving side can tell
+ // the fragments of one frame from those of the next.
+ if (!m_videoFrameStarted)
+ {
+ m_videoFrameTimestamp = (quint32)m_callClock.elapsed();
+ m_videoFrameStarted = true;
+ }
+
+ quint16 seq = m_videoSeq & VideoFrameAssembler::SeqNumMask;
+ m_videoSeq = (m_videoSeq + 1) & VideoFrameAssembler::SeqNumMask;
+
+ if (is_last_fragment)
+ {
+ seq |= VideoFrameAssembler::LastFragmentFlag;
+ // The next fragment emitted belongs to a new frame.
+ m_videoFrameStarted = false;
+ }
+
+ skynet->SendVideoPacket(m_peer_id, seq, m_videoFrameTimestamp, data);
+}
+
+void AudioVideoCallController::incoming_video_params(QByteArray peer_id, QString codec, quint16 width, quint16 height)
+{
+ if (!is_active || peer_id != m_peer_id)
+ return;
+
+ QMetaObject::invokeMethod(remotevideo, "configurePlayback", Qt::QueuedConnection,
+ Q_ARG(QString, codec), Q_ARG(int, (int)width), Q_ARG(int, (int)height));
+ QMetaObject::invokeMethod(remotevideo, "Play", Qt::QueuedConnection);
+}
+
+void AudioVideoCallController::incoming_video_packet(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data)
+{
+ if (!is_active || peer_id != m_peer_id)
+ return;
+
+ QByteArray frame = m_videoAssembler.addFragment(seq_num, timestamp, data);
+
+ // Still waiting for the rest of this frame's fragments.
+ if (frame.isEmpty())
+ return;
+
+ // Direct call, not invokeMethod()/QueuedConnection - same reason as in
+ // incoming_audio_packet().
+ remotevideo->enqueueFrame(frame);
+}
+
diff --git a/pica-client/audiovideocallcontroller.h b/pica-client/audiovideocallcontroller.h
index 8df6af1..05a56ed 100644
--- a/pica-client/audiovideocallcontroller.h
+++ b/pica-client/audiovideocallcontroller.h
@@ -47,7 +47,10 @@ private:
AudioDevice *ringdevice;
AudioDevice *microphone;
AudioDevice *output;
+ // Local camera capture+encode.
VideoDevice *cam;
+ // Decoding of the video stream received from the peer.
+ VideoDevice *remotevideo;
bool is_active;
QByteArray m_peer_id;
@@ -65,11 +68,28 @@ private:
QThread microphone_thread;
QThread output_thread;
+ // Camera capture+encode and remote-video decode, same arrangement as
+ // the audio devices above (see VideoDevice).
+ QThread cam_thread;
+ QThread remotevideo_thread;
+
// Sequence number / capture-clock timestamp stamped onto outgoing
// 0x76 audio packets (see PICA_PROTO_CALL_PACKET_HDRSIZE).
quint16 m_audioSeq;
QElapsedTimer m_callClock;
+ // Outgoing 0x77 video packets. The counter occupies the low 15 bits of
+ // the wire sequence number, the top bit being the last fragment marker.
+ // All fragments of one encoded frame share m_videoFrameTimestamp, which
+ // is re-read from m_callClock only when a new frame starts - tracked by
+ // m_videoFrameStarted, set false again after emitting a last fragment.
+ quint16 m_videoSeq;
+ quint32 m_videoFrameTimestamp;
+ bool m_videoFrameStarted;
+
+ // Reassembly of incoming 0x77 fragments into whole encoded frames.
+ VideoFrameAssembler m_videoAssembler;
+
void playEarpieceTone(void (TonePlayer::*tone)());
void playRingTone();
void stopTones();
@@ -77,6 +97,9 @@ private:
void startAudioPipeline();
void stopAudioPipeline();
+ void startVideoPipeline();
+ void stopVideoPipeline();
+
private slots:
// button handlers
void initiate_call();
@@ -88,6 +111,10 @@ private slots:
void incoming_audio_params(QByteArray peer_id, QString codec, quint16 sample_rate);
void incoming_audio_packet(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data);
+ void send_video_packet(QByteArray data, bool is_last_fragment);
+ void incoming_video_params(QByteArray peer_id, QString codec, quint16 width, quint16 height);
+ void incoming_video_packet(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data);
+
};
#endif // AUDIOVIDEOCALLCONTROLLER_H
diff --git a/pica-client/callwindow.cpp b/pica-client/callwindow.cpp
index 21bbcb0..5eb21cc 100644
--- a/pica-client/callwindow.cpp
+++ b/pica-client/callwindow.cpp
@@ -34,10 +34,15 @@ CallWindow::CallWindow(QByteArray peer_id, bool incoming)
lbTimer = new QLabel(this);
lbTimer->setAlignment(Qt::AlignCenter);
lbTimer->hide();
+ lbVideo = new QLabel(this);
+ lbVideo->setAlignment(Qt::AlignCenter);
+ lbVideo->setMinimumSize(320, 240);
+ lbVideo->hide();
callTimer = new QTimer(this);
callElapsedSeconds = 0;
lv->addWidget(lbTimer);
+ lv->addWidget(lbVideo);
lh->addWidget(pbAccept, Qt::AlignLeft);
lh->addWidget(pbCall, Qt::AlignLeft);
@@ -77,6 +82,19 @@ void CallWindow::call_ended()
pbHang->setDisabled(true);
}
+void CallWindow::showRemoteFrame(QImage frame)
+{
+ if (frame.isNull())
+ return;
+
+ if (lbVideo->isHidden())
+ lbVideo->show();
+
+ lbVideo->setPixmap(QPixmap::fromImage(frame).scaled(lbVideo->size(),
+ Qt::KeepAspectRatio,
+ Qt::SmoothTransformation));
+}
+
void CallWindow::update_call_timer()
{
int hours, minutes, seconds;
diff --git a/pica-client/callwindow.h b/pica-client/callwindow.h
index 2cc0eeb..9b9869e 100644
--- a/pica-client/callwindow.h
+++ b/pica-client/callwindow.h
@@ -22,6 +22,7 @@
#include <QPushButton>
#include <QLabel>
#include <QTimer>
+#include <QImage>
class CallWindow : public QWidget
{
@@ -40,6 +41,9 @@ private:
QPushButton *pbCall;
QPushButton *pbHang;
QLabel *lbTimer;
+ // Displays the remote side's video. Stays hidden until the first frame
+ // arrives, so an audio-only call keeps the compact window it had before.
+ QLabel *lbVideo;
QTimer *callTimer;
int callElapsedSeconds;
@@ -48,6 +52,7 @@ private:
public slots:
void call_started();
void call_ended();
+ void showRemoteFrame(QImage frame);
private slots:
void call();
void accept();
diff --git a/pica-client/dialogs/settingsdialog.cpp b/pica-client/dialogs/settingsdialog.cpp
index 86faba6..536d141 100644
--- a/pica-client/dialogs/settingsdialog.cpp
+++ b/pica-client/dialogs/settingsdialog.cpp
@@ -155,12 +155,40 @@ SettingsDialog::SettingsDialog(QWidget *parent) :
fillVideoDevices();
videoDevRefresh = new QPushButton(tr("Refresh 🔄"), this);
connect(videoDevRefresh, SIGNAL(clicked()), this, SLOT(fillVideoDevices()));
+
+ btVideoTest = new QPushButton(tr("Test 📷"), this);
+ connect(btVideoTest, SIGNAL(clicked()), this, SLOT(toggleVideoTest()));
+
+ videoPreview = new QLabel(this);
+ videoPreview->setAlignment(Qt::AlignCenter);
+ videoPreview->setMinimumSize(320, 240);
+ videoPreview->setFrameShape(QFrame::StyledPanel);
+
videodevLayout->addWidget(videoDev);
videodevLayout->addWidget(videoDevRefresh);
+ videodevLayout->addWidget(btVideoTest);
+ videodevLayout->addWidget(videoPreview);
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;
+
+ testCam = new VideoDevice();
+ testCam->moveToThread(&testCamThread);
+ connect(testCam, SIGNAL(packetReady(QByteArray,bool)), this, SLOT(videoTestFragment(QByteArray,bool)));
+ connect(testCam, SIGNAL(errorOccurred(QString)), this, SLOT(videoTestError(QString)));
+ testCamThread.start();
+
+ testDecoder = new VideoDevice();
+ testDecoder->moveToThread(&testDecoderThread);
+ connect(testDecoder, SIGNAL(frameReady(QImage)), this, SLOT(videoTestFrame(QImage)));
+ connect(testDecoder, SIGNAL(errorOccurred(QString)), this, SLOT(videoTestError(QString)));
+ testDecoderThread.start();
+
tabW->addTab(directc2ctab, tr("Direct Connections"));
tabW->addTab(multilogintab, tr("Multiple logins"));
tabW->addTab(audiodevtab, tr("Audio Devices"));
@@ -189,6 +217,106 @@ SettingsDialog::SettingsDialog(QWidget *parent) :
setWindowTitle(tr("Pica Pica Messenger Settings"));
}
+SettingsDialog::~SettingsDialog()
+{
+ // Stops the loops first - the threads cannot finish while Capture()/Play()
+ // are still running inside them.
+ testCam->Close();
+ testDecoder->Close();
+
+ testCamThread.quit();
+ testCamThread.wait();
+ delete testCam;
+
+ testDecoderThread.quit();
+ testDecoderThread.wait();
+ delete testDecoder;
+}
+
+void SettingsDialog::toggleVideoTest()
+{
+ if (videoTestRunning)
+ {
+ stopVideoTest();
+ return;
+ }
+
+ QString dev = videoDev->itemData(videoDev->currentIndex()).toString();
+
+ if (dev.isEmpty())
+ {
+ videoPreview->setText(tr("No camera selected"));
+ return;
+ }
+
+ testAssembler.reset();
+ videoTestRunning = true;
+ btVideoTest->setText(tr("Stop ⏹"));
+ videoPreview->setText(tr("Starting..."));
+
+ QMetaObject::invokeMethod(testCam, "configureCapture", Qt::QueuedConnection,
+ Q_ARG(QString, dev), Q_ARG(QString, QStringLiteral("h264")),
+ Q_ARG(int, 640), Q_ARG(int, 480));
+ QMetaObject::invokeMethod(testCam, "Capture", Qt::QueuedConnection);
+
+ QMetaObject::invokeMethod(testDecoder, "configurePlayback", Qt::QueuedConnection,
+ Q_ARG(QString, QStringLiteral("h264")),
+ Q_ARG(int, 640), Q_ARG(int, 480));
+ QMetaObject::invokeMethod(testDecoder, "Play", Qt::QueuedConnection);
+}
+
+void SettingsDialog::stopVideoTest()
+{
+ // Direct calls: both devices are blocked inside their capture/decode
+ // loops, so a queued call would never reach them (see the comments in
+ // AudioVideoCallController::stopAudioPipeline()).
+ testCam->Close();
+ testDecoder->Close();
+
+ testAssembler.reset();
+ videoTestRunning = false;
+ btVideoTest->setText(tr("Test 📷"));
+ videoPreview->clear();
+}
+
+void SettingsDialog::videoTestFragment(QByteArray data, bool is_last_fragment)
+{
+ if (!videoTestRunning)
+ return;
+
+ // The encoder emits fragments exactly as they would go onto the wire, so
+ // feed them through the same assembler the receiving end of a call uses.
+ // The sequence number is rebuilt here the way send_video_packet() builds
+ // it, the timestamp being constant since only frame boundaries matter.
+ quint16 seq = is_last_fragment ? VideoFrameAssembler::LastFragmentFlag : 0;
+ QByteArray frame = testAssembler.addFragment(seq, 0, data);
+
+ if (frame.isEmpty())
+ return;
+
+ // Direct call, for the same reason as in the call controller.
+ testDecoder->enqueueFrame(frame);
+}
+
+void SettingsDialog::videoTestFrame(QImage frame)
+{
+ if (!videoTestRunning || frame.isNull())
+ return;
+
+ videoPreview->setPixmap(QPixmap::fromImage(frame).scaled(videoPreview->size(),
+ Qt::KeepAspectRatio,
+ Qt::SmoothTransformation));
+}
+
+void SettingsDialog::videoTestError(QString message)
+{
+ if (!videoTestRunning)
+ return;
+
+ stopVideoTest();
+ videoPreview->setText(message);
+}
+
void SettingsDialog::fillDevicesComboBox(QComboBox *cb, MediaDevice *dev, enum MediaDeviceStreamDirection dir)
{
cb->clear();
diff --git a/pica-client/dialogs/settingsdialog.h b/pica-client/dialogs/settingsdialog.h
index cf767b2..06a2f13 100644
--- a/pica-client/dialogs/settingsdialog.h
+++ b/pica-client/dialogs/settingsdialog.h
@@ -24,13 +24,18 @@
#include <QRadioButton>
#include <QComboBox>
#include <QCheckBox>
+#include <QLabel>
+#include <QThread>
+#include <QImage>
#include "../mediadevice.h"
+#include "../videodevice.h"
class SettingsDialog : public QDialog
{
Q_OBJECT
public:
explicit SettingsDialog(QWidget *parent = 0);
+ ~SettingsDialog();
signals:
@@ -61,6 +66,21 @@ private:
QPushButton *btAudioTest;
+ // Local video pipeline test: frames from the selected camera are
+ // encoded, split into protocol sized fragments, reassembled and decoded
+ // again, then shown in videoPreview - the same path a call takes, minus
+ // the network, so the whole chain can be checked from this dialog.
+ QPushButton *btVideoTest;
+ QLabel *videoPreview;
+ VideoDevice *testCam;
+ VideoDevice *testDecoder;
+ QThread testCamThread;
+ QThread testDecoderThread;
+ VideoFrameAssembler testAssembler;
+ bool videoTestRunning;
+
+ void stopVideoTest();
+
QPushButton *btOk;
QPushButton *btCancel;
@@ -79,6 +99,11 @@ private slots:
void fillAudioPlaybackDevices();
void fillAudioRingDevices();
+ void toggleVideoTest();
+ void videoTestFragment(QByteArray data, bool is_last_fragment);
+ void videoTestFrame(QImage frame);
+ void videoTestError(QString message);
+
};
#endif // SETTINGSDIALOG_H
diff --git a/pica-client/skynet.cpp b/pica-client/skynet.cpp
index 1cd42bf..0ca37cc 100644
--- a/pica-client/skynet.cpp
+++ b/pica-client/skynet.cpp
@@ -896,6 +896,38 @@ void SkyNet::do_SendAudioPacket(QByteArray to, quint16 seq_num, quint32 timestam
}
}
+void SkyNet::SendVideoParams(QByteArray to, QString codec, quint16 width, quint16 height)
+{
+ QMetaObject::invokeMethod(this, "do_SendVideoParams", Qt::AutoConnection,
+ Q_ARG(QByteArray, to), Q_ARG(QString, codec), Q_ARG(quint16, width), Q_ARG(quint16, height));
+}
+
+void SkyNet::do_SendVideoParams(QByteArray to, QString codec, quint16 width, quint16 height)
+{
+ struct PICA_c2c *iptr;
+
+ if ((iptr = find_active_chan(to)))
+ {
+ PICA_set_call_video_params(iptr, codec.toLatin1().constData(), width, height);
+ }
+}
+
+void SkyNet::SendVideoPacket(QByteArray to, quint16 seq_num, quint32 timestamp, QByteArray data)
+{
+ QMetaObject::invokeMethod(this, "do_SendVideoPacket", Qt::AutoConnection,
+ Q_ARG(QByteArray, to), Q_ARG(quint16, seq_num), Q_ARG(quint32, timestamp), Q_ARG(QByteArray, data));
+}
+
+void SkyNet::do_SendVideoPacket(QByteArray to, quint16 seq_num, quint32 timestamp, QByteArray data)
+{
+ struct PICA_c2c *iptr;
+
+ if ((iptr = find_active_chan(to)))
+ {
+ PICA_send_video_packet(iptr, seq_num, timestamp, data.constData(), data.size());
+ }
+}
+
void SkyNet::StartCall(QByteArray to)
{
QMetaObject::invokeMethod(this, "do_StartCall", Qt::AutoConnection,
@@ -1187,6 +1219,16 @@ void SkyNet::emit_IncomingAudioPacket(QByteArray peer_id, quint16 seq_num, quint
emit IncomingAudioPacket(peer_id, seq_num, timestamp, data);
}
+void SkyNet::emit_IncomingVideoParams(QByteArray peer_id, QString codec, quint16 width, quint16 height)
+{
+ emit IncomingVideoParams(peer_id, codec, width, height);
+}
+
+void SkyNet::emit_IncomingVideoPacket(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data)
+{
+ emit IncomingVideoPacket(peer_id, seq_num, timestamp, data);
+}
+
//callbacks
void SkyNet::newmsg_cb(const unsigned char *peer_id, const char *msgbuf, unsigned int nb, int type)
@@ -1428,6 +1470,7 @@ void SkyNet::call_audio_params_cb(const unsigned char *peer_id, const char *code
void SkyNet::call_video_params_cb(const unsigned char *peer_id, const char *codec, uint16_t width, uint16_t height)
{
+ skynet->emit_IncomingVideoParams(QByteArray((const char*)peer_id, PICA_ID_SIZE), QString::fromLatin1(codec), width, height);
}
void SkyNet::call_audio_packet_cb(const unsigned char *peer_id, uint16_t seq_num, uint32_t timestamp, uint16_t size, const char *pkt_data)
@@ -1437,4 +1480,5 @@ void SkyNet::call_audio_packet_cb(const unsigned char *peer_id, uint16_t seq_num
void SkyNet::call_video_packet_cb(const unsigned char *peer_id, uint16_t seq_num, uint32_t timestamp, uint16_t size, const char *pkt_data)
{
+ skynet->emit_IncomingVideoPacket(QByteArray((const char*)peer_id, PICA_ID_SIZE), seq_num, timestamp, QByteArray(pkt_data, size));
}
diff --git a/pica-client/skynet.h b/pica-client/skynet.h
index 80d9a2f..0baf496 100644
--- a/pica-client/skynet.h
+++ b/pica-client/skynet.h
@@ -52,6 +52,9 @@ public:
void SendAudioParams(QByteArray to, QString codec, quint16 sample_rate);
void SendAudioPacket(QByteArray to, quint16 seq_num, quint32 timestamp, QByteArray data);
+ void SendVideoParams(QByteArray to, QString codec, quint16 width, quint16 height);
+ void SendVideoPacket(QByteArray to, quint16 seq_num, quint32 timestamp, QByteArray data);
+
signals:
void MessageReceived(QByteArray from, QString msg);
void UnableToDeliver(QByteArray to, QString msg);
@@ -89,6 +92,9 @@ signals:
void IncomingAudioParams(QByteArray peer_id, QString codec, quint16 sample_rate);
void IncomingAudioPacket(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data);
+
+ void IncomingVideoParams(QByteArray peer_id, QString codec, quint16 width, quint16 height);
+ void IncomingVideoPacket(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data);
private:
// Constructed in init(), once this SkyNet instance is running on its
// own network thread and that thread's dedicated QSqlDatabase
@@ -165,6 +171,9 @@ private:
void emit_IncomingAudioParams(QByteArray peer_id, QString codec, quint16 sample_rate);
void emit_IncomingAudioPacket(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data);
+ void emit_IncomingVideoParams(QByteArray peer_id, QString codec, quint16 width, quint16 height);
+ void emit_IncomingVideoPacket(QByteArray peer_id, quint16 seq_num, quint32 timestamp, QByteArray data);
+
static void newmsg_cb(const unsigned char *peer_id, const char* msgbuf, unsigned int nb, int type);
static void msgok_cb(const unsigned char *peer_id);
@@ -273,6 +282,9 @@ private slots:
void do_SendAudioParams(QByteArray to, QString codec, quint16 sample_rate);
void do_SendAudioPacket(QByteArray to, quint16 seq_num, quint32 timestamp, QByteArray data);
+ void do_SendVideoParams(QByteArray to, QString codec, quint16 width, quint16 height);
+ void do_SendVideoPacket(QByteArray to, quint16 seq_num, quint32 timestamp, QByteArray data);
+
};
#endif // SKYNET_H
diff --git a/pica-client/videodevice.cpp b/pica-client/videodevice.cpp
index aabb245..4dd9d6d 100644
--- a/pica-client/videodevice.cpp
+++ b/pica-client/videodevice.cpp
@@ -1,5 +1,26 @@
+/*
+ (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 "videodevice.h"
+#include "../PICA_proto.h"
+
#include <QFile>
+#include <QDebug>
+#include <QMutexLocker>
+#include <cstring>
#ifdef Q_OS_LINUX
#include <linux/videodev2.h>
@@ -10,26 +31,471 @@
#include <fcntl.h>
#endif
-VideoDevice::VideoDevice()
+// FFmpeg headers
+extern "C" {
+#include <libavformat/avformat.h>
+#include <libavdevice/avdevice.h>
+#include <libavcodec/avcodec.h>
+#include <libavutil/opt.h>
+#include <libavutil/imgutils.h>
+#include <libavutil/frame.h>
+#include <libavutil/mem.h>
+#include <libavutil/error.h>
+#include <libswscale/swscale.h>
+}
+
+// Encoding defaults. Not exposed as settings yet - see the deferred list in
+// the feature plan; a fixed, widely supported set of parameters for now.
+static const int kFrameRate = 15;
+static const int64_t kBitrate = 600000;
+// Keyframe every ~2 seconds. There is no retransmission or keyframe request
+// machinery yet, so this doubles as how quickly a receiver that missed data
+// can resynchronize.
+static const int kGopSize = kFrameRate * 2;
+
+// Largest amount of encoded data one 0x77 message can carry: the protocol's
+// per-message payload cap, minus the sequence number + timestamp header that
+// every call media packet carries.
+static const int kMaxFragmentSize = PICA_PROTO_C2CMSG_MAXDATASIZE - PICA_PROTO_CALL_PACKET_HDRSIZE;
+
+static QString ff_errstr(int err)
+{
+ char buf[256] = {0};
+ av_strerror(err, buf, sizeof(buf));
+ return QString::fromLocal8Bit(buf);
+}
+
+VideoFrameAssembler::VideoFrameAssembler()
+ : m_timestamp(0), m_inProgress(false)
+{
+}
+
+void VideoFrameAssembler::reset()
{
+ m_buffer.clear();
+ m_inProgress = false;
+}
+
+QByteArray VideoFrameAssembler::addFragment(quint16 seq_num, quint32 timestamp, const QByteArray &data)
+{
+ // Fragments of one encoded frame all carry the same timestamp; a
+ // different one while a frame is still being assembled means the rest of
+ // that frame is never coming, so drop what was collected and start over.
+ if (m_inProgress && timestamp != m_timestamp)
+ m_buffer.clear();
+
+ m_buffer.append(data);
+ m_timestamp = timestamp;
+ m_inProgress = true;
+ if (!(seq_num & LastFragmentFlag))
+ return QByteArray();
+
+ QByteArray frame = m_buffer;
+ reset();
+
+ return frame;
+}
+
+VideoDevice::VideoDevice(QObject *parent)
+ : QObject(parent), m_width(640), m_height(480), m_abort(0)
+{
}
VideoDevice::~VideoDevice()
{
+ Close();
+}
+void VideoDevice::configureCapture(QString deviceName, QString codec, int width, int height)
+{
+ m_deviceName = deviceName;
+ m_codec = codec;
+ m_width = width;
+ m_height = height;
}
-void VideoDevice::Capture()
+void VideoDevice::configurePlayback(QString codec, int width, int height)
{
+ m_codec = codec;
+ m_width = width;
+ m_height = height;
}
-void VideoDevice::Play()
+void VideoDevice::enqueueFrame(QByteArray encodedFrame)
{
+ QMutexLocker locker(&m_queueMutex);
+
+ while (m_frameQueue.size() >= kMaxQueueDepth)
+ m_frameQueue.dequeue();
+
+ m_frameQueue.enqueue(encodedFrame);
+ m_queueCond.wakeAll();
}
void VideoDevice::Close()
{
+ m_abort.storeRelaxed(1);
+
+ {
+ QMutexLocker locker(&m_queueMutex);
+ m_frameQueue.clear();
+ }
+ m_queueCond.wakeAll();
+}
+
+void VideoDevice::Capture()
+{
+ m_abort.storeRelaxed(0);
+
+ avdevice_register_all();
+#if LIBAVFORMAT_VERSION_MAJOR < 58
+ av_register_all();
+ avcodec_register_all();
+#endif
+
+ const QByteArray deviceUtf8 = m_deviceName.toUtf8();
+ const QByteArray driverUtf8 = VideoDevice::PlatformDriverName().toUtf8();
+ const AVInputFormat *ifmt = av_find_input_format(driverUtf8.constData());
+ if (!ifmt)
+ {
+ QString msg = QString("Could not find '%1' video input driver").arg(VideoDevice::PlatformDriverName());
+ qWarning() << msg;
+ emit errorOccurred(msg);
+ return;
+ }
+
+ AVDictionary *opts = nullptr;
+ av_dict_set(&opts, "video_size", QString("%1x%2").arg(m_width).arg(m_height).toUtf8().constData(), 0);
+ av_dict_set(&opts, "framerate", QString::number(kFrameRate).toUtf8().constData(), 0);
+
+ AVFormatContext *ifmt_ctx = nullptr;
+ int ret = avformat_open_input(&ifmt_ctx, deviceUtf8.constData(), ifmt, &opts);
+ av_dict_free(&opts);
+ if (ret < 0)
+ {
+ // Not fatal to the call - it simply proceeds without outgoing video.
+ QString msg = QString("Could not open camera '%1': %2").arg(m_deviceName, ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccurred(msg);
+ return;
+ }
+
+ if ((ret = avformat_find_stream_info(ifmt_ctx, nullptr)) < 0 || ifmt_ctx->nb_streams < 1)
+ {
+ QString msg = QString("Could not determine camera stream parameters: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccurred(msg);
+ avformat_close_input(&ifmt_ctx);
+ return;
+ }
+
+ AVStream *in_st = ifmt_ctx->streams[0];
+
+ // The camera hands us either raw frames or, on many webcams, an already
+ // compressed stream (MJPEG). Either way it goes through a decoder here to
+ // get raw pictures for the encoder. Forwarding an already compressed
+ // camera stream untouched is a planned optimization, not done yet.
+ const AVCodec *cam_dec = avcodec_find_decoder(in_st->codecpar->codec_id);
+ AVCodecContext *dec_ctx = cam_dec ? avcodec_alloc_context3(cam_dec) : nullptr;
+ if (!cam_dec || !dec_ctx ||
+ avcodec_parameters_to_context(dec_ctx, in_st->codecpar) < 0 ||
+ avcodec_open2(dec_ctx, cam_dec, nullptr) < 0)
+ {
+ QString msg = "Could not open camera format decoder";
+ qWarning() << msg;
+ emit errorOccurred(msg);
+ if (dec_ctx) avcodec_free_context(&dec_ctx);
+ avformat_close_input(&ifmt_ctx);
+ return;
+ }
+
+ const AVCodec *enc = avcodec_find_encoder_by_name("libx264");
+ if (!enc)
+ enc = avcodec_find_encoder(AV_CODEC_ID_H264);
+ AVCodecContext *enc_ctx = enc ? avcodec_alloc_context3(enc) : nullptr;
+ if (!enc || !enc_ctx)
+ {
+ QString msg = "H.264 encoder not available";
+ qWarning() << msg;
+ emit errorOccurred(msg);
+ avcodec_free_context(&dec_ctx);
+ avformat_close_input(&ifmt_ctx);
+ return;
+ }
+
+ enc_ctx->width = m_width;
+ enc_ctx->height = m_height;
+ enc_ctx->pix_fmt = AV_PIX_FMT_YUV420P;
+ enc_ctx->time_base = AVRational{1, kFrameRate};
+ enc_ctx->framerate = AVRational{kFrameRate, 1};
+ enc_ctx->bit_rate = kBitrate;
+ enc_ctx->gop_size = kGopSize;
+ // B-frames reorder output, which would cost latency in a live call.
+ enc_ctx->max_b_frames = 0;
+
+ // Note the absence of AV_CODEC_FLAG_GLOBAL_HEADER: we want the parameter
+ // sets repeated in-band with every keyframe, since the receiving side has
+ // no out-of-band way to learn them and may start decoding mid-stream.
+ av_opt_set(enc_ctx->priv_data, "preset", "veryfast", 0);
+ av_opt_set(enc_ctx->priv_data, "tune", "zerolatency", 0);
+
+ if ((ret = avcodec_open2(enc_ctx, enc, nullptr)) < 0)
+ {
+ QString msg = QString("Could not open H.264 encoder: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccurred(msg);
+ avcodec_free_context(&enc_ctx);
+ avcodec_free_context(&dec_ctx);
+ avformat_close_input(&ifmt_ctx);
+ return;
+ }
+
+ AVFrame *enc_frame = av_frame_alloc();
+ if (enc_frame)
+ {
+ enc_frame->format = enc_ctx->pix_fmt;
+ enc_frame->width = enc_ctx->width;
+ enc_frame->height = enc_ctx->height;
+ }
+ if (!enc_frame || av_frame_get_buffer(enc_frame, 0) < 0)
+ {
+ QString msg = "Could not allocate encoder frame";
+ qWarning() << msg;
+ emit errorOccurred(msg);
+ if (enc_frame) av_frame_free(&enc_frame);
+ avcodec_free_context(&enc_ctx);
+ avcodec_free_context(&dec_ctx);
+ avformat_close_input(&ifmt_ctx);
+ return;
+ }
+
+ SwsContext *sws = nullptr;
+ AVPacket *in_pkt = av_packet_alloc();
+ AVFrame *cam_frame = av_frame_alloc();
+ AVPacket *out_pkt = av_packet_alloc();
+ int64_t pts = 0;
+ bool loggedReadError = false;
+ bool loggedScaleError = false;
+ bool loggedEncodeError = false;
+
+ while (!m_abort.loadRelaxed())
+ {
+ ret = av_read_frame(ifmt_ctx, in_pkt);
+ if (ret < 0)
+ {
+ if (ret == AVERROR(EAGAIN))
+ continue;
+ if (!loggedReadError)
+ {
+ qWarning() << QString("Camera read failed: %1").arg(ff_errstr(ret));
+ loggedReadError = true;
+ }
+ break;
+ }
+
+ ret = avcodec_send_packet(dec_ctx, in_pkt);
+ av_packet_unref(in_pkt);
+ if (ret < 0)
+ continue;
+
+ while (avcodec_receive_frame(dec_ctx, cam_frame) == 0)
+ {
+ // Built lazily: the camera's actual frame format and size are
+ // only known once a frame has been decoded, and may differ from
+ // what was requested.
+ if (!sws)
+ {
+ sws = sws_getContext(cam_frame->width, cam_frame->height, (AVPixelFormat)cam_frame->format,
+ enc_ctx->width, enc_ctx->height, enc_ctx->pix_fmt,
+ SWS_BILINEAR, nullptr, nullptr, nullptr);
+ if (!sws)
+ {
+ if (!loggedScaleError)
+ {
+ qWarning() << "Could not set up capture scaler";
+ loggedScaleError = true;
+ }
+ av_frame_unref(cam_frame);
+ continue;
+ }
+ }
+
+ if (av_frame_make_writable(enc_frame) < 0)
+ {
+ av_frame_unref(cam_frame);
+ continue;
+ }
+
+ sws_scale(sws, cam_frame->data, cam_frame->linesize, 0, cam_frame->height,
+ enc_frame->data, enc_frame->linesize);
+ av_frame_unref(cam_frame);
+
+ enc_frame->pts = pts++;
+
+ if (avcodec_send_frame(enc_ctx, enc_frame) != 0)
+ {
+ if (!loggedEncodeError)
+ {
+ qWarning() << "H.264 encode failed";
+ loggedEncodeError = true;
+ }
+ continue;
+ }
+
+ while (avcodec_receive_packet(enc_ctx, out_pkt) == 0)
+ {
+ // One encoded frame goes out as one or more 0x77 messages;
+ // the final one is flagged so the receiver knows the frame is
+ // complete (see the fragmentation notes in the protocol doc).
+ int offset = 0;
+ while (offset < out_pkt->size)
+ {
+ int chunk = qMin(kMaxFragmentSize, out_pkt->size - offset);
+ bool isLast = (offset + chunk >= out_pkt->size);
+
+ emit packetReady(QByteArray((const char *)out_pkt->data + offset, chunk), isLast);
+ offset += chunk;
+ }
+ av_packet_unref(out_pkt);
+ }
+ }
+ }
+
+ av_packet_free(&in_pkt);
+ av_packet_free(&out_pkt);
+ av_frame_free(&cam_frame);
+ av_frame_free(&enc_frame);
+ if (sws) sws_freeContext(sws);
+ avcodec_free_context(&enc_ctx);
+ avcodec_free_context(&dec_ctx);
+ avformat_close_input(&ifmt_ctx);
+}
+
+void VideoDevice::Play()
+{
+ m_abort.storeRelaxed(0);
+
+#if LIBAVFORMAT_VERSION_MAJOR < 58
+ avcodec_register_all();
+#endif
+
+ // Only "h264" is understood by this slice - matches what
+ // AudioVideoCallController always negotiates via SendVideoParams().
+ const AVCodec *dec = avcodec_find_decoder(AV_CODEC_ID_H264);
+ AVCodecContext *dec_ctx = dec ? avcodec_alloc_context3(dec) : nullptr;
+ if (m_codec != QLatin1String("h264") || !dec || !dec_ctx || avcodec_open2(dec_ctx, dec, nullptr) < 0)
+ {
+ QString msg = QString("Unsupported call video codec: %1").arg(m_codec);
+ qWarning() << msg;
+ emit errorOccurred(msg);
+ if (dec_ctx) avcodec_free_context(&dec_ctx);
+ return;
+ }
+
+ SwsContext *sws = nullptr;
+ int sws_width = 0, sws_height = 0;
+ AVPixelFormat sws_fmt = AV_PIX_FMT_NONE;
+ AVFrame *dec_frame = av_frame_alloc();
+ AVFrame *rgb_frame = av_frame_alloc();
+ bool loggedDecodeError = false;
+ bool loggedScaleError = false;
+
+ while (true)
+ {
+ QByteArray frameData;
+ {
+ QMutexLocker locker(&m_queueMutex);
+ while (m_frameQueue.isEmpty() && !m_abort.loadRelaxed())
+ m_queueCond.wait(&m_queueMutex, 100);
+
+ if (m_abort.loadRelaxed())
+ break;
+
+ if (m_frameQueue.isEmpty())
+ continue;
+
+ frameData = m_frameQueue.dequeue();
+ }
+
+ AVPacket *in_pkt = av_packet_alloc();
+ if (av_new_packet(in_pkt, frameData.size()) < 0)
+ {
+ av_packet_free(&in_pkt);
+ continue;
+ }
+ memcpy(in_pkt->data, frameData.constData(), frameData.size());
+
+ int ret = avcodec_send_packet(dec_ctx, in_pkt);
+ av_packet_free(&in_pkt);
+ if (ret < 0)
+ {
+ if (!loggedDecodeError)
+ {
+ qWarning() << QString("H.264 decode failed: %1").arg(ff_errstr(ret));
+ loggedDecodeError = true;
+ }
+ continue;
+ }
+
+ while (avcodec_receive_frame(dec_ctx, dec_frame) == 0)
+ {
+ // Rebuilt whenever the incoming picture format or size changes -
+ // including the first frame, when they first become known.
+ if (!sws || dec_frame->width != sws_width || dec_frame->height != sws_height ||
+ (AVPixelFormat)dec_frame->format != sws_fmt)
+ {
+ if (sws) sws_freeContext(sws);
+ sws_width = dec_frame->width;
+ sws_height = dec_frame->height;
+ sws_fmt = (AVPixelFormat)dec_frame->format;
+
+ sws = sws_getContext(sws_width, sws_height, sws_fmt,
+ sws_width, sws_height, AV_PIX_FMT_RGB24,
+ 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 (!loggedScaleError)
+ {
+ qWarning() << "Could not set up playback scaler";
+ loggedScaleError = true;
+ }
+ if (sws) { sws_freeContext(sws); sws = nullptr; }
+ av_frame_unref(dec_frame);
+ continue;
+ }
+ }
+
+ if (av_frame_make_writable(rgb_frame) < 0)
+ {
+ av_frame_unref(dec_frame);
+ continue;
+ }
+
+ sws_scale(sws, dec_frame->data, dec_frame->linesize, 0, dec_frame->height,
+ rgb_frame->data, rgb_frame->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());
+
+ av_frame_unref(dec_frame);
+ }
+ }
+
+ av_frame_free(&dec_frame);
+ av_frame_free(&rgb_frame);
+ if (sws) sws_freeContext(sws);
+ avcodec_free_context(&dec_ctx);
}
QList<MediaDeviceInfo> VideoDevice::Enumerate(enum MediaDeviceStreamDirection dir)
@@ -79,3 +545,16 @@ QList<MediaDeviceInfo> VideoDevice::Enumerate(enum MediaDeviceStreamDirection di
#endif
return result;
}
+
+QString VideoDevice::PlatformDriverName()
+{
+#if defined(Q_OS_LINUX)
+ return QStringLiteral("v4l2");
+#elif defined(Q_OS_MACOS) || defined(Q_OS_MAC)
+ return QStringLiteral("avfoundation");
+#elif defined(Q_OS_WIN)
+ return QStringLiteral("dshow");
+#else
+ return QStringLiteral("v4l2");
+#endif
+}
diff --git a/pica-client/videodevice.h b/pica-client/videodevice.h
index b7a344e..9ee4397 100644
--- a/pica-client/videodevice.h
+++ b/pica-client/videodevice.h
@@ -1,17 +1,134 @@
+/*
+ (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 VIDEODEVICE_H
#define VIDEODEVICE_H
#include "mediadevice.h"
+#include <QObject>
+#include <QString>
+#include <QByteArray>
+#include <QImage>
+#include <QQueue>
+#include <QMutex>
+#include <QWaitCondition>
+#include <QAtomicInt>
-class VideoDevice : public MediaDevice
+// Reassembles the fragments of one encoded video frame, as carried by 0x77
+// protocol messages - see the fragmentation description in
+// doc/proto-doc-latest.txt. Used both on the receiving end of a call and by
+// the settings dialog's local pipeline test.
+class VideoFrameAssembler
{
public:
- VideoDevice();
+ // A 0x77 sequence number is a last fragment marker in its top bit plus a
+ // 15 bit packet counter.
+ static const quint16 LastFragmentFlag = 0x8000;
+ static const quint16 SeqNumMask = 0x7FFF;
+
+ VideoFrameAssembler();
+
+ // Adds one received fragment. Returns the complete encoded frame once
+ // the fragment carrying the last fragment marker has been added, and an
+ // empty QByteArray while the frame is still incomplete.
+ QByteArray addFragment(quint16 seq_num, quint32 timestamp, const QByteArray &data);
+
+ void reset();
+
+private:
+ QByteArray m_buffer;
+ quint32 m_timestamp;
+ bool m_inProgress;
+};
+
+// Video counterpart of AudioDevice: a single instance is dedicated to one
+// direction - either capture+encode (Capture(), reading from a local camera)
+// or decode (Play(), fed from the network via enqueueFrame()). Each instance
+// is meant to live on its own QThread, since both Capture() and Play() block
+// the calling thread in a loop until Close() is called.
+//
+// Note the calling rules that come with that, the same ones AudioDevice
+// follows: enqueueFrame() and Close() have to reach a loop that is already
+// running, so they are internally thread-safe and must be called directly
+// rather than through QMetaObject::invokeMethod() - a queued call would sit
+// in the event queue behind the very loop it is meant to feed or interrupt.
+// configureCapture()/configurePlayback() are the exception: they only run
+// before the loop starts, while the thread is still idle.
+class VideoDevice : public QObject, public MediaDevice
+{
+ Q_OBJECT
+public:
+ VideoDevice(QObject *parent = nullptr);
~VideoDevice();
+
+ Q_INVOKABLE void configureCapture(QString deviceName, QString codec, int width, int height);
+ Q_INVOKABLE void configurePlayback(QString codec, int width, int height);
+
+ // Push one complete encoded frame (already reassembled from 0x77
+ // fragments by the caller) into the decode queue. Safe to call from any
+ // thread. Drops the oldest queued frame if the buffer is full - for live
+ // video, showing the newest frame matters more than showing every frame.
+ void enqueueFrame(QByteArray encodedFrame);
+
+public slots:
+ // Blocking capture+encode loop: opens the configured camera, encodes
+ // captured frames, and emits packetReady() for each encoded frame, split
+ // into fragments that fit a single 0x77 protocol message. Returns once
+ // Close() is called.
+ virtual void Capture() override;
+
+ // Blocking decode loop: decodes frames pulled from the internal queue
+ // (see enqueueFrame()) and emits frameReady() for each decoded picture.
+ // Returns once Close() is called.
+ virtual void Play() override;
+
+ // Signals a running Capture()/Play() loop to stop and return. Safe to
+ // call from any thread, and safe to call even if nothing is running.
+ virtual void Close() override;
+
+signals:
+ // One fragment of an encoded frame, ready to be sent over the network.
+ // isLastFragment marks the final fragment of a frame - it maps directly
+ // 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.
+ void frameReady(QImage frame);
+
+ void errorOccurred(QString message);
+
+public:
virtual QList<MediaDeviceInfo> Enumerate(enum MediaDeviceStreamDirection dir);
- void Capture();
- void Play();
- void Close();
+
+ // Name of the FFmpeg libavdevice demuxer for the host platform's native
+ // video capture API. Counterpart of AudioDevice::PlatformDriverName().
+ static QString PlatformDriverName();
+
+private:
+ QString m_deviceName;
+ QString m_codec;
+ int m_width;
+ int m_height;
+
+ QAtomicInt m_abort;
+
+ QMutex m_queueMutex;
+ QWaitCondition m_queueCond;
+ QQueue<QByteArray> m_frameQueue;
+ static const int kMaxQueueDepth = 2;
};
#endif // VIDEODEVICE_H