From e54cf647315c7050e04ada9ebb4e2124739a0aa9 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 05:36:25 +0000 Subject: [PATCH 01/16] feat(VideoManager): add HTTP MJPEG video source --- .github/build-config.json | 4 + .../GStreamer/tests/test_plugin_policy.cmake | 4 + .../qgc-user-guide/settings_view/general.md | 4 +- docs/en/qgc-user-guide/settings_view/video.md | 8 +- src/AppSettings/pages/Video.SettingsUI.json | 6 +- src/Settings/Video.SettingsGroup.json | 22 ++- src/Settings/VideoSettings.cc | 17 ++ src/Settings/VideoSettings.h | 4 + src/VideoManager/VideoManager.cc | 9 +- .../GStreamer/GstSourceFactory.cc | 120 +++++++++++- .../GStreamer/GstSourceFactory.h | 15 +- .../GStreamer/GstVideoReceiver.cc | 1 + test/VideoManager/GStreamer/GStreamerTest.cc | 3 + test/VideoManager/GStreamer/GStreamerTest.h | 3 + .../GStreamerSourceFactoryTest.cc | 181 ++++++++++++++++++ 15 files changed, 384 insertions(+), 17 deletions(-) diff --git a/.github/build-config.json b/.github/build-config.json index c27d970fb9c3..68d4af1dbdd5 100644 --- a/.github/build-config.json +++ b/.github/build-config.json @@ -38,9 +38,12 @@ "app", "coreelements", "isomp4", + "jpeg", + "jpegformat", "libav", "matroska", "mpegtsdemux", + "multipart", "multifile", "opengl", "openh264", @@ -49,6 +52,7 @@ "rtpmanager", "rtsp", "sdpelem", + "soup", "tcp", "typefindfunctions", "udp", diff --git a/cmake/GStreamer/tests/test_plugin_policy.cmake b/cmake/GStreamer/tests/test_plugin_policy.cmake index 95aca388866e..f68ffeea94f4 100644 --- a/cmake/GStreamer/tests/test_plugin_policy.cmake +++ b/cmake/GStreamer/tests/test_plugin_policy.cmake @@ -35,6 +35,10 @@ file(READ "${CMAKE_CURRENT_LIST_DIR}/../../../.github/build-config.json" QGC_BUI gstreamer_plugins_for(PLATFORM windows OUT_VAR _plugins_windows_real) qgc_test_assert_in_list("windows has d3d11" d3d11 _plugins_windows_real) qgc_test_assert_in_list("windows has d3d12" d3d12 _plugins_windows_real) +qgc_test_assert_in_list("common has native JPEG decoder" jpeg _plugins_windows_real) +qgc_test_assert_in_list("common has HTTP MJPEG jpeg parser" jpegformat _plugins_windows_real) +qgc_test_assert_in_list("common has HTTP MJPEG multipart demuxer" multipart _plugins_windows_real) +qgc_test_assert_in_list("common has HTTP client source" soup _plugins_windows_real) qgc_test_pass("plugins_for real windows d3d addenda") set(_req videoconvertscale videoconvert videoscale x264enc) diff --git a/docs/en/qgc-user-guide/settings_view/general.md b/docs/en/qgc-user-guide/settings_view/general.md index 70b5cb9af8ba..045e7f030a59 100644 --- a/docs/en/qgc-user-guide/settings_view/general.md +++ b/docs/en/qgc-user-guide/settings_view/general.md @@ -201,13 +201,15 @@ The _Video_ section is used to define the source and connection settings for vid The settings are: -- **Video Source**: Video Stream Disabled | RTSP Video Stream | UDP h.264 Video Stream | UDP h.265 Video Stream | TCP-MPEG2 Video Stream | MPEG-TS Video Stream | Integrated Camera +- **Video Source**: Video Stream Disabled | RTSP Video Stream | HTTP MJPEG Video Stream | UDP h.264 Video Stream | UDP h.265 Video Stream | TCP-MPEG2 Video Stream | MPEG-TS Video Stream | Integrated Camera ::: info If no video source is specified then no other video or _video recording_ settings will be displayed. ::: - **URL/Port**: Connection type-specific stream address (may be port or URL). + HTTP MJPEG requires a full `http://` or `https://` URL serving + `multipart/x-mixed-replace` JPEG frames. - **Aspect Ratio**: Aspect ratio for scaling video in video widget (set to 0.0 to ignore scaling) - **Disabled When Disarmed**: Disable video feed when vehicle is disarmed. - **Low Latency Mode**: Enabling low latency mode reduces the video stream latency, but may cause frame loss and choppy video (especially with a poor network connection). diff --git a/docs/en/qgc-user-guide/settings_view/video.md b/docs/en/qgc-user-guide/settings_view/video.md index 6287717c14e0..0c8412330da1 100644 --- a/docs/en/qgc-user-guide/settings_view/video.md +++ b/docs/en/qgc-user-guide/settings_view/video.md @@ -4,16 +4,22 @@ Configure video streaming and recording settings. ## Video Source -- **Source** — Video Stream Disabled / RTSP Video Stream / UDP h.264 / UDP h.265 / TCP-MPEG2 / MPEG-TS / Integrated Camera +- **Source** — Video Stream Disabled / RTSP Video Stream / HTTP MJPEG Video Stream / UDP h.264 / UDP h.265 / TCP-MPEG2 / MPEG-TS / Integrated Camera ## Connection Connection settings vary by source type: - **RTSP URL** — full RTSP stream address +- **HTTP MJPEG URL** — full `http://` or `https://` address of a multipart MJPEG stream - **TCP URL** — TCP stream address - **UDP URL** — UDP stream address and port (default: `0.0.0.0:5600`) +HTTP MJPEG expects a `multipart/x-mixed-replace` response containing JPEG frames. +It does not accept a web page, a single JPEG URL, or an arbitrary HTTP video file. +HTTPS certificate validation remains enabled in GStreamer, redirects are not followed, and URL user information +(`user:password@host`) is rejected. + ## Settings - **Aspect Ratio** — aspect ratio for scaling video in the display widget (default: 16:9; set to 0.0 to disable scaling) diff --git a/src/AppSettings/pages/Video.SettingsUI.json b/src/AppSettings/pages/Video.SettingsUI.json index 5a44c56b5496..9645d02988b2 100644 --- a/src/AppSettings/pages/Video.SettingsUI.json +++ b/src/AppSettings/pages/Video.SettingsUI.json @@ -23,13 +23,17 @@ }, { "heading": "Connection", - "keywords": ["rtsp", "tcp", "udp", "mpegts", "video url", "stream url"], + "keywords": ["rtsp", "http", "https", "mjpeg", "jpeg", "tcp", "udp", "mpegts", "video url", "stream url"], "showWhen": "!sourceDisabled && !autoStreamConfig", "controls": [ { "setting": "videoSettings.rtspUrl", "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.rtspVideoSource" }, + { + "setting": "videoSettings.httpMjpegUrl", + "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.httpMjpegVideoSource" + }, { "setting": "videoSettings.tcpUrl", "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.tcpVideoSource" diff --git a/src/Settings/Video.SettingsGroup.json b/src/Settings/Video.SettingsGroup.json index 901e050eb3f2..84df70c0b91d 100644 --- a/src/Settings/Video.SettingsGroup.json +++ b/src/Settings/Video.SettingsGroup.json @@ -4,8 +4,8 @@ "QGC.MetaData.Facts": [ { "name": "videoSource", - "shortDesc": "Source for video stream (UDP, TCP, RTSP, or connected USB camera).", - "longDesc": "Source for video. UDP, TCP, RTSP and UVC Cameras may be supported depending on Vehicle and ground station version.", + "shortDesc": "Source for video stream (UDP, TCP, RTSP, HTTP MJPEG, or connected USB camera).", + "longDesc": "Source for video. UDP, TCP, RTSP, HTTP multipart MJPEG and UVC cameras may be supported depending on the ground station build.", "type": "string", "default": "", "label": "Source", @@ -29,6 +29,15 @@ "label": "RTSP URL", "keywords": "rtsp,video url,stream url" }, + { + "name": "httpMjpegUrl", + "shortDesc": "Full URL for an HTTP multipart MJPEG stream.", + "longDesc": "Full http:// or https:// URL for a multipart MJPEG stream (multipart/x-mixed-replace with JPEG frames). User information in the URL is not supported.", + "type": "string", + "default": "", + "label": "HTTP MJPEG URL", + "keywords": "http,https,mjpeg,jpeg,video url,stream url" + }, { "name": "tcpUrl", "shortDesc": "Network address and port for TCP video stream (e.g. 192.168.143.200:3001).", @@ -117,13 +126,14 @@ }, { "name": "rtspTimeout", - "shortDesc": "RTSP Video Timeout", - "longDesc": "How long to wait before assuming RTSP link is gone.", + "shortDesc": "Network video timeout.", + "longDesc": "How long to wait before assuming a timeout-based network video source is unavailable.", "type": "uint32", "min": 1, "units": "s", "default": 8, - "label": "RTSP Video Timeout" + "label": "Network Video Timeout", + "keywords": "rtsp,http,mjpeg,network,video timeout" }, { "name": "streamEnabled", @@ -170,7 +180,7 @@ "type": "bool", "default": true, "label": "Auto-reconnect on stream loss", - "keywords": "rtsp,reconnect,watchdog,recovery,advanced" + "keywords": "rtsp,http,mjpeg,stream,reconnect,watchdog,recovery,advanced" }, { "name": "forceVideoDecoder", diff --git a/src/Settings/VideoSettings.cc b/src/Settings/VideoSettings.cc index ae43d03c6c1a..8d4b2fb8ad49 100644 --- a/src/Settings/VideoSettings.cc +++ b/src/Settings/VideoSettings.cc @@ -20,6 +20,9 @@ DECLARE_SETTINGGROUP(Video, "Video") // Setup enum values for videoSource settings into meta data QVariantList videoSourceList; videoSourceList.append(videoSourceRTSP); + if (kGstEnabled) { + videoSourceList.append(videoSourceHTTPMJPEG); + } videoSourceList.append(videoSourceUDPH264); videoSourceList.append(videoSourceUDPH265); videoSourceList.append(videoSourceTCP); @@ -220,6 +223,15 @@ DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, rtspUrl) return _rtspUrlFact; } +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, httpMjpegUrl) +{ + if (!_httpMjpegUrlFact) { + _httpMjpegUrlFact = _createSettingsFact(httpMjpegUrlName); + connect(_httpMjpegUrlFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _httpMjpegUrlFact; +} + DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, tcpUrl) { if (!_tcpUrlFact) { @@ -251,6 +263,11 @@ bool VideoSettings::streamConfigured(void) qCDebug(VideoSettingsLog) << "Testing configuration for RTSP Stream:" << rtspUrl()->rawValue().toString(); return !rtspUrl()->rawValue().toString().isEmpty(); } + //-- If HTTP MJPEG, check for URL + if (vSource == videoSourceHTTPMJPEG) { + qCDebug(VideoSettingsLog) << "Testing configuration for HTTP MJPEG Stream"; + return !httpMjpegUrl()->rawValue().toString().isEmpty(); + } //-- If TCP, check for URL if(vSource == videoSourceTCP) { qCDebug(VideoSettingsLog) << "Testing configuration for TCP Stream:" << tcpUrl()->rawValue().toString(); diff --git a/src/Settings/VideoSettings.h b/src/Settings/VideoSettings.h index 6bd21c19b1b5..9b83cdbf30aa 100644 --- a/src/Settings/VideoSettings.h +++ b/src/Settings/VideoSettings.h @@ -17,6 +17,7 @@ class VideoSettings : public SettingsGroup DEFINE_SETTINGFACT(udpUrl) DEFINE_SETTINGFACT(tcpUrl) DEFINE_SETTINGFACT(rtspUrl) + DEFINE_SETTINGFACT(httpMjpegUrl) DEFINE_SETTINGFACT(aspectRatio) DEFINE_SETTINGFACT(videoFit) DEFINE_SETTINGFACT(gridLines) @@ -37,6 +38,7 @@ class VideoSettings : public SettingsGroup Q_PROPERTY(bool streamConfigured READ streamConfigured NOTIFY streamConfiguredChanged) Q_PROPERTY(QString rtspVideoSource READ rtspVideoSource CONSTANT) + Q_PROPERTY(QString httpMjpegVideoSource READ httpMjpegVideoSource CONSTANT) Q_PROPERTY(QString udp264VideoSource READ udp264VideoSource CONSTANT) Q_PROPERTY(QString udp265VideoSource READ udp265VideoSource CONSTANT) Q_PROPERTY(QString tcpVideoSource READ tcpVideoSource CONSTANT) @@ -45,6 +47,7 @@ class VideoSettings : public SettingsGroup bool streamConfigured (); QString rtspVideoSource () { return videoSourceRTSP; } + QString httpMjpegVideoSource () { return videoSourceHTTPMJPEG; } QString udp264VideoSource () { return videoSourceUDPH264; } QString udp265VideoSource () { return videoSourceUDPH265; } QString tcpVideoSource () { return videoSourceTCP; } @@ -59,6 +62,7 @@ class VideoSettings : public SettingsGroup static constexpr const char* videoSourceNoVideo = QT_TRANSLATE_NOOP("VideoSettings", "No Video Available"); static constexpr const char* videoDisabled = QT_TRANSLATE_NOOP("VideoSettings", "Video Stream Disabled"); static constexpr const char* videoSourceRTSP = QT_TRANSLATE_NOOP("VideoSettings", "RTSP Video Stream"); + static constexpr const char* videoSourceHTTPMJPEG = QT_TRANSLATE_NOOP("VideoSettings", "HTTP MJPEG Video Stream"); static constexpr const char* videoSourceUDPH264 = QT_TRANSLATE_NOOP("VideoSettings", "UDP h.264 Video Stream"); static constexpr const char* videoSourceUDPH265 = QT_TRANSLATE_NOOP("VideoSettings", "UDP h.265 Video Stream"); static constexpr const char* videoSourceTCP = QT_TRANSLATE_NOOP("VideoSettings", "TCP-MPEG2 Video Stream"); diff --git a/src/VideoManager/VideoManager.cc b/src/VideoManager/VideoManager.cc index 72d47e207f7d..fe3c19c7eef1 100644 --- a/src/VideoManager/VideoManager.cc +++ b/src/VideoManager/VideoManager.cc @@ -177,6 +177,7 @@ void VideoManager::init(QQuickWindow *mainWindow) (void) connect(_videoSettings->videoSource(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->udpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->rtspUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->httpMjpegUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->tcpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->aspectRatio(), &Fact::rawValueChanged, this, &VideoManager::aspectRatioChanged); (void) connect(_videoSettings->lowLatencyMode(), &Fact::rawValueChanged, this, [this](const QVariant &value) { Q_UNUSED(value); _restartAllVideos(); }); @@ -500,6 +501,7 @@ bool VideoManager::isStreamSource() const VideoSettings::videoSourceUDPH264, VideoSettings::videoSourceUDPH265, VideoSettings::videoSourceRTSP, + VideoSettings::videoSourceHTTPMJPEG, VideoSettings::videoSourceTCP, VideoSettings::videoSourceMPEGTS, VideoSettings::videoSource3DRSolo, @@ -701,6 +703,8 @@ bool VideoManager::_updateSettings(VideoReceiver *receiver) settingsChanged |= _updateVideoUri(receiver, QStringLiteral("mpegts://%1").arg(_videoSettings->udpUrl()->rawValue().toString())); } else if (source == VideoSettings::videoSourceRTSP) { settingsChanged |= _updateVideoUri(receiver, _videoSettings->rtspUrl()->rawValue().toString()); + } else if (source == VideoSettings::videoSourceHTTPMJPEG) { + settingsChanged |= _updateVideoUri(receiver, _videoSettings->httpMjpegUrl()->rawValue().toString()); } else if (source == VideoSettings::videoSourceTCP) { settingsChanged |= _updateVideoUri(receiver, QStringLiteral("tcp://%1").arg(_videoSettings->tcpUrl()->rawValue().toString())); } else if (source == VideoSettings::videoSource3DRSolo) { @@ -842,7 +846,10 @@ void VideoManager::_startReceiver(VideoReceiver *receiver) } const QString source = _videoSettings->videoSource()->rawValue().toString(); - const uint32_t timeout = ((source == VideoSettings::videoSourceRTSP) ? _videoSettings->rtspTimeout()->rawValue().toUInt() : 3); + const bool usesNetworkTimeout = + (source == VideoSettings::videoSourceRTSP) || (source == VideoSettings::videoSourceHTTPMJPEG); + // Keep the existing Fact/persistence key for compatibility while its UI meaning expands to timeout-based sources. + const uint32_t timeout = usesNetworkTimeout ? _videoSettings->rtspTimeout()->rawValue().toUInt() : 3; receiver->start(timeout); } diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index d5a161ab464b..cca1589e036d 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -421,6 +422,118 @@ GstElement* buildUdpSource(const QUrl& sourceUrl, bool isUdpH264, bool isUdpH265 return source; } +void linkMultipartJpegPad(GstElement* element, GstPad* pad, gpointer data) +{ + GstElement* parser = GST_ELEMENT(data); + if (!element || !pad || !parser || (GST_PAD_DIRECTION(pad) != GST_PAD_SRC)) { + return; + } + + GstCaps* jpegCaps = gst_caps_from_string("image/jpeg"); + GstCaps* padCaps = gst_pad_get_current_caps(pad); + if (!padCaps) { + padCaps = gst_pad_query_caps(pad, nullptr); + } + const bool isJpeg = jpegCaps && padCaps && gst_caps_can_intersect(padCaps, jpegCaps); + gst_clear_caps(&padCaps); + gst_clear_caps(&jpegCaps); + if (!isJpeg) { + return; + } + + GstPad* parserSink = gst_element_get_static_pad(parser, "sink"); + if (!parserSink) { + qCWarning(GstSourceFactoryLog) << "HTTP MJPEG parser sink pad is unavailable"; + return; + } + + if (!gst_pad_is_linked(parserSink)) { + const GstPadLinkReturn result = gst_pad_link(pad, parserSink); + if (result != GST_PAD_LINK_OK) { + qCWarning(GstSourceFactoryLog) << "HTTP MJPEG demux/parser link failed:" << result; + } + } + gst_object_unref(parserSink); +} + +GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) +{ + if (!sourceUrl.isValid() || sourceUrl.isRelative() || sourceUrl.host().isEmpty() || (sourceUrl.port() == 0)) { + qCWarning(GstSourceFactoryLog) << "Invalid HTTP MJPEG URL:" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); + return nullptr; + } + if (!sourceUrl.userInfo().isEmpty()) { + qCWarning(GstSourceFactoryLog) << "HTTP MJPEG credentials in URLs are not supported"; + return nullptr; + } + + GstElement* source = gst_element_factory_make("souphttpsrc", "source"); + GstElement* demux = gst_element_factory_make("multipartdemux", "multipart-demux"); + GstElement* parser = gst_element_factory_make("jpegparse", "jpeg-parser"); + GstElement* bin = gst_bin_new("sourcebin"); + GstElement* sourceBin = nullptr; + + do { + if (!source || !demux || !parser || !bin) { + qCWarning(GstSourceFactoryLog) << "HTTP MJPEG requires souphttpsrc, multipartdemux, and jpegparse"; + break; + } + + QUrl cleanUrl(sourceUrl); + cleanUrl.setUserInfo(QString()); + cleanUrl.setFragment(QString()); + const QByteArray location = cleanUrl.toEncoded(QUrl::FullyEncoded); + const QByteArray userAgent = QGCNetworkHelper::defaultUserAgent().toUtf8(); + const guint timeoutS = std::clamp(config.timeoutS, 1u, 3600u); + g_object_set(source, "location", location.constData(), "method", "GET", "is-live", TRUE, "do-timestamp", TRUE, + "keep-alive", TRUE, "compress", FALSE, "iradio-mode", FALSE, "automatic-redirect", FALSE, + "retries", 0, "timeout", timeoutS, "ssl-strict", TRUE, "ssl-use-system-ca-file", TRUE, + "http-log-level", 0, "user-agent", userAgent.constData(), nullptr); + g_object_set(demux, "single-stream", TRUE, nullptr); + + if (!gst_bin_add(GST_BIN(bin), source)) { + qCWarning(GstSourceFactoryLog) << "Failed to add HTTP source to source bin"; + break; + } + GstElement* binSource = source; + source = nullptr; + + if (!gst_bin_add(GST_BIN(bin), demux)) { + qCWarning(GstSourceFactoryLog) << "Failed to add multipart demuxer to source bin"; + break; + } + GstElement* binDemux = demux; + demux = nullptr; + + if (!gst_bin_add(GST_BIN(bin), parser)) { + qCWarning(GstSourceFactoryLog) << "Failed to add JPEG parser to source bin"; + break; + } + GstElement* binParser = parser; + parser = nullptr; + + if (!gst_element_link(binSource, binDemux)) { + qCWarning(GstSourceFactoryLog) << "Failed to link HTTP source to multipart demuxer"; + break; + } + (void) g_signal_connect_object(binDemux, "pad-added", G_CALLBACK(linkMultipartJpegPad), binParser, + G_CONNECT_DEFAULT); + if (!addStaticGhostPad(binParser)) { + break; + } + + sourceBin = bin; + bin = nullptr; + } while (false); + + gst_clear_object(&bin); + gst_clear_object(&parser); + gst_clear_object(&demux); + gst_clear_object(&source); + return sourceBin; +} + // Wire upstream → (optional rtpjitterbuffer) → binParser, topology chosen by RTP probe (MPEG-TS // links via pad-added). Created elements join @p bin; returns false (logged) on failure. bool linkSourceToParser(GstElement* bin, GstElement* upstream, GstElement* binParser, const Config& config, @@ -526,12 +639,17 @@ GstElement* create(const QString& uri, const Config& config) const bool isUdpH265 = (scheme == QLatin1String("udp265")); const bool isUdpMPEGTS = (scheme == QLatin1String("mpegts")); const bool isTcpMPEGTS = (scheme == QLatin1String("tcp")); + const bool isHttpMjpeg = (scheme == QLatin1String("http")) || (scheme == QLatin1String("https")); - if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS) { + if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS && !isHttpMjpeg) { qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); return nullptr; } + if (isHttpMjpeg) { + return buildHttpMjpegSource(sourceUrl, config); + } + // Owning locals until gst_bin_add*, then nulled (non-owning alias used downstream) so the // unconditional gst_clear_object cleanup at the bottom stays safe. GstElement* source = nullptr; diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h index 5b996afe2643..d89ff9b21045 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace GStreamer::SourceFactory { @@ -22,15 +23,17 @@ struct Config JitterBuffer jitterBuffer = JitterBuffer::DropOnLatency; int latencyMs = 80; bool doRetransmission = true; + /// Blocking network I/O timeout for source elements that expose one, in seconds. + uint32_t timeoutS = 8; }; -/// Build a source bin (`source` [+ `tsdemux`] [+ `rtpjitterbuffer`] + `parsebin`) -/// for `uri`. Supported schemes: rtsp/rtspt, tcp:// (MPEG-TS), udp:// (H.264 RTP), -/// udp265:// (H.265 RTP), mpegts:// (MPEG-TS over UDP). +/// Build a source bin that exposes parsed encoded video for `uri`. +/// Supported schemes: rtsp/rtspt, tcp:// (MPEG-TS), udp:// (H.264 RTP), +/// udp265:// (H.265 RTP), mpegts:// (MPEG-TS over UDP), and http(s):// +/// (multipart MJPEG). /// -/// Ghost pads on the returned bin are wired lazily; for `rtspsrc`/`tsdemux`/`parsebin` -/// they appear only after upstream produces pads, so callers must connect any -/// downstream `pad-added` handlers before transitioning to PLAYING. +/// Ghost pads on RTP/MPEG-TS bins are wired lazily after upstream produces pads. +/// The HTTP MJPEG bin exposes its parsed-JPEG pad immediately. /// /// Returns the source bin or nullptr on failure. GstElement* create(const QString& uri, const Config& config); diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc index 395626a6a525..4cd862962c57 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc @@ -223,6 +223,7 @@ void GstVideoReceiver::start(uint32_t timeout) ? GStreamer::SourceFactory::JitterBuffer::DropOnLatency : GStreamer::SourceFactory::JitterBuffer::Buffered); sourceConfig.latencyMs = _rtpJitterLatencyMs; + sourceConfig.timeoutS = timeout; // do-retransmission needs ≥40 ms latency headroom over the default 20 ms rtx-delay; // forcibly disable for sub-frame latency configurations to avoid retransmit storms. sourceConfig.doRetransmission = (_rtpJitterLatencyMs >= 40) && (sourceConfig.jitterBuffer != GStreamer::SourceFactory::JitterBuffer::None); diff --git a/test/VideoManager/GStreamer/GStreamerTest.cc b/test/VideoManager/GStreamer/GStreamerTest.cc index 4d4d77713e3f..e53f0174bbab 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.cc +++ b/test/VideoManager/GStreamer/GStreamerTest.cc @@ -904,6 +904,9 @@ QGC_GST_SKIP_TEST(_testSourceFactoryJitterBufferNone) QGC_GST_SKIP_TEST(_testSourceFactoryNoRetransmission) QGC_GST_SKIP_TEST(_testSourceFactoryRtspJitterBufferPolicy_data) QGC_GST_SKIP_TEST(_testSourceFactoryRtspJitterBufferPolicy) +QGC_GST_SKIP_TEST(_testSourceFactoryHttpMjpeg) +QGC_GST_SKIP_TEST(_testSourceFactoryHttpMjpegDelivery) +QGC_GST_SKIP_TEST(_testSourceFactoryRejectsUnsafeHttpMjpegUrl) QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadUri) QGC_GST_SKIP_TEST(_testSourceFactoryTcpMpegTs) QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadTcpUri) diff --git a/test/VideoManager/GStreamer/GStreamerTest.h b/test/VideoManager/GStreamer/GStreamerTest.h index a2a3db99ca20..003fc1b4033a 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.h +++ b/test/VideoManager/GStreamer/GStreamerTest.h @@ -88,6 +88,9 @@ private slots: void _testSourceFactoryNoRetransmission(); void _testSourceFactoryRtspJitterBufferPolicy_data(); void _testSourceFactoryRtspJitterBufferPolicy(); + void _testSourceFactoryHttpMjpeg(); + void _testSourceFactoryHttpMjpegDelivery(); + void _testSourceFactoryRejectsUnsafeHttpMjpegUrl(); void _testSourceFactoryRejectsBadUri(); void _testSourceFactoryTcpMpegTs(); void _testSourceFactoryRejectsBadTcpUri(); diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index c7228756a472..b9700a621df2 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -2,14 +2,27 @@ #ifdef QGC_GST_STREAMING +#include #include #include +#include +#include #include #include "GstSourceFactory.h" +#include "LocalHttpTestServer.h" +#include "QGCNetworkHelper.h" namespace { +GstSample* tryPullSampleOrPreroll(GstAppSink* sink) +{ + if (GstSample* sample = gst_app_sink_try_pull_sample(sink, 0)) { + return sample; + } + return gst_app_sink_try_pull_preroll(sink, 0); +} + // Borrowed (bin-owned) first child whose element-factory name matches, or nullptr. GstElement* findChildByFactoryName(GstElement* bin, const char* factoryName) { @@ -164,6 +177,174 @@ void GStreamerTest::_testSourceFactoryRtspJitterBufferPolicy() "rtspsrc owns its internal jitterbuffer; the factory must not add a second one"); } +void GStreamerTest::_testSourceFactoryHttpMjpeg() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse")) { + QSKIP("souphttpsrc/multipartdemux/jpegparse plugins unavailable"); + } + + GStreamer::SourceFactory::Config config; + config.timeoutS = 9; + GstElement* bin = GStreamer::SourceFactory::create( + QStringLiteral("https://video.example.test:8443/camera.mjpg?quality=80#local-view"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* source = findChildByFactoryName(bin, "souphttpsrc"); + GstElement* demux = findChildByFactoryName(bin, "multipartdemux"); + GstElement* parser = findChildByFactoryName(bin, "jpegparse"); + QVERIFY(source); + QVERIFY(demux); + QVERIFY(parser); + + gchar* location = nullptr; + gchar* method = nullptr; + gboolean isLive = FALSE; + gboolean doTimestamp = FALSE; + gboolean keepAlive = FALSE; + gboolean compress = TRUE; + gboolean iradioMode = TRUE; + gboolean automaticRedirect = TRUE; + gboolean sslStrict = FALSE; + gboolean sslUseSystemCaFile = FALSE; + gboolean singleStream = FALSE; + gint retries = -1; + guint timeout = 0; + gint httpLogLevel = -1; + gchar* userAgent = nullptr; + g_object_get(source, "location", &location, "method", &method, "is-live", &isLive, "do-timestamp", &doTimestamp, + "keep-alive", &keepAlive, "compress", &compress, "iradio-mode", &iradioMode, "automatic-redirect", + &automaticRedirect, "retries", &retries, "timeout", &timeout, "ssl-strict", &sslStrict, + "ssl-use-system-ca-file", &sslUseSystemCaFile, "http-log-level", &httpLogLevel, "user-agent", + &userAgent, nullptr); + const auto stringsCleanup = qScopeGuard([&] { + g_free(location); + g_free(method); + g_free(userAgent); + }); + g_object_get(demux, "single-stream", &singleStream, nullptr); + + QCOMPARE(QString::fromUtf8(location), QStringLiteral("https://video.example.test:8443/camera.mjpg?quality=80")); + QCOMPARE(QString::fromUtf8(method), QStringLiteral("GET")); + QCOMPARE(isLive, TRUE); + QCOMPARE(doTimestamp, TRUE); + QCOMPARE(keepAlive, TRUE); + QCOMPARE(compress, FALSE); + QCOMPARE(iradioMode, FALSE); + QCOMPARE(automaticRedirect, FALSE); + QCOMPARE(retries, 0); + QCOMPARE(timeout, 9u); + QCOMPARE(sslStrict, TRUE); + QCOMPARE(sslUseSystemCaFile, TRUE); + QCOMPARE(httpLogLevel, 0); + QCOMPARE(QString::fromUtf8(userAgent), QGCNetworkHelper::defaultUserAgent()); + QCOMPARE(singleStream, TRUE); + + static GstStaticPadTemplate jpegPadTemplate = + GST_STATIC_PAD_TEMPLATE("src_%u", GST_PAD_SRC, GST_PAD_SOMETIMES, GST_STATIC_CAPS("image/jpeg")); + GstPad* jpegPad = gst_pad_new_from_static_template(&jpegPadTemplate, "src_0"); + QVERIFY(jpegPad); + QVERIFY2(gst_element_add_pad(demux, jpegPad), "multipartdemux test pad must be accepted"); + + GstPad* parserSink = gst_element_get_static_pad(parser, "sink"); + QVERIFY(parserSink); + const auto parserSinkCleanup = qScopeGuard([&] { gst_object_unref(parserSink); }); + QVERIFY2(gst_pad_is_linked(parserSink), "multipart JPEG pad-added must link to jpegparse"); + GstPad* peer = gst_pad_get_peer(parserSink); + QVERIFY(peer); + QCOMPARE(peer, jpegPad); + gst_object_unref(peer); + QVERIFY(gst_element_remove_pad(demux, jpegPad)); + + GstPad* srcPad = gst_element_get_static_pad(bin, "src"); + QVERIFY2(srcPad, "HTTP MJPEG source bin must expose a static parsed-JPEG source pad"); + gst_object_unref(srcPad); +} + +void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse") || !gst_element_factory_find("appsink")) { + QSKIP("souphttpsrc/multipartdemux/jpegparse/appsink plugins unavailable"); + } + + QByteArray jpeg; + QBuffer jpegBuffer(&jpeg); + QVERIFY(jpegBuffer.open(QIODevice::WriteOnly)); + QImage image(16, 16, QImage::Format_RGB32); + image.fill(Qt::green); + QVERIFY2(image.save(&jpegBuffer, "JPEG"), "Qt JPEG encoder unavailable"); + + const QByteArray boundary("qgc-test-boundary"); + const QByteArray framePart = "--" + boundary + + "\r\n" + "Content-Type: image/jpeg\r\n" + "Content-Length: " + + QByteArray::number(jpeg.size()) + "\r\n\r\n" + jpeg + "\r\n"; + // A multipart video stream contains repeated images. Supplying two also + // verifies delivery after multipartdemux exposes and links its dynamic pad. + const QByteArray body = framePart + framePart + "--" + boundary + "--\r\n"; + const QByteArray response = + "HTTP/1.1 200 OK\r\n" + "Content-Type: multipart/x-mixed-replace; boundary=" + + boundary + + "\r\n" + "Connection: close\r\n" + "Content-Length: " + + QByteArray::number(body.size()) + "\r\n\r\n" + body; + + TestFixtures::LocalHttpTestServer server; + QVERIFY2(server.listen(), "Could not start local MJPEG test server"); + server.installRawResponder(response); + + GStreamer::SourceFactory::Config config; + config.timeoutS = 5; + GstElement* source = GStreamer::SourceFactory::create(server.url(QStringLiteral("/video_feed")), config); + GstElement* sink = gst_element_factory_make("appsink", "sink"); + GstElement* pipeline = gst_pipeline_new("http-mjpeg-delivery-test"); + if (!source || !sink || !pipeline) { + gst_clear_object(&source); + gst_clear_object(&sink); + gst_clear_object(&pipeline); + QFAIL("Could not create HTTP MJPEG delivery pipeline"); + } + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + g_object_set(sink, "sync", FALSE, "max-buffers", 1u, "drop", TRUE, nullptr); + gst_bin_add_many(GST_BIN(pipeline), source, sink, nullptr); + QVERIFY2(gst_element_link(source, sink), "Could not link HTTP MJPEG source to appsink"); + QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, + "HTTP MJPEG delivery pipeline failed to start"); + + GstSample* sample = nullptr; + QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::mediumMs()); + const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); + GstBuffer* buffer = gst_sample_get_buffer(sample); + QVERIFY(buffer); + QVERIFY(gst_buffer_get_size(buffer) > 0); + GstCaps* caps = gst_sample_get_caps(sample); + QVERIFY(caps); + QCOMPARE(QString::fromUtf8(gst_structure_get_name(gst_caps_get_structure(caps, 0))), QStringLiteral("image/jpeg")); +} + +void GStreamerTest::_testSourceFactoryRejectsUnsafeHttpMjpegUrl() +{ + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("Invalid HTTP MJPEG URL"))); + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("HTTP MJPEG credentials in URLs are not supported"))); + + GStreamer::SourceFactory::Config config; + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("http:///camera.mjpg"), config)); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("http://video.example.test:0/camera.mjpg"), config)); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("https://operator:secret@video.example.test/camera.mjpg"), + config)); +} + void GStreamerTest::_testSourceFactoryRejectsBadUri() { ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtCriticalMsg, From d7b14968da27fa8b113184e168a64cdd5156d507 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 08:12:58 +0000 Subject: [PATCH 02/16] fix(VideoManager): align HTTP video TLS and tests --- .../GStreamer/GstSourceFactory.cc | 7 +++++-- .../GStreamerSourceFactoryTest.cc | 20 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index cca1589e036d..9fc92cdd89eb 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -486,10 +486,13 @@ GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) const QByteArray location = cleanUrl.toEncoded(QUrl::FullyEncoded); const QByteArray userAgent = QGCNetworkHelper::defaultUserAgent().toUtf8(); const guint timeoutS = std::clamp(config.timeoutS, 1u, 3600u); + // ssl-strict keeps certificate validation enabled. The active GLib TLS + // backend supplies the platform trust database; the legacy + // ssl-use-system-ca-file property is a no-op with libsoup3. g_object_set(source, "location", location.constData(), "method", "GET", "is-live", TRUE, "do-timestamp", TRUE, "keep-alive", TRUE, "compress", FALSE, "iradio-mode", FALSE, "automatic-redirect", FALSE, - "retries", 0, "timeout", timeoutS, "ssl-strict", TRUE, "ssl-use-system-ca-file", TRUE, - "http-log-level", 0, "user-agent", userAgent.constData(), nullptr); + "retries", 0, "timeout", timeoutS, "ssl-strict", TRUE, "http-log-level", 0, "user-agent", + userAgent.constData(), nullptr); g_object_set(demux, "single-stream", TRUE, nullptr); if (!gst_bin_add(GST_BIN(bin), source)) { diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index b9700a621df2..0ba7f8d16b0a 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -207,7 +207,6 @@ void GStreamerTest::_testSourceFactoryHttpMjpeg() gboolean iradioMode = TRUE; gboolean automaticRedirect = TRUE; gboolean sslStrict = FALSE; - gboolean sslUseSystemCaFile = FALSE; gboolean singleStream = FALSE; gint retries = -1; guint timeout = 0; @@ -216,8 +215,7 @@ void GStreamerTest::_testSourceFactoryHttpMjpeg() g_object_get(source, "location", &location, "method", &method, "is-live", &isLive, "do-timestamp", &doTimestamp, "keep-alive", &keepAlive, "compress", &compress, "iradio-mode", &iradioMode, "automatic-redirect", &automaticRedirect, "retries", &retries, "timeout", &timeout, "ssl-strict", &sslStrict, - "ssl-use-system-ca-file", &sslUseSystemCaFile, "http-log-level", &httpLogLevel, "user-agent", - &userAgent, nullptr); + "http-log-level", &httpLogLevel, "user-agent", &userAgent, nullptr); const auto stringsCleanup = qScopeGuard([&] { g_free(location); g_free(method); @@ -236,7 +234,6 @@ void GStreamerTest::_testSourceFactoryHttpMjpeg() QCOMPARE(retries, 0); QCOMPARE(timeout, 9u); QCOMPARE(sslStrict, TRUE); - QCOMPARE(sslUseSystemCaFile, TRUE); QCOMPARE(httpLogLevel, 0); QCOMPARE(QString::fromUtf8(userAgent), QGCNetworkHelper::defaultUserAgent()); QCOMPARE(singleStream, TRUE); @@ -296,7 +293,6 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() TestFixtures::LocalHttpTestServer server; QVERIFY2(server.listen(), "Could not start local MJPEG test server"); - server.installRawResponder(response); GStreamer::SourceFactory::Config config; config.timeoutS = 5; @@ -320,6 +316,20 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, "HTTP MJPEG delivery pipeline failed to start"); + QTcpSocket* client = server.waitForConnection(TestTimeout::mediumMs()); + QVERIFY2(client, "HTTP MJPEG source did not connect to the local test server"); + const auto clientCleanup = qScopeGuard([&] { + client->disconnectFromHost(); + client->deleteLater(); + }); + if (client->bytesAvailable() == 0) { + QVERIFY2(client->waitForReadyRead(TestTimeout::mediumMs()), "HTTP MJPEG source did not send a request"); + } + const QByteArray request = client->readAll(); + QVERIFY2(request.startsWith("GET /video_feed HTTP/1.1\r\n"), "HTTP MJPEG source sent an unexpected request"); + QCOMPARE(client->write(response), response.size()); + QVERIFY2(client->waitForBytesWritten(TestTimeout::mediumMs()), "Could not deliver the local MJPEG response"); + GstSample* sample = nullptr; QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::mediumMs()); const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); From c7d33826de2b76b0324e534652637a0076834d8e Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 09:38:47 +0000 Subject: [PATCH 03/16] test(VideoManager): stabilize MJPEG delivery coverage --- .../SourceFactory/GStreamerSourceFactoryTest.cc | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 0ba7f8d16b0a..ce5a01576f8c 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -293,6 +293,7 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() TestFixtures::LocalHttpTestServer server; QVERIFY2(server.listen(), "Could not start local MJPEG test server"); + server.installRawResponder(response); GStreamer::SourceFactory::Config config; config.timeoutS = 5; @@ -316,22 +317,8 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, "HTTP MJPEG delivery pipeline failed to start"); - QTcpSocket* client = server.waitForConnection(TestTimeout::mediumMs()); - QVERIFY2(client, "HTTP MJPEG source did not connect to the local test server"); - const auto clientCleanup = qScopeGuard([&] { - client->disconnectFromHost(); - client->deleteLater(); - }); - if (client->bytesAvailable() == 0) { - QVERIFY2(client->waitForReadyRead(TestTimeout::mediumMs()), "HTTP MJPEG source did not send a request"); - } - const QByteArray request = client->readAll(); - QVERIFY2(request.startsWith("GET /video_feed HTTP/1.1\r\n"), "HTTP MJPEG source sent an unexpected request"); - QCOMPARE(client->write(response), response.size()); - QVERIFY2(client->waitForBytesWritten(TestTimeout::mediumMs()), "Could not deliver the local MJPEG response"); - GstSample* sample = nullptr; - QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::mediumMs()); + QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::longMs()); const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); GstBuffer* buffer = gst_sample_get_buffer(sample); QVERIFY(buffer); From 9c45eed439b96cd8eb586eadc83cdd5f4cec9c62 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 10:53:21 +0000 Subject: [PATCH 04/16] fix(VideoManager): support older GLib connect flags --- src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index 9fc92cdd89eb..69c5848bf629 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -521,7 +521,7 @@ GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) break; } (void) g_signal_connect_object(binDemux, "pad-added", G_CALLBACK(linkMultipartJpegPad), binParser, - G_CONNECT_DEFAULT); + static_cast(0)); if (!addStaticGhostPad(binParser)) { break; } From 76ba8f4a7dc235ec93517356ed7f78ac7bbd84b3 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 11:28:02 +0000 Subject: [PATCH 05/16] test(VideoManager): keep MJPEG fixture responsive --- .../SourceFactory/GStreamerSourceFactoryTest.cc | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index ce5a01576f8c..1a05e8b6f249 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -314,11 +315,18 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() g_object_set(sink, "sync", FALSE, "max-buffers", 1u, "drop", TRUE, nullptr); gst_bin_add_many(GST_BIN(pipeline), source, sink, nullptr); QVERIFY2(gst_element_link(source, sink), "Could not link HTTP MJPEG source to appsink"); - QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, + + // souphttpsrc can wait for the HTTP response while changing state. Run that + // transition off-thread so this test's Qt event loop can serve the request. + const QFuture stateFuture = + QtConcurrent::run([pipeline]() { return gst_element_set_state(pipeline, GST_STATE_PLAYING); }); + QTRY_VERIFY_WITH_TIMEOUT(stateFuture.isFinished(), TestTimeout::mediumMs()); + QVERIFY2(stateFuture.result() != GST_STATE_CHANGE_FAILURE, "HTTP MJPEG delivery pipeline failed to start"); GstSample* sample = nullptr; - QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::longMs()); + QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, + TestTimeout::mediumMs()); const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); GstBuffer* buffer = gst_sample_get_buffer(sample); QVERIFY(buffer); From b9619ee3c18c2c2dc2889d41d44690d359a5ff96 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 15:45:03 +0000 Subject: [PATCH 06/16] test(VideoManager): serve MJPEG delivery synchronously --- .../GStreamerSourceFactoryTest.cc | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 1a05e8b6f249..5994582c60f3 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -2,10 +2,11 @@ #ifdef QGC_GST_STREAMING +#include #include +#include #include #include -#include #include #include #include @@ -16,9 +17,9 @@ namespace { -GstSample* tryPullSampleOrPreroll(GstAppSink* sink) +GstSample* tryPullSampleOrPreroll(GstAppSink* sink, GstClockTime timeout = 0) { - if (GstSample* sample = gst_app_sink_try_pull_sample(sink, 0)) { + if (GstSample* sample = gst_app_sink_try_pull_sample(sink, timeout)) { return sample; } return gst_app_sink_try_pull_preroll(sink, 0); @@ -280,21 +281,18 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() "Content-Type: image/jpeg\r\n" "Content-Length: " + QByteArray::number(jpeg.size()) + "\r\n\r\n" + jpeg + "\r\n"; - // A multipart video stream contains repeated images. Supplying two also - // verifies delivery after multipartdemux exposes and links its dynamic pad. - const QByteArray body = framePart + framePart + "--" + boundary + "--\r\n"; + // Keep the multipart response open like a camera stream so delivery does + // not depend on the timing of a finite response reaching EOS. const QByteArray response = "HTTP/1.1 200 OK\r\n" "Content-Type: multipart/x-mixed-replace; boundary=" + boundary + "\r\n" - "Connection: close\r\n" - "Content-Length: " + - QByteArray::number(body.size()) + "\r\n\r\n" + body; + "Connection: keep-alive\r\n\r\n" + + framePart + framePart; TestFixtures::LocalHttpTestServer server; QVERIFY2(server.listen(), "Could not start local MJPEG test server"); - server.installRawResponder(response); GStreamer::SourceFactory::Config config; config.timeoutS = 5; @@ -317,16 +315,40 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() QVERIFY2(gst_element_link(source, sink), "Could not link HTTP MJPEG source to appsink"); // souphttpsrc can wait for the HTTP response while changing state. Run that - // transition off-thread so this test's Qt event loop can serve the request. + // transition off-thread so this thread can accept and serve the request. const QFuture stateFuture = QtConcurrent::run([pipeline]() { return gst_element_set_state(pipeline, GST_STATE_PLAYING); }); + + QTcpSocket* client = server.waitForConnection(TestTimeout::mediumMs()); + QVERIFY2(client, "HTTP MJPEG source did not connect to the local test server"); + const auto clientCleanup = qScopeGuard([&] { + client->disconnectFromHost(); + client->deleteLater(); + }); + + QByteArray request; + QDeadlineTimer requestDeadline(TestTimeout::mediumDuration()); + while (!request.contains(QByteArrayLiteral("\r\n\r\n")) && !requestDeadline.hasExpired()) { + request.append(client->readAll()); + if (!request.contains(QByteArrayLiteral("\r\n\r\n"))) { + (void) client->waitForReadyRead(static_cast(requestDeadline.remainingTime())); + } + } + QVERIFY2(request.startsWith(QByteArrayLiteral("GET /video_feed HTTP/1.1\r\n")), + "HTTP MJPEG source sent an unexpected request"); + + QCOMPARE(client->write(response), static_cast(response.size())); + QDeadlineTimer responseDeadline(TestTimeout::mediumDuration()); + while ((client->bytesToWrite() > 0) && !responseDeadline.hasExpired()) { + (void) client->waitForBytesWritten(static_cast(responseDeadline.remainingTime())); + } + QCOMPARE(client->bytesToWrite(), 0); + QTRY_VERIFY_WITH_TIMEOUT(stateFuture.isFinished(), TestTimeout::mediumMs()); - QVERIFY2(stateFuture.result() != GST_STATE_CHANGE_FAILURE, - "HTTP MJPEG delivery pipeline failed to start"); + QVERIFY2(stateFuture.result() != GST_STATE_CHANGE_FAILURE, "HTTP MJPEG delivery pipeline failed to start"); - GstSample* sample = nullptr; - QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, - TestTimeout::mediumMs()); + GstSample* sample = tryPullSampleOrPreroll(GST_APP_SINK(sink), TestTimeout::mediumDuration().count() * GST_MSECOND); + QVERIFY2(sample, "HTTP MJPEG stream did not deliver a JPEG sample before timeout"); const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); GstBuffer* buffer = gst_sample_get_buffer(sample); QVERIFY(buffer); From 5d29e216306c320c1e48a48526ffad70a0f7b07f Mon Sep 17 00:00:00 2001 From: alireza787b Date: Fri, 31 Jul 2026 01:23:25 +0000 Subject: [PATCH 07/16] style(VideoManager): format source factory changes --- src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index 69c5848bf629..d518730fa63c 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -645,7 +645,8 @@ GstElement* create(const QString& uri, const Config& config) const bool isHttpMjpeg = (scheme == QLatin1String("http")) || (scheme == QLatin1String("https")); if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS && !isHttpMjpeg) { - qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" + << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); return nullptr; } From d5ab4fbd5980522a788cb186c17c424d51b2ca8c Mon Sep 17 00:00:00 2001 From: alireza787b Date: Thu, 20 Aug 2026 09:12:11 +0000 Subject: [PATCH 08/16] fix(VideoManager): keep HTTP stack independent --- src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index d518730fa63c..22f657f59597 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -8,6 +8,7 @@ #include "GStreamerHelpers.h" #include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" QGC_LOGGING_CATEGORY(GstSourceFactoryLog, "Video.GStreamer.GstSourceFactory") @@ -459,8 +460,7 @@ void linkMultipartJpegPad(GstElement* element, GstPad* pad, gpointer data) GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) { if (!sourceUrl.isValid() || sourceUrl.isRelative() || sourceUrl.host().isEmpty() || (sourceUrl.port() == 0)) { - qCWarning(GstSourceFactoryLog) << "Invalid HTTP MJPEG URL:" - << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); + qCWarning(GstSourceFactoryLog) << "Invalid HTTP MJPEG URL"; return nullptr; } if (!sourceUrl.userInfo().isEmpty()) { From ed170f3092471b0116d49f18a8e221824d5ff26a Mon Sep 17 00:00:00 2001 From: alireza787b Date: Sun, 26 Jul 2026 12:47:05 +0000 Subject: [PATCH 09/16] feat(VideoManager): add WebSocket JPEG video source --- CMakeLists.txt | 1 + .../qgc-user-guide/settings_view/general.md | 4 +- docs/en/qgc-user-guide/settings_view/video.md | 11 +- src/AppSettings/pages/Video.SettingsUI.json | 6 +- src/Settings/Video.SettingsGroup.json | 13 +- src/Settings/VideoSettings.cc | 16 + src/Settings/VideoSettings.h | 8 +- src/VideoManager/VideoManager.cc | 10 +- .../VideoReceiver/GStreamer/CMakeLists.txt | 4 + .../GStreamer/GstSourceFactory.cc | 106 +++- .../GStreamer/GstSourceFactory.h | 12 +- .../GStreamer/GstVideoReceiver.cc | 54 ++- .../GStreamer/GstVideoReceiver.h | 2 + .../GStreamer/QGCWebSocketVideoSource.cc | 449 +++++++++++++++++ .../GStreamer/QGCWebSocketVideoSource.h | 34 ++ test/VideoManager/GStreamer/GStreamerTest.cc | 9 + test/VideoManager/GStreamer/GStreamerTest.h | 9 + .../GStreamerSourceFactoryTest.cc | 451 ++++++++++++++++++ 18 files changed, 1174 insertions(+), 25 deletions(-) create mode 100644 src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc create mode 100644 src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 32800cf2c9e6..0ff3ba9afe37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -256,6 +256,7 @@ find_package(Qt6 Sql Svg TextToSpeech + WebSockets Xml Quick3D StateMachine diff --git a/docs/en/qgc-user-guide/settings_view/general.md b/docs/en/qgc-user-guide/settings_view/general.md index 045e7f030a59..f1b672c84496 100644 --- a/docs/en/qgc-user-guide/settings_view/general.md +++ b/docs/en/qgc-user-guide/settings_view/general.md @@ -201,7 +201,7 @@ The _Video_ section is used to define the source and connection settings for vid The settings are: -- **Video Source**: Video Stream Disabled | RTSP Video Stream | HTTP MJPEG Video Stream | UDP h.264 Video Stream | UDP h.265 Video Stream | TCP-MPEG2 Video Stream | MPEG-TS Video Stream | Integrated Camera +- **Video Source**: Video Stream Disabled | RTSP Video Stream | HTTP MJPEG Video Stream | WebSocket JPEG Video Stream | UDP h.264 Video Stream | UDP h.265 Video Stream | TCP-MPEG2 Video Stream | MPEG-TS Video Stream | Integrated Camera ::: info If no video source is specified then no other video or _video recording_ settings will be displayed. @@ -210,6 +210,8 @@ The settings are: - **URL/Port**: Connection type-specific stream address (may be port or URL). HTTP MJPEG requires a full `http://` or `https://` URL serving `multipart/x-mixed-replace` JPEG frames. + WebSocket JPEG requires a full `ws://` or `wss://` URL and one complete JPEG + image in each binary message. - **Aspect Ratio**: Aspect ratio for scaling video in video widget (set to 0.0 to ignore scaling) - **Disabled When Disarmed**: Disable video feed when vehicle is disarmed. - **Low Latency Mode**: Enabling low latency mode reduces the video stream latency, but may cause frame loss and choppy video (especially with a poor network connection). diff --git a/docs/en/qgc-user-guide/settings_view/video.md b/docs/en/qgc-user-guide/settings_view/video.md index 0c8412330da1..fdb7782885ea 100644 --- a/docs/en/qgc-user-guide/settings_view/video.md +++ b/docs/en/qgc-user-guide/settings_view/video.md @@ -4,7 +4,7 @@ Configure video streaming and recording settings. ## Video Source -- **Source** — Video Stream Disabled / RTSP Video Stream / HTTP MJPEG Video Stream / UDP h.264 / UDP h.265 / TCP-MPEG2 / MPEG-TS / Integrated Camera +- **Source** — Video Stream Disabled / RTSP Video Stream / HTTP MJPEG Video Stream / WebSocket JPEG Video Stream / UDP h.264 / UDP h.265 / TCP-MPEG2 / MPEG-TS / Integrated Camera ## Connection @@ -12,6 +12,7 @@ Connection settings vary by source type: - **RTSP URL** — full RTSP stream address - **HTTP MJPEG URL** — full `http://` or `https://` address of a multipart MJPEG stream +- **WebSocket JPEG URL** — full `ws://` or `wss://` address of a JPEG message stream - **TCP URL** — TCP stream address - **UDP URL** — UDP stream address and port (default: `0.0.0.0:5600`) @@ -20,6 +21,14 @@ It does not accept a web page, a single JPEG URL, or an arbitrary HTTP video fil HTTPS certificate validation remains enabled in GStreamer, redirects are not followed, and URL user information (`user:password@host`) is rejected. +WebSocket JPEG expects exactly one complete JPEG image in each binary message. +Text messages are ignored. Messages larger than 16 MiB, images over 16,384 +pixels in either dimension, and images over 64 megapixels are rejected. The +receiver drops older queued images when decoding falls behind. `wss://` +certificates are verified using QGroundControl's configured trust store. +Credentials embedded in the URL are rejected. Use unencrypted `ws://` only on +a trusted network. + ## Settings - **Aspect Ratio** — aspect ratio for scaling video in the display widget (default: 16:9; set to 0.0 to disable scaling) diff --git a/src/AppSettings/pages/Video.SettingsUI.json b/src/AppSettings/pages/Video.SettingsUI.json index 9645d02988b2..eef77bef78a1 100644 --- a/src/AppSettings/pages/Video.SettingsUI.json +++ b/src/AppSettings/pages/Video.SettingsUI.json @@ -23,7 +23,7 @@ }, { "heading": "Connection", - "keywords": ["rtsp", "http", "https", "mjpeg", "jpeg", "tcp", "udp", "mpegts", "video url", "stream url"], + "keywords": ["rtsp", "http", "https", "mjpeg", "websocket", "ws", "wss", "jpeg", "tcp", "udp", "mpegts", "video url", "stream url"], "showWhen": "!sourceDisabled && !autoStreamConfig", "controls": [ { @@ -34,6 +34,10 @@ "setting": "videoSettings.httpMjpegUrl", "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.httpMjpegVideoSource" }, + { + "setting": "videoSettings.webSocketJpegUrl", + "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.webSocketJpegVideoSource" + }, { "setting": "videoSettings.tcpUrl", "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.tcpVideoSource" diff --git a/src/Settings/Video.SettingsGroup.json b/src/Settings/Video.SettingsGroup.json index 84df70c0b91d..259218d6c9e7 100644 --- a/src/Settings/Video.SettingsGroup.json +++ b/src/Settings/Video.SettingsGroup.json @@ -4,8 +4,8 @@ "QGC.MetaData.Facts": [ { "name": "videoSource", - "shortDesc": "Source for video stream (UDP, TCP, RTSP, HTTP MJPEG, or connected USB camera).", - "longDesc": "Source for video. UDP, TCP, RTSP, HTTP multipart MJPEG and UVC cameras may be supported depending on the ground station build.", + "shortDesc": "Source for video stream (UDP, TCP, RTSP, HTTP MJPEG, WebSocket JPEG, or connected USB camera).", + "longDesc": "Source for video. UDP, TCP, RTSP, HTTP multipart MJPEG, WebSocket JPEG and UVC cameras may be supported depending on the ground station build.", "type": "string", "default": "", "label": "Source", @@ -38,6 +38,15 @@ "label": "HTTP MJPEG URL", "keywords": "http,https,mjpeg,jpeg,video url,stream url" }, + { + "name": "webSocketJpegUrl", + "shortDesc": "Full URL for a WebSocket JPEG stream.", + "longDesc": "Full ws:// or wss:// URL. Each binary WebSocket message must contain exactly one complete JPEG image. Text messages are ignored. URL-embedded credentials are not supported.", + "type": "string", + "default": "", + "label": "WebSocket JPEG URL", + "keywords": "websocket,ws,wss,jpeg,video url,stream url" + }, { "name": "tcpUrl", "shortDesc": "Network address and port for TCP video stream (e.g. 192.168.143.200:3001).", diff --git a/src/Settings/VideoSettings.cc b/src/Settings/VideoSettings.cc index 8d4b2fb8ad49..30cd0c496347 100644 --- a/src/Settings/VideoSettings.cc +++ b/src/Settings/VideoSettings.cc @@ -22,6 +22,7 @@ DECLARE_SETTINGGROUP(Video, "Video") videoSourceList.append(videoSourceRTSP); if (kGstEnabled) { videoSourceList.append(videoSourceHTTPMJPEG); + videoSourceList.append(videoSourceWebSocketJPEG); } videoSourceList.append(videoSourceUDPH264); videoSourceList.append(videoSourceUDPH265); @@ -232,6 +233,15 @@ DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, httpMjpegUrl) return _httpMjpegUrlFact; } +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, webSocketJpegUrl) +{ + if (!_webSocketJpegUrlFact) { + _webSocketJpegUrlFact = _createSettingsFact(webSocketJpegUrlName); + connect(_webSocketJpegUrlFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _webSocketJpegUrlFact; +} + DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, tcpUrl) { if (!_tcpUrlFact) { @@ -268,6 +278,12 @@ bool VideoSettings::streamConfigured(void) qCDebug(VideoSettingsLog) << "Testing configuration for HTTP MJPEG Stream"; return !httpMjpegUrl()->rawValue().toString().isEmpty(); } + //-- If WebSocket JPEG, check for URL + if (vSource == videoSourceWebSocketJPEG) { + qCDebug(VideoSettingsLog) << "Testing configuration for WebSocket JPEG Stream:" + << QGCNetworkHelper::redactedUrlForLogging(webSocketJpegUrl()->rawValue().toString()); + return !webSocketJpegUrl()->rawValue().toString().isEmpty(); + } //-- If TCP, check for URL if(vSource == videoSourceTCP) { qCDebug(VideoSettingsLog) << "Testing configuration for TCP Stream:" << tcpUrl()->rawValue().toString(); diff --git a/src/Settings/VideoSettings.h b/src/Settings/VideoSettings.h index 9b83cdbf30aa..ee597a1e3a3e 100644 --- a/src/Settings/VideoSettings.h +++ b/src/Settings/VideoSettings.h @@ -18,6 +18,7 @@ class VideoSettings : public SettingsGroup DEFINE_SETTINGFACT(tcpUrl) DEFINE_SETTINGFACT(rtspUrl) DEFINE_SETTINGFACT(httpMjpegUrl) + DEFINE_SETTINGFACT(webSocketJpegUrl) DEFINE_SETTINGFACT(aspectRatio) DEFINE_SETTINGFACT(videoFit) DEFINE_SETTINGFACT(gridLines) @@ -39,6 +40,7 @@ class VideoSettings : public SettingsGroup Q_PROPERTY(bool streamConfigured READ streamConfigured NOTIFY streamConfiguredChanged) Q_PROPERTY(QString rtspVideoSource READ rtspVideoSource CONSTANT) Q_PROPERTY(QString httpMjpegVideoSource READ httpMjpegVideoSource CONSTANT) + Q_PROPERTY(QString webSocketJpegVideoSource READ webSocketJpegVideoSource CONSTANT) Q_PROPERTY(QString udp264VideoSource READ udp264VideoSource CONSTANT) Q_PROPERTY(QString udp265VideoSource READ udp265VideoSource CONSTANT) Q_PROPERTY(QString tcpVideoSource READ tcpVideoSource CONSTANT) @@ -47,7 +49,9 @@ class VideoSettings : public SettingsGroup bool streamConfigured (); QString rtspVideoSource () { return videoSourceRTSP; } - QString httpMjpegVideoSource () { return videoSourceHTTPMJPEG; } + QString httpMjpegVideoSource() { return videoSourceHTTPMJPEG; } + + QString webSocketJpegVideoSource() { return videoSourceWebSocketJPEG; } QString udp264VideoSource () { return videoSourceUDPH264; } QString udp265VideoSource () { return videoSourceUDPH265; } QString tcpVideoSource () { return videoSourceTCP; } @@ -63,6 +67,8 @@ class VideoSettings : public SettingsGroup static constexpr const char* videoDisabled = QT_TRANSLATE_NOOP("VideoSettings", "Video Stream Disabled"); static constexpr const char* videoSourceRTSP = QT_TRANSLATE_NOOP("VideoSettings", "RTSP Video Stream"); static constexpr const char* videoSourceHTTPMJPEG = QT_TRANSLATE_NOOP("VideoSettings", "HTTP MJPEG Video Stream"); + static constexpr const char* videoSourceWebSocketJPEG = + QT_TRANSLATE_NOOP("VideoSettings", "WebSocket JPEG Video Stream"); static constexpr const char* videoSourceUDPH264 = QT_TRANSLATE_NOOP("VideoSettings", "UDP h.264 Video Stream"); static constexpr const char* videoSourceUDPH265 = QT_TRANSLATE_NOOP("VideoSettings", "UDP h.265 Video Stream"); static constexpr const char* videoSourceTCP = QT_TRANSLATE_NOOP("VideoSettings", "TCP-MPEG2 Video Stream"); diff --git a/src/VideoManager/VideoManager.cc b/src/VideoManager/VideoManager.cc index fe3c19c7eef1..681cf6b251a9 100644 --- a/src/VideoManager/VideoManager.cc +++ b/src/VideoManager/VideoManager.cc @@ -178,6 +178,8 @@ void VideoManager::init(QQuickWindow *mainWindow) (void) connect(_videoSettings->udpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->rtspUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->httpMjpegUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->webSocketJpegUrl(), &Fact::rawValueChanged, this, + &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->tcpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->aspectRatio(), &Fact::rawValueChanged, this, &VideoManager::aspectRatioChanged); (void) connect(_videoSettings->lowLatencyMode(), &Fact::rawValueChanged, this, [this](const QVariant &value) { Q_UNUSED(value); _restartAllVideos(); }); @@ -502,6 +504,7 @@ bool VideoManager::isStreamSource() const VideoSettings::videoSourceUDPH265, VideoSettings::videoSourceRTSP, VideoSettings::videoSourceHTTPMJPEG, + VideoSettings::videoSourceWebSocketJPEG, VideoSettings::videoSourceTCP, VideoSettings::videoSourceMPEGTS, VideoSettings::videoSource3DRSolo, @@ -705,6 +708,8 @@ bool VideoManager::_updateSettings(VideoReceiver *receiver) settingsChanged |= _updateVideoUri(receiver, _videoSettings->rtspUrl()->rawValue().toString()); } else if (source == VideoSettings::videoSourceHTTPMJPEG) { settingsChanged |= _updateVideoUri(receiver, _videoSettings->httpMjpegUrl()->rawValue().toString()); + } else if (source == VideoSettings::videoSourceWebSocketJPEG) { + settingsChanged |= _updateVideoUri(receiver, _videoSettings->webSocketJpegUrl()->rawValue().toString()); } else if (source == VideoSettings::videoSourceTCP) { settingsChanged |= _updateVideoUri(receiver, QStringLiteral("tcp://%1").arg(_videoSettings->tcpUrl()->rawValue().toString())); } else if (source == VideoSettings::videoSource3DRSolo) { @@ -846,8 +851,9 @@ void VideoManager::_startReceiver(VideoReceiver *receiver) } const QString source = _videoSettings->videoSource()->rawValue().toString(); - const bool usesNetworkTimeout = - (source == VideoSettings::videoSourceRTSP) || (source == VideoSettings::videoSourceHTTPMJPEG); + const bool usesNetworkTimeout = (source == VideoSettings::videoSourceRTSP) || + (source == VideoSettings::videoSourceHTTPMJPEG) || + (source == VideoSettings::videoSourceWebSocketJPEG); // Keep the existing Fact/persistence key for compatibility while its UI meaning expands to timeout-based sources. const uint32_t timeout = usesNetworkTimeout ? _videoSettings->rtspTimeout()->rawValue().toUInt() : 3; diff --git a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt index d41821af1943..a9eab2ad4232 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt +++ b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt @@ -107,8 +107,12 @@ target_sources(${CMAKE_PROJECT_NAME} GstVideoReceiver.h QGCQVideoSinkController.cc QGCQVideoSinkController.h + QGCWebSocketVideoSource.cc + QGCWebSocketVideoSource.h ) +target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE Qt6::WebSockets) + # iOS xcframework ships no CA list / no keychain-via-OpenSSL; bundle a Mozilla NSS extract for # libgioopenssl (fetched at configure time, IOS.cmake). On the app target for MACOSX_PACKAGE_LOCATION. if(IOS AND GStreamer_IOS_CA_BUNDLE) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index 22f657f59597..b6fd248dfe21 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -3,12 +3,14 @@ #include #include #include +#include #include #include #include "GStreamerHelpers.h" #include "QGCLoggingCategory.h" #include "QGCNetworkHelper.h" +#include "QGCWebSocketVideoSource.h" QGC_LOGGING_CATEGORY(GstSourceFactoryLog, "Video.GStreamer.GstSourceFactory") @@ -17,6 +19,7 @@ namespace { constexpr guint64 kRtspTcpTimeoutUs = G_GUINT64_CONSTANT(5000000); constexpr int kRtspRetry = 3; constexpr int kUdpBufferSizeBytes = 8 * 1024 * 1024; +constexpr char kWebSocketSourceContextKey[] = "qgc-websocket-source-context"; void configureH26xParser(GstElement* element) { @@ -537,6 +540,79 @@ GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) return sourceBin; } +GstElement* buildWebSocketJpegSource(const QUrl& sourceUrl) +{ + if (!sourceUrl.isValid() || sourceUrl.isRelative() || sourceUrl.host().isEmpty() || (sourceUrl.port() == 0)) { + qCWarning(GstSourceFactoryLog) << "Invalid WebSocket JPEG URL:" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); + return nullptr; + } + if (!sourceUrl.userInfo().isEmpty()) { + qCWarning(GstSourceFactoryLog) << "WebSocket JPEG credentials in URLs are not supported"; + return nullptr; + } + + GstElement* appsrc = gst_element_factory_make("appsrc", "source"); + GstElement* parser = gst_element_factory_make("jpegparse", "jpeg-parser"); + GstElement* bin = gst_bin_new("sourcebin"); + GstElement* sourceBin = nullptr; + + do { + if (!appsrc || !parser || !bin) { + qCWarning(GstSourceFactoryLog) << "WebSocket JPEG requires appsrc and jpegparse"; + break; + } + + GstCaps* caps = gst_caps_from_string("image/jpeg"); + if (!caps) { + qCWarning(GstSourceFactoryLog) << "Failed to create WebSocket JPEG caps"; + break; + } + g_object_set(appsrc, "caps", caps, "is-live", TRUE, "do-timestamp", TRUE, "format", GST_FORMAT_TIME, "block", + FALSE, "max-buffers", static_cast(2), "max-bytes", + static_cast(QGCWebSocketVideoSource::kMaximumJpegBytes * 2), "leaky-type", + GST_APP_LEAKY_TYPE_DOWNSTREAM, "emit-signals", FALSE, nullptr); + gst_clear_caps(&caps); + + if (!gst_bin_add(GST_BIN(bin), appsrc)) { + qCWarning(GstSourceFactoryLog) << "Failed to add WebSocket appsrc to source bin"; + break; + } + GstElement* binAppsrc = appsrc; + appsrc = nullptr; + + if (!gst_bin_add(GST_BIN(bin), parser)) { + qCWarning(GstSourceFactoryLog) << "Failed to add WebSocket JPEG parser to source bin"; + break; + } + GstElement* binParser = parser; + parser = nullptr; + + if (!gst_element_link(binAppsrc, binParser)) { + qCWarning(GstSourceFactoryLog) << "Failed to link WebSocket JPEG source"; + break; + } + if (!addStaticGhostPad(binParser)) { + break; + } + + QUrl cleanUrl(sourceUrl); + cleanUrl.setUserInfo(QString()); + cleanUrl.setFragment(QString()); + auto* context = new QGCWebSocketVideoSource(cleanUrl, binAppsrc); + g_object_set_data_full(G_OBJECT(bin), kWebSocketSourceContextKey, context, + [](gpointer data) { delete static_cast(data); }); + + sourceBin = bin; + bin = nullptr; + } while (false); + + gst_clear_object(&bin); + gst_clear_object(&parser); + gst_clear_object(&appsrc); + return sourceBin; +} + // Wire upstream → (optional rtpjitterbuffer) → binParser, topology chosen by RTP probe (MPEG-TS // links via pad-added). Created elements join @p bin; returns false (logged) on failure. bool linkSourceToParser(GstElement* bin, GstElement* upstream, GstElement* binParser, const Config& config, @@ -643,8 +719,9 @@ GstElement* create(const QString& uri, const Config& config) const bool isUdpMPEGTS = (scheme == QLatin1String("mpegts")); const bool isTcpMPEGTS = (scheme == QLatin1String("tcp")); const bool isHttpMjpeg = (scheme == QLatin1String("http")) || (scheme == QLatin1String("https")); + const bool isWebSocketJpeg = (scheme == QLatin1String("ws")) || (scheme == QLatin1String("wss")); - if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS && !isHttpMjpeg) { + if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS && !isHttpMjpeg && !isWebSocketJpeg) { qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); return nullptr; @@ -653,6 +730,9 @@ GstElement* create(const QString& uri, const Config& config) if (isHttpMjpeg) { return buildHttpMjpegSource(sourceUrl, config); } + if (isWebSocketJpeg) { + return buildWebSocketJpegSource(sourceUrl); + } // Owning locals until gst_bin_add*, then nulled (non-owning alias used downstream) so the // unconditional gst_clear_object cleanup at the bottom stays safe. @@ -784,4 +864,28 @@ GstElement* create(const QString& uri, const Config& config) return srcbin; } +bool activate(GstElement* source) +{ + if (!source) { + return false; + } + + auto* context = + static_cast(g_object_get_data(G_OBJECT(source), kWebSocketSourceContextKey)); + return !context || context->start(); +} + +void deactivate(GstElement* source) +{ + if (!source) { + return; + } + + auto* context = + static_cast(g_object_get_data(G_OBJECT(source), kWebSocketSourceContextKey)); + if (context) { + context->stop(); + } +} + } // namespace GStreamer::SourceFactory diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h index d89ff9b21045..02ff97932a06 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h @@ -30,12 +30,20 @@ struct Config /// Build a source bin that exposes parsed encoded video for `uri`. /// Supported schemes: rtsp/rtspt, tcp:// (MPEG-TS), udp:// (H.264 RTP), /// udp265:// (H.265 RTP), mpegts:// (MPEG-TS over UDP), and http(s):// -/// (multipart MJPEG). +/// (multipart MJPEG), and ws(s):// (one JPEG per binary message). /// /// Ghost pads on RTP/MPEG-TS bins are wired lazily after upstream produces pads. -/// The HTTP MJPEG bin exposes its parsed-JPEG pad immediately. +/// The HTTP and WebSocket JPEG bins expose their parsed-JPEG pads immediately. /// /// Returns the source bin or nullptr on failure. GstElement* create(const QString& uri, const Config& config); +/// Activate sources whose network client requires a Qt event loop after the source bin has been +/// attached to a pipeline and its bus handler is installed. No-op for other source families. +bool activate(GstElement* source); + +/// Stop source-specific network clients before their GStreamer bin is released. +/// No-op for source families whose lifetime is managed entirely by GStreamer. +void deactivate(GstElement* source); + } // namespace GStreamer::SourceFactory diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc index 4cd862962c57..8a0f22821621 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc @@ -96,6 +96,31 @@ GstVideoReceiver::~GstVideoReceiver() qCDebug(GstVideoReceiverLog) << this; } +void GstVideoReceiver::_removeTeeProbe() +{ + if (_teeProbeId == 0) { + return; + } + + if (_tee) { + GstPad* sinkpad = gst_element_get_static_pad(_tee, "sink"); + if (sinkpad) { + gst_pad_remove_probe(sinkpad, _teeProbeId); + gst_clear_object(&sinkpad); + } + } + _teeProbeId = 0; +} + +void GstVideoReceiver::_clearPipelineAliases() +{ + _recorderValve = nullptr; + _decoderValve = nullptr; + _tee = nullptr; + _source = nullptr; + _teeProbeId = 0; +} + void GstVideoReceiver::start(uint32_t timeout) { if (_needDispatch()) { @@ -268,6 +293,11 @@ void GstVideoReceiver::start(uint32_t timeout) gst_clear_object(&bus); } + if (!GStreamer::SourceFactory::activate(_source)) { + qCCritical(GstVideoReceiverLog) << "SourceFactory::activate() failed"; + break; + } + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-initial"); running = (gst_element_set_state(_pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); } while(0); @@ -275,9 +305,15 @@ void GstVideoReceiver::start(uint32_t timeout) if (!running) { qCCritical(GstVideoReceiverLog) << "Failed"; + _removeTeeProbe(); + if (pipelineUp) { + GStreamer::SourceFactory::deactivate(_source); + } + if (_pipeline) { (void) gst_element_set_state(_pipeline, GST_STATE_NULL); (void) gst_element_get_state(_pipeline, nullptr, nullptr, GST_CLOCK_TIME_NONE); + QMutexLocker lock(&_pipelineMutex); gst_clear_object(&_pipeline); } @@ -289,6 +325,7 @@ void GstVideoReceiver::start(uint32_t timeout) gst_clear_object(&_tee); gst_clear_object(&_source); } + _clearPipelineAliases(); emit onStartComplete(STATUS_FAIL); } else { @@ -322,16 +359,7 @@ void GstVideoReceiver::stop() // Only _watchdogTimer.stop() must run on the GUI thread (the timer lives on `this`). QMetaObject::invokeMethod(this, [this]() { _watchdogTimer.stop(); }, Qt::QueuedConnection); - if (_teeProbeId != 0) { - if (_tee) { - GstPad *sinkpad = gst_element_get_static_pad(_tee, "sink"); - if (sinkpad) { - gst_pad_remove_probe(sinkpad, _teeProbeId); - gst_clear_object(&sinkpad); - } - } - _teeProbeId = 0; - } + _removeTeeProbe(); if (_pipeline) { GstBus *bus = gst_pipeline_get_bus(GST_PIPELINE(_pipeline)); @@ -398,6 +426,7 @@ void GstVideoReceiver::stop() qCCritical(GstVideoReceiverLog) << "gst_pipeline_get_bus() failed"; } + GStreamer::SourceFactory::deactivate(_source); (void) gst_element_set_state(_pipeline, GST_STATE_NULL); (void) gst_element_get_state(_pipeline, nullptr, nullptr, GST_CLOCK_TIME_NONE); @@ -420,10 +449,7 @@ void GstVideoReceiver::stop() _pipeline = nullptr; } - _recorderValve = nullptr; - _decoderValve = nullptr; - _tee = nullptr; - _source = nullptr; + _clearPipelineAliases(); _lastSourceFrameTime = 0; diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h index d31621959600..5f3e553e4c70 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h @@ -106,6 +106,8 @@ private slots: void _shutdownDecodingBranch(); void _shutdownRecordingBranch(); void _logDecodebin3SelectedCodec(GstElement *decodebin3); + void _removeTeeProbe(); + void _clearPipelineAliases(); bool _needDispatch(); diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc new file mode 100644 index 000000000000..5eb255b8014b --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc @@ -0,0 +1,449 @@ +#include "QGCWebSocketVideoSource.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" + +QGC_LOGGING_CATEGORY(QGCWebSocketVideoSourceLog, "Video.GStreamer.WebSocketVideoSource") + +namespace { + +constexpr int kThreadLifecycleTimeoutMs = 3000; + +bool isStartOfFrameMarker(quint8 marker) +{ + switch (marker) { + case 0xC0: + case 0xC1: + case 0xC2: + case 0xC3: + case 0xC5: + case 0xC6: + case 0xC7: + case 0xC9: + case 0xCA: + case 0xCB: + case 0xCD: + case 0xCE: + case 0xCF: + return true; + default: + return false; + } +} + +bool parseSingleJpeg(QByteArrayView message, QSize& dimensions) +{ + const auto* data = reinterpret_cast(message.data()); + const qsizetype size = message.size(); + if ((size < 4) || (data[0] != 0xFF) || (data[1] != 0xD8)) { + return false; + } + + bool foundStartOfFrame = false; + bool foundStartOfScan = false; + bool inEntropyData = false; + qsizetype offset = 2; + + while (offset < size) { + quint8 marker = 0; + + if (inEntropyData) { + bool foundMarker = false; + while (offset < size) { + if (data[offset++] != 0xFF) { + continue; + } + while ((offset < size) && (data[offset] == 0xFF)) { + ++offset; + } + if (offset >= size) { + return false; + } + + marker = data[offset++]; + if ((marker == 0x00) || (marker == 0x01) || ((marker >= 0xD0) && (marker <= 0xD7))) { + continue; + } + foundMarker = true; + break; + } + if (!foundMarker) { + return false; + } + inEntropyData = false; + } else { + if (data[offset++] != 0xFF) { + return false; + } + while ((offset < size) && (data[offset] == 0xFF)) { + ++offset; + } + if (offset >= size) { + return false; + } + marker = data[offset++]; + } + + if (marker == 0xD9) { + return foundStartOfFrame && foundStartOfScan && (offset == size); + } + if ((marker == 0xD8) || (marker == 0x00) || ((marker >= 0xD0) && (marker <= 0xD7))) { + return false; + } + if (marker == 0x01) { + continue; + } + + if ((offset + 2) > size) { + return false; + } + const quint16 segmentLength = (static_cast(data[offset]) << 8) | data[offset + 1]; + if ((segmentLength < 2) || (static_cast(segmentLength) > (size - offset))) { + return false; + } + const qsizetype segmentEnd = offset + segmentLength; + + if (isStartOfFrameMarker(marker)) { + if (foundStartOfFrame || (segmentLength < 8)) { + return false; + } + const quint8 componentCount = data[offset + 7]; + if ((componentCount == 0) || (segmentLength != static_cast(8 + (3 * componentCount)))) { + return false; + } + + const int height = (static_cast(data[offset + 3]) << 8) | data[offset + 4]; + const int width = (static_cast(data[offset + 5]) << 8) | data[offset + 6]; + dimensions = QSize(width, height); + foundStartOfFrame = true; + } else if (marker == 0xDA) { + if (!foundStartOfFrame || (segmentLength < 6)) { + return false; + } + const quint8 componentCount = data[offset + 2]; + if ((componentCount == 0) || (segmentLength != static_cast(6 + (2 * componentCount)))) { + return false; + } + foundStartOfScan = true; + inEntropyData = true; + } + + offset = segmentEnd; + } + + return false; +} + +class WebSocketWorker final : public QObject +{ +public: + WebSocketWorker(const QUrl& url, GstElement* appsrc) : _url(url), _appsrc(appsrc) {} + + ~WebSocketWorker() override { stop(); } + + void start() + { + if (_running || !_appsrc || !_accepting.load(std::memory_order_acquire)) { + return; + } + + _webSocket = new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this); + _webSocket->setMaxAllowedIncomingFrameSize(static_cast(QGCWebSocketVideoSource::kMaximumJpegBytes)); + _webSocket->setMaxAllowedIncomingMessageSize(static_cast(QGCWebSocketVideoSource::kMaximumJpegBytes)); + _webSocket->setReadBufferSize(static_cast(QGCWebSocketVideoSource::kMaximumJpegBytes)); + + if (_url.scheme() == QLatin1String("wss")) { + _webSocket->setSslConfiguration(QGCNetworkHelper::createSslConfig()); + } + + (void) connect(_webSocket, &QWebSocket::connected, this, [this]() { + qCDebug(QGCWebSocketVideoSourceLog) << "Connected" << QGCNetworkHelper::redactedUrlForLogging(_url); + }); + (void) connect(_webSocket, &QWebSocket::disconnected, this, [this]() { + qCDebug(QGCWebSocketVideoSourceLog) << "Disconnected" << QGCNetworkHelper::redactedUrlForLogging(_url); + _failStream(GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_READ, "WebSocket video connection closed", + QStringLiteral("Connection closed unexpectedly")); + }); + (void) connect(_webSocket, &QWebSocket::binaryMessageReceived, this, + [this](const QByteArray& message) { _handleBinaryMessage(message); }); + (void) connect(_webSocket, &QWebSocket::textMessageReceived, this, [](const QString&) {}); + (void) connect(_webSocket, &QWebSocket::errorOccurred, this, [this](QAbstractSocket::SocketError error) { + if (!_accepting.load(std::memory_order_acquire) || _terminalError) { + return; + } + qCWarning(QGCWebSocketVideoSourceLog) << "WebSocket transport error code" << static_cast(error); + _failStream(GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_OPEN_READ, "WebSocket video connection failed", + QStringLiteral("Socket error code %1").arg(static_cast(error))); + if (_webSocket) { + _webSocket->abort(); + } + }); + (void) connect(_webSocket, &QWebSocket::sslErrors, this, [](const QList& errors) { + if (!errors.isEmpty()) { + qCWarning(QGCWebSocketVideoSourceLog) + << "WebSocket TLS verification failed with" << errors.size() << "error(s); first code" + << static_cast(errors.constFirst().error()); + } + }); + + QNetworkRequest request(_url); + request.setRawHeader("User-Agent", QGCNetworkHelper::defaultUserAgent().toUtf8()); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + + _running = true; + _terminalError = false; + qCDebug(QGCWebSocketVideoSourceLog) << "Opening" << QGCNetworkHelper::redactedUrlForLogging(_url); + _webSocket->open(request); + } + + void stop() + { + _accepting.store(false, std::memory_order_release); + _running = false; + _terminalError = true; + if (_webSocket) { + _webSocket->disconnect(this); + _webSocket->abort(); + delete _webSocket; + _webSocket = nullptr; + } + } + + void requestStop() { _accepting.store(false, std::memory_order_release); } + +private: + void _failStream(GQuark domain, gint code, const char* message, const QString& debug) + { + if (!_accepting.load(std::memory_order_acquire) || !_running || _terminalError || !_appsrc) { + return; + } + + _terminalError = true; + GError* error = g_error_new_literal(domain, code, message); + const QByteArray debugUtf8 = debug.toUtf8(); + GstMessage* errorMessage = gst_message_new_error(GST_OBJECT(_appsrc), error, debugUtf8.constData()); + g_clear_error(&error); + (void) gst_element_post_message(_appsrc, errorMessage); + } + + void _handleBinaryMessage(const QByteArray& message) + { + if (!_accepting.load(std::memory_order_acquire) || !_running || _terminalError) { + return; + } + + if (!QGCWebSocketVideoSource::isCompleteJpeg(message)) { + qCWarning(QGCWebSocketVideoSourceLog) + << "Rejecting WebSocket message that is not exactly one complete JPEG; bytes:" << message.size(); + _failStream(GST_STREAM_ERROR, GST_STREAM_ERROR_DECODE, "WebSocket JPEG stream was rejected", + QStringLiteral("Invalid JPEG message (%1 bytes)").arg(message.size())); + if (_webSocket) { + _webSocket->close(QWebSocketProtocol::CloseCodeProtocolError, QStringLiteral("Invalid JPEG frame")); + } + return; + } + + GstBuffer* buffer = gst_buffer_new_allocate(nullptr, static_cast(message.size()), nullptr); + if (!buffer) { + qCWarning(QGCWebSocketVideoSourceLog) << "Failed to allocate WebSocket JPEG buffer"; + _failStream(GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_NO_SPACE_LEFT, "WebSocket JPEG buffer allocation failed", + QStringLiteral("Requested %1 bytes").arg(message.size())); + if (_webSocket) { + _webSocket->abort(); + } + return; + } + + (void) gst_buffer_fill(buffer, 0, message.constData(), static_cast(message.size())); + GST_BUFFER_PTS(buffer) = GST_CLOCK_TIME_NONE; + GST_BUFFER_DTS(buffer) = GST_CLOCK_TIME_NONE; + GST_BUFFER_DURATION(buffer) = GST_CLOCK_TIME_NONE; + + const GstFlowReturn result = gst_app_src_push_buffer(GST_APP_SRC(_appsrc), buffer); + if ((result != GST_FLOW_OK) && (result != GST_FLOW_FLUSHING)) { + qCWarning(QGCWebSocketVideoSourceLog) << "Failed to push WebSocket JPEG buffer:" << result; + _failStream(GST_STREAM_ERROR, GST_STREAM_ERROR_FAILED, "WebSocket JPEG pipeline rejected a frame", + QStringLiteral("GStreamer flow result %1").arg(static_cast(result))); + if (_webSocket) { + _webSocket->abort(); + } + } + } + + const QUrl _url; + GstElement* _appsrc = nullptr; + QWebSocket* _webSocket = nullptr; + std::atomic _accepting{true}; + bool _running = false; + bool _terminalError = false; +}; + +class WebSocketThread final : public QThread +{ +public: + WebSocketThread(const QUrl& url, GstElement* appsrc) + : _url(url), _appsrc(appsrc ? GST_ELEMENT(gst_object_ref(appsrc)) : nullptr) + { + setObjectName(QStringLiteral("QGCWebSocketVideo")); + } + + ~WebSocketThread() override { gst_clear_object(&_appsrc); } + + bool startSession() + { + if (_started.exchange(true, std::memory_order_acq_rel)) { + return isRunning() && !_stopRequested.load(std::memory_order_acquire); + } + + start(); + if (!_initialized.tryAcquire(1, kThreadLifecycleTimeoutMs)) { + qCCritical(QGCWebSocketVideoSourceLog) << "Timed out starting the WebSocket video worker"; + stopSession(); + return false; + } + return !_stopRequested.load(std::memory_order_acquire); + } + + void stopSession() + { + _stopRequested.store(true, std::memory_order_release); + + { + QMutexLocker lock(&_workerMutex); + if (_worker) { + _worker->requestStop(); + if (!QMetaObject::invokeMethod( + _worker, + [this, worker = _worker]() { + worker->stop(); + quit(); + }, + Qt::QueuedConnection)) { + quit(); + } + } else { + quit(); + } + } + + if (QThread::currentThread() == this) { + qCCritical(QGCWebSocketVideoSourceLog) << "WebSocket source shutdown must run on its receiver owner thread"; + return; + } + + if (!wait(kThreadLifecycleTimeoutMs)) { + qCWarning(QGCWebSocketVideoSourceLog) + << "WebSocket video worker exceeded its shutdown budget; waiting for ownership-safe completion"; + (void) wait(); + } + } + +protected: + void run() final + { + WebSocketWorker worker(_url, _appsrc); + { + QMutexLocker lock(&_workerMutex); + _worker = &worker; + } + + if (!_stopRequested.load(std::memory_order_acquire)) { + worker.start(); + } else { + worker.requestStop(); + } + _initialized.release(); + + if (!_stopRequested.load(std::memory_order_acquire)) { + (void) exec(); + } + + worker.stop(); + { + QMutexLocker lock(&_workerMutex); + _worker = nullptr; + } + } + +private: + const QUrl _url; + GstElement* _appsrc = nullptr; + QMutex _workerMutex; + WebSocketWorker* _worker = nullptr; + QSemaphore _initialized; + std::atomic _started{false}; + std::atomic _stopRequested{false}; +}; + +} // namespace + +class QGCWebSocketVideoSource::Impl +{ +public: + Impl(const QUrl& url, GstElement* appsrc) : _thread(std::make_unique(url, appsrc)) {} + + ~Impl() { stop(); } + + bool start() { return _thread && _thread->startSession(); } + + void stop() + { + if (_thread) { + _thread->stopSession(); + if (!_thread->isRunning()) { + _thread.reset(); + } + } + } + +private: + std::unique_ptr _thread; +}; + +QGCWebSocketVideoSource::QGCWebSocketVideoSource(const QUrl& url, GstElement* appsrc) + : _impl(std::make_unique(url, appsrc)) +{} + +QGCWebSocketVideoSource::~QGCWebSocketVideoSource() = default; + +bool QGCWebSocketVideoSource::start() +{ + return _impl && _impl->start(); +} + +void QGCWebSocketVideoSource::stop() +{ + if (_impl) { + _impl->stop(); + } +} + +bool QGCWebSocketVideoSource::isCompleteJpeg(QByteArrayView message) +{ + if (message.size() < 4 || message.size() > kMaximumJpegBytes) { + return false; + } + + QSize parsedDimensions; + if (!parseSingleJpeg(message, parsedDimensions)) { + return false; + } + + if (!parsedDimensions.isValid() || (parsedDimensions.width() > kMaximumJpegDimension) || + (parsedDimensions.height() > kMaximumJpegDimension)) { + return false; + } + return (static_cast(parsedDimensions.width()) * static_cast(parsedDimensions.height())) <= + kMaximumDecodedPixels; +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h new file mode 100644 index 000000000000..7d8539c1b8ff --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +typedef struct _GstElement GstElement; + +/// Bridges one-JPEG-per-binary-message WebSocket streams into a bounded GStreamer appsrc. +/// +/// The controller owns a dedicated Qt event-loop thread because GstVideoReceiver's worker is a +/// task queue, not a Qt event loop. It does not reconnect; GstVideoReceiver owns that policy. +class QGCWebSocketVideoSource final +{ +public: + static constexpr qsizetype kMaximumJpegBytes = 16 * 1024 * 1024; + static constexpr int kMaximumJpegDimension = 16384; + static constexpr quint64 kMaximumDecodedPixels = 64 * 1024 * 1024; + + QGCWebSocketVideoSource(const QUrl& url, GstElement* appsrc); + ~QGCWebSocketVideoSource(); + + Q_DISABLE_COPY_MOVE(QGCWebSocketVideoSource) + + bool start(); + void stop(); + + static bool isCompleteJpeg(QByteArrayView message); + +private: + class Impl; + std::unique_ptr _impl; +}; diff --git a/test/VideoManager/GStreamer/GStreamerTest.cc b/test/VideoManager/GStreamer/GStreamerTest.cc index e53f0174bbab..5fb247f040fc 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.cc +++ b/test/VideoManager/GStreamer/GStreamerTest.cc @@ -907,6 +907,15 @@ QGC_GST_SKIP_TEST(_testSourceFactoryRtspJitterBufferPolicy) QGC_GST_SKIP_TEST(_testSourceFactoryHttpMjpeg) QGC_GST_SKIP_TEST(_testSourceFactoryHttpMjpegDelivery) QGC_GST_SKIP_TEST(_testSourceFactoryRejectsUnsafeHttpMjpegUrl) +QGC_GST_SKIP_TEST(_testSourceFactoryWebSocketJpeg) +QGC_GST_SKIP_TEST(_testWebSocketJpegValidation) +QGC_GST_SKIP_TEST(_testSourceFactoryWebSocketJpegDelivery) +QGC_GST_SKIP_TEST(_testSourceFactoryWebSocketJpegWssTrusted) +QGC_GST_SKIP_TEST(_testSourceFactoryWebSocketJpegWssRejectsUntrusted) +QGC_GST_SKIP_TEST(_testSourceFactoryWebSocketJpegFailedHandshake) +QGC_GST_SKIP_TEST(_testSourceFactoryWebSocketJpegRemoteDisconnect) +QGC_GST_SKIP_TEST(_testSourceFactoryWebSocketJpegImmediateStop) +QGC_GST_SKIP_TEST(_testSourceFactoryRejectsUnsafeWebSocketJpegUrl) QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadUri) QGC_GST_SKIP_TEST(_testSourceFactoryTcpMpegTs) QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadTcpUri) diff --git a/test/VideoManager/GStreamer/GStreamerTest.h b/test/VideoManager/GStreamer/GStreamerTest.h index 003fc1b4033a..dd6a28de0726 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.h +++ b/test/VideoManager/GStreamer/GStreamerTest.h @@ -91,6 +91,15 @@ private slots: void _testSourceFactoryHttpMjpeg(); void _testSourceFactoryHttpMjpegDelivery(); void _testSourceFactoryRejectsUnsafeHttpMjpegUrl(); + void _testSourceFactoryWebSocketJpeg(); + void _testWebSocketJpegValidation(); + void _testSourceFactoryWebSocketJpegDelivery(); + void _testSourceFactoryWebSocketJpegWssTrusted(); + void _testSourceFactoryWebSocketJpegWssRejectsUntrusted(); + void _testSourceFactoryWebSocketJpegFailedHandshake(); + void _testSourceFactoryWebSocketJpegRemoteDisconnect(); + void _testSourceFactoryWebSocketJpegImmediateStop(); + void _testSourceFactoryRejectsUnsafeWebSocketJpegUrl(); void _testSourceFactoryRejectsBadUri(); void _testSourceFactoryTcpMpegTs(); void _testSourceFactoryRejectsBadTcpUri(); diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 5994582c60f3..80ebde84e72c 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -8,12 +8,24 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include "GstSourceFactory.h" #include "LocalHttpTestServer.h" #include "QGCNetworkHelper.h" +#include "QGCWebSocketVideoSource.h" namespace { @@ -57,6 +69,96 @@ GstElement* findChildByFactoryName(GstElement* bin, const char* factoryName) return match; } +QByteArray makeTestJpeg(int width = 4, int height = 4, bool progressive = false) +{ + QImage image(width, height, QImage::Format_RGB32); + image.fill(Qt::green); + + QByteArray jpeg; + QBuffer buffer(&jpeg); + if (!buffer.open(QIODevice::WriteOnly)) { + return {}; + } + + QImageWriter writer(&buffer, "JPEG"); + writer.setProgressiveScanWrite(progressive); + if (!writer.write(image)) { + return {}; + } + return jpeg; +} + +QByteArray withJpegDimensions(QByteArray jpeg, quint16 width, quint16 height) +{ + constexpr std::array kStartOfFrameMarkers = { + 0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, + }; + + for (const unsigned char markerCode : kStartOfFrameMarkers) { + QByteArray marker; + marker.append(static_cast(0xFF)); + marker.append(static_cast(markerCode)); + const qsizetype markerOffset = jpeg.indexOf(marker); + if ((markerOffset < 0) || ((markerOffset + 8) >= jpeg.size())) { + continue; + } + + jpeg[markerOffset + 5] = static_cast((height >> 8) & 0xFF); + jpeg[markerOffset + 6] = static_cast(height & 0xFF); + jpeg[markerOffset + 7] = static_cast((width >> 8) & 0xFF); + jpeg[markerOffset + 8] = static_cast(width & 0xFF); + return jpeg; + } + + return {}; +} + +QSize jpegDimensions(const QByteArray& jpeg) +{ + QBuffer buffer; + buffer.setData(jpeg); + if (!buffer.open(QIODevice::ReadOnly)) { + return {}; + } + + QImageReader reader(&buffer, "JPEG"); + return reader.size(); +} + +// Throwaway localhost-only test material. It is intentionally not stored as a PEM credential. +QSslCertificate testTlsCertificate() +{ + static constexpr auto kCertificateDer = + "MIIBmzCCAUGgAwIBAgIUFbcEH9kmwztc5b9Ha3cY4x9FDO8wCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAX" + "DTI2MDcyODA2MjMwOFoYDzIxMjYwNzA0MDYyMzA4WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO" + "PQMBBwNCAAR4OSsMTUhwl3a5KP18gdm/QNswKq5NFg0JqeUNF7BgFzAa10WVKA7/c6TEoaKsSn32iXesOUYI7Fs1WH7zFsjA" + "o28wbTAdBgNVHQ4EFgQUvOEXyJbTXCPncAQ5EWRp6XuZ1WgwHwYDVR0jBBgwFoAUvOEXyJbTXCPncAQ5EWRp6XuZ1WgwDwYD" + "VR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwCgYIKoZIzj0EAwIDSAAwRQIhAP8NQO0HrtST24NQ" + "2BiGZOoyNmjgnlJ7U9vkDcwNXjYKAiBkOpNock+KqNb9qooUWnkxz0VUBbkKBcs9EFtS3EQndw=="; + return QSslCertificate(QByteArray::fromBase64(QByteArrayView(kCertificateDer)), QSsl::Der); +} + +QSslKey testTlsPrivateKey() +{ + static constexpr auto kPrivateKeyDer = + "MHcCAQEEIEdHEf9aQn1QFwpXUAWdVOLDXr8LbWlidiIpShmGvco5oAoGCCqGSM49AwEHoUQDQgAEeDkrDE1IcJd2uSj9fIHZ" + "v0DbMCquTRYNCanlDRewYBcwGtdFlSgO/3OkxKGirEp99ol3rDlGCOxbNVh+8xbIwA=="; + return QSslKey(QByteArray::fromBase64(QByteArrayView(kPrivateKeyDer)), QSsl::Ec, QSsl::Der, QSsl::PrivateKey); +} + +bool configureSecureTestServer(QWebSocketServer& server, const QSslCertificate& certificate, const QSslKey& privateKey) +{ + if (certificate.isNull() || privateKey.isNull()) { + return false; + } + + QSslConfiguration configuration = server.sslConfiguration(); + configuration.setLocalCertificate(certificate); + configuration.setPrivateKey(privateKey); + server.setSslConfiguration(configuration); + return true; +} + } // namespace void GStreamerTest::_testSourceFactoryUdpRtpJitterBuffer() @@ -372,6 +474,355 @@ void GStreamerTest::_testSourceFactoryRejectsUnsafeHttpMjpegUrl() config)); } +void GStreamerTest::_testSourceFactoryWebSocketJpeg() +{ + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse")) { + QSKIP("appsrc/jpegparse plugins unavailable"); + } + + GStreamer::SourceFactory::Config config; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("ws://127.0.0.1:9/video"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* appsrc = findChildByFactoryName(bin, "appsrc"); + GstElement* parser = findChildByFactoryName(bin, "jpegparse"); + QVERIFY(appsrc); + QVERIFY(parser); + + gboolean isLive = FALSE; + gboolean doTimestamp = FALSE; + gboolean block = TRUE; + gboolean emitSignals = TRUE; + GstFormat format = GST_FORMAT_UNDEFINED; + guint64 maxBuffers = 0; + guint64 maxBytes = 0; + GstAppLeakyType leakyType = GST_APP_LEAKY_TYPE_NONE; + g_object_get(appsrc, "is-live", &isLive, "do-timestamp", &doTimestamp, "format", &format, "block", &block, + "max-buffers", &maxBuffers, "max-bytes", &maxBytes, "leaky-type", &leakyType, "emit-signals", + &emitSignals, nullptr); + QCOMPARE(isLive, TRUE); + QCOMPARE(doTimestamp, TRUE); + QCOMPARE(format, GST_FORMAT_TIME); + QCOMPARE(block, FALSE); + QCOMPARE(maxBuffers, 2u); + QCOMPARE(maxBytes, static_cast(QGCWebSocketVideoSource::kMaximumJpegBytes * 2)); + QCOMPARE(leakyType, GST_APP_LEAKY_TYPE_DOWNSTREAM); + QCOMPARE(emitSignals, FALSE); + + GstPad* srcPad = gst_element_get_static_pad(bin, "src"); + QVERIFY2(srcPad, "WebSocket JPEG source bin must expose a static parsed-JPEG source pad"); + gst_object_unref(srcPad); +} + +void GStreamerTest::_testWebSocketJpegValidation() +{ + const QByteArray jpeg = makeTestJpeg(); + QVERIFY(!jpeg.isEmpty()); + QVERIFY(QGCWebSocketVideoSource::isCompleteJpeg(jpeg)); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(QByteArrayLiteral("not-a-jpeg"))); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(jpeg + jpeg)); + + QByteArray jpegWithTrailingByte = jpeg; + jpegWithTrailingByte.append('\0'); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(jpegWithTrailingByte)); + + QByteArray missingEndOfImage = jpeg; + missingEndOfImage.chop(2); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(missingEndOfImage)); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(QByteArray::fromHex("ffd8ffd9"))); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(QByteArray::fromHex("ffd8ffe10001ffd9"))); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(QByteArray::fromHex("ffd8ffe10010abcdffd9"))); + + QByteArray jpegWithEmbeddedEndMarker = jpeg; + jpegWithEmbeddedEndMarker.insert(2, QByteArray::fromHex("ffe1000678ffd979")); + QVERIFY(QGCWebSocketVideoSource::isCompleteJpeg(jpegWithEmbeddedEndMarker)); + + const QByteArray progressiveJpeg = makeTestJpeg(4, 4, true); + QVERIFY(!progressiveJpeg.isEmpty()); + QVERIFY(QGCWebSocketVideoSource::isCompleteJpeg(progressiveJpeg)); + + const QByteArray oversized(QGCWebSocketVideoSource::kMaximumJpegBytes + 1, '\0'); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(oversized)); + + const QByteArray excessiveWidth = withJpegDimensions(jpeg, QGCWebSocketVideoSource::kMaximumJpegDimension + 1, 1); + QVERIFY(!excessiveWidth.isEmpty()); + QCOMPARE(jpegDimensions(excessiveWidth), QSize(QGCWebSocketVideoSource::kMaximumJpegDimension + 1, 1)); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(excessiveWidth)); + + const QByteArray excessivePixels = withJpegDimensions(jpeg, QGCWebSocketVideoSource::kMaximumJpegDimension, 4097); + QVERIFY(!excessivePixels.isEmpty()); + QCOMPARE(jpegDimensions(excessivePixels), QSize(QGCWebSocketVideoSource::kMaximumJpegDimension, 4097)); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(excessivePixels)); +} + +void GStreamerTest::_testSourceFactoryWebSocketJpegDelivery() +{ + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse") || + !gst_element_factory_find("appsink")) { + QSKIP("appsrc/jpegparse/appsink plugins unavailable"); + } + + QWebSocketServer server(QStringLiteral("QGC WebSocket JPEG test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QSignalSpy connectionSpy(&server, &QWebSocketServer::newConnection); + + GStreamer::SourceFactory::Config config; + const QString url = QStringLiteral("ws://127.0.0.1:%1/video?camera=front#local-fragment").arg(server.serverPort()); + GstElement* bin = GStreamer::SourceFactory::create(url, config); + QVERIFY(bin); + + GstElement* pipeline = gst_pipeline_new("websocket-jpeg-test"); + GstElement* sink = gst_element_factory_make("appsink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + const auto pipelineCleanup = qScopeGuard([&] { + if (pipeline) { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + } + gst_clear_object(&sink); + gst_clear_object(&bin); + }); + + g_object_set(sink, "sync", FALSE, "emit-signals", FALSE, "max-buffers", 1u, "drop", TRUE, nullptr); + GstElement* sourceBin = bin; + QVERIFY(gst_bin_add(GST_BIN(pipeline), bin)); + bin = nullptr; + GstElement* binSink = sink; + QVERIFY(gst_bin_add(GST_BIN(pipeline), sink)); + sink = nullptr; + QVERIFY(gst_element_link(sourceBin, binSink)); + + QVERIFY(GStreamer::SourceFactory::activate(sourceBin)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_SIGNAL_WAIT(connectionSpy, TestTimeout::mediumMs()); + QWebSocket* peer = server.nextPendingConnection(); + QVERIFY(peer); + QCOMPARE(peer->requestUrl().path(), QStringLiteral("/video")); + QCOMPARE(peer->requestUrl().query(), QStringLiteral("camera=front")); + QVERIFY(peer->requestUrl().fragment().isEmpty()); + + const QByteArray jpeg = makeTestJpeg(); + QVERIFY(!jpeg.isEmpty()); + peer->setOutgoingFrameSize(64); + (void) peer->sendTextMessage(QStringLiteral("metadata is ignored")); + QCOMPARE(peer->sendBinaryMessage(jpeg), static_cast(jpeg.size())); + (void) peer->flush(); + + GstSample* sample = nullptr; + QTRY_VERIFY_WITH_TIMEOUT((sample = gst_app_sink_try_pull_sample(GST_APP_SINK(binSink), 0)) != nullptr, + TestTimeout::mediumMs()); + QVERIFY(gst_buffer_get_size(gst_sample_get_buffer(sample)) > 0); + gst_sample_unref(sample); + + QSignalSpy disconnectedSpy(peer, &QWebSocket::disconnected); + GStreamer::SourceFactory::deactivate(sourceBin); + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + pipeline = nullptr; + QVERIFY_SIGNAL_WAIT(disconnectedSpy, TestTimeout::mediumMs()); +} + +void GStreamerTest::_testSourceFactoryWebSocketJpegWssTrusted() +{ + if (!QSslSocket::supportsSsl()) { + QSKIP("TLS backend unavailable"); + } + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse")) { + QSKIP("appsrc/jpegparse plugins unavailable"); + } + + const QSslCertificate certificate = testTlsCertificate(); + const QSslKey privateKey = testTlsPrivateKey(); + QVERIFY(!certificate.isNull()); + QVERIFY(!privateKey.isNull()); + + const QSslConfiguration previousDefault = QSslConfiguration::defaultConfiguration(); + const auto restoreDefault = qScopeGuard([&] { QSslConfiguration::setDefaultConfiguration(previousDefault); }); + QSslConfiguration trustedDefault = previousDefault; + QList authorities = trustedDefault.caCertificates(); + authorities.append(certificate); + trustedDefault.setCaCertificates(authorities); + QSslConfiguration::setDefaultConfiguration(trustedDefault); + + QWebSocketServer server(QStringLiteral("QGC trusted WSS JPEG test"), QWebSocketServer::SecureMode); + QVERIFY(configureSecureTestServer(server, certificate, privateKey)); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QSignalSpy connectionSpy(&server, &QWebSocketServer::newConnection); + + GStreamer::SourceFactory::Config config; + GstElement* source = + GStreamer::SourceFactory::create(QStringLiteral("wss://127.0.0.1:%1/video").arg(server.serverPort()), config); + GstElement* pipeline = gst_pipeline_new("websocket-jpeg-trusted-wss-test"); + QVERIFY(source); + QVERIFY(pipeline); + const auto cleanup = qScopeGuard([&] { + GStreamer::SourceFactory::deactivate(source); + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); + QVERIFY(GStreamer::SourceFactory::activate(source)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_SIGNAL_WAIT(connectionSpy, TestTimeout::mediumMs()); +} + +void GStreamerTest::_testSourceFactoryWebSocketJpegWssRejectsUntrusted() +{ + if (!QSslSocket::supportsSsl()) { + QSKIP("TLS backend unavailable"); + } + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse")) { + QSKIP("appsrc/jpegparse plugins unavailable"); + } + + const QSslCertificate certificate = testTlsCertificate(); + const QSslKey privateKey = testTlsPrivateKey(); + QVERIFY(!certificate.isNull()); + QVERIFY(!privateKey.isNull()); + + QWebSocketServer server(QStringLiteral("QGC untrusted WSS JPEG test"), QWebSocketServer::SecureMode); + QVERIFY(configureSecureTestServer(server, certificate, privateKey)); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + + GStreamer::SourceFactory::Config config; + GstElement* source = + GStreamer::SourceFactory::create(QStringLiteral("wss://127.0.0.1:%1/video").arg(server.serverPort()), config); + GstElement* pipeline = gst_pipeline_new("websocket-jpeg-untrusted-wss-test"); + QVERIFY(source); + QVERIFY(pipeline); + const auto cleanup = qScopeGuard([&] { + GStreamer::SourceFactory::deactivate(source); + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); + QVERIFY(GStreamer::SourceFactory::activate(source)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + + GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); + QVERIFY(bus); + const auto busCleanup = qScopeGuard([&] { gst_object_unref(bus); }); + GstMessage* message = nullptr; + QTRY_VERIFY_WITH_TIMEOUT( + (message = gst_bus_pop_filtered(bus, static_cast(GST_MESSAGE_ERROR))) != nullptr, + TestTimeout::mediumMs()); + gst_message_unref(message); +} + +void GStreamerTest::_testSourceFactoryWebSocketJpegFailedHandshake() +{ + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse")) { + QSKIP("appsrc/jpegparse plugins unavailable"); + } + + QWebSocketServer portReservation(QStringLiteral("QGC WebSocket closed-port test"), QWebSocketServer::NonSecureMode); + QVERIFY(portReservation.listen(QHostAddress::LocalHost, 0)); + const quint16 closedPort = portReservation.serverPort(); + portReservation.close(); + + GStreamer::SourceFactory::Config config; + GstElement* source = + GStreamer::SourceFactory::create(QStringLiteral("ws://127.0.0.1:%1/video").arg(closedPort), config); + GstElement* pipeline = gst_pipeline_new("websocket-jpeg-failed-handshake-test"); + QVERIFY(source); + QVERIFY(pipeline); + const auto cleanup = qScopeGuard([&] { + GStreamer::SourceFactory::deactivate(source); + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); + QVERIFY(GStreamer::SourceFactory::activate(source)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + + GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); + QVERIFY(bus); + const auto busCleanup = qScopeGuard([&] { gst_object_unref(bus); }); + GstMessage* message = gst_bus_timed_pop_filtered( + bus, static_cast(TestTimeout::mediumMs()) * GST_MSECOND, GST_MESSAGE_ERROR); + QVERIFY2(message, "A failed WebSocket handshake must surface as a GStreamer bus error"); + gst_message_unref(message); +} + +void GStreamerTest::_testSourceFactoryWebSocketJpegRemoteDisconnect() +{ + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse")) { + QSKIP("appsrc/jpegparse plugins unavailable"); + } + + QWebSocketServer server(QStringLiteral("QGC WebSocket disconnect test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QSignalSpy connectionSpy(&server, &QWebSocketServer::newConnection); + + GStreamer::SourceFactory::Config config; + GstElement* source = + GStreamer::SourceFactory::create(QStringLiteral("ws://127.0.0.1:%1/video").arg(server.serverPort()), config); + GstElement* pipeline = gst_pipeline_new("websocket-jpeg-remote-disconnect-test"); + QVERIFY(source); + QVERIFY(pipeline); + const auto cleanup = qScopeGuard([&] { + GStreamer::SourceFactory::deactivate(source); + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); + QVERIFY(GStreamer::SourceFactory::activate(source)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_SIGNAL_WAIT(connectionSpy, TestTimeout::mediumMs()); + + QWebSocket* peer = server.nextPendingConnection(); + QVERIFY(peer); + peer->abort(); + + GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); + QVERIFY(bus); + const auto busCleanup = qScopeGuard([&] { gst_object_unref(bus); }); + GstMessage* message = gst_bus_timed_pop_filtered( + bus, static_cast(TestTimeout::mediumMs()) * GST_MSECOND, GST_MESSAGE_ERROR); + QVERIFY2(message, "A remote WebSocket disconnect must surface as a GStreamer bus error"); + gst_message_unref(message); +} + +void GStreamerTest::_testSourceFactoryWebSocketJpegImmediateStop() +{ + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse")) { + QSKIP("appsrc/jpegparse plugins unavailable"); + } + + QWebSocketServer server(QStringLiteral("QGC WebSocket lifecycle test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + const QString url = QStringLiteral("ws://127.0.0.1:%1/video").arg(server.serverPort()); + + for (int iteration = 0; iteration < 8; ++iteration) { + GStreamer::SourceFactory::Config config; + GstElement* source = GStreamer::SourceFactory::create(url, config); + QVERIFY(source); + QVERIFY(GStreamer::SourceFactory::activate(source)); + GStreamer::SourceFactory::deactivate(source); + gst_object_unref(source); + } +} + +void GStreamerTest::_testSourceFactoryRejectsUnsafeWebSocketJpegUrl() +{ + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("Invalid WebSocket JPEG URL"))); + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("WebSocket JPEG credentials in URLs are not supported"))); + + GStreamer::SourceFactory::Config config; + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("ws:///video"), config)); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("ws://video.example.test:0/video"), config)); + QVERIFY( + !GStreamer::SourceFactory::create(QStringLiteral("wss://operator:secret@video.example.test/video"), config)); +} + void GStreamerTest::_testSourceFactoryRejectsBadUri() { ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtCriticalMsg, From a877520e8e441d5125f943e9e7112fcc3664b59b Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 08:28:33 +0000 Subject: [PATCH 10/16] fix(VideoManager): harden WebSocket JPEG transport --- CMakeLists.txt | 1 - docs/en/qgc-user-guide/settings_view/video.md | 18 ++++-- src/AppSettings/pages/Video.SettingsUI.json | 4 ++ src/Settings/Video.SettingsGroup.json | 2 +- .../VideoReceiver/GStreamer/CMakeLists.txt | 6 ++ .../GStreamer/QGCWebSocketVideoSource.h | 5 +- test/VideoManager/GStreamer/GStreamerTest.h | 1 + .../GStreamerSourceFactoryTest.cc | 57 ++++++++++++++++++- 8 files changed, 82 insertions(+), 12 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ff3ba9afe37..32800cf2c9e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -256,7 +256,6 @@ find_package(Qt6 Sql Svg TextToSpeech - WebSockets Xml Quick3D StateMachine diff --git a/docs/en/qgc-user-guide/settings_view/video.md b/docs/en/qgc-user-guide/settings_view/video.md index fdb7782885ea..3f3080bc95af 100644 --- a/docs/en/qgc-user-guide/settings_view/video.md +++ b/docs/en/qgc-user-guide/settings_view/video.md @@ -22,18 +22,24 @@ HTTPS certificate validation remains enabled in GStreamer, redirects are not fol (`user:password@host`) is rejected. WebSocket JPEG expects exactly one complete JPEG image in each binary message. -Text messages are ignored. Messages larger than 16 MiB, images over 16,384 -pixels in either dimension, and images over 64 megapixels are rejected. The -receiver drops older queued images when decoding falls behind. `wss://` -certificates are verified using QGroundControl's configured trust store. -Credentials embedded in the URL are rejected. Use unencrypted `ws://` only on -a trusted network. +Text messages are ignored. Messages larger than 16 MiB, images over 8,192 +pixels in either dimension, and images exceeding the 64 MiB decoded-surface +budget are rejected. The receiver drops older queued images when decoding +falls behind. `wss://` certificates are verified using QGroundControl's +configured trust store. + +This source supports unauthenticated endpoints only. Authorization headers are +not supported, credentials embedded in the URL are rejected, and query values +are stored as ordinary settings. Use unencrypted `ws://` only on a trusted +network. ## Settings - **Aspect Ratio** — aspect ratio for scaling video in the display widget (default: 16:9; set to 0.0 to disable scaling) - **Stop recording when disarmed** — automatically stop recording when the vehicle disarms - **Low Latency Mode** — minimize video decode latency at the cost of some smoothness +- **Network Video Timeout** — time before a stalled RTSP, HTTP MJPEG, or WebSocket JPEG source is considered unavailable +- **Auto-reconnect on stream loss** — restart a timed-out or failed stream with exponential backoff - **Force CPU video path** — disable hardware-accelerated rendering (use if video displays incorrectly) - **Force video decoder priority** — override the automatic decoder selection: Default / Software / Hardware / NVIDIA / VA-API / DirectX 3D11 / VideoToolbox / Intel / Vulkan diff --git a/src/AppSettings/pages/Video.SettingsUI.json b/src/AppSettings/pages/Video.SettingsUI.json index eef77bef78a1..bc27e53d0840 100644 --- a/src/AppSettings/pages/Video.SettingsUI.json +++ b/src/AppSettings/pages/Video.SettingsUI.json @@ -69,6 +69,10 @@ "setting": "videoSettings.rtpJitterLatencyMs", "showWhen": "!autoStreamConfig && isStreamSource && rtpLatencyVisible && QGroundControl.settingsManager.videoSettings.rtpJitterLatencyMs.userVisible" }, + { + "setting": "videoSettings.rtspTimeout", + "showWhen": "!autoStreamConfig && (videoSource === QGroundControl.settingsManager.videoSettings.rtspVideoSource || videoSource === QGroundControl.settingsManager.videoSettings.httpMjpegVideoSource || videoSource === QGroundControl.settingsManager.videoSettings.webSocketJpegVideoSource)" + }, { "setting": "videoSettings.rtspAutoReconnect", "showWhen": "!autoStreamConfig && isStreamSource" diff --git a/src/Settings/Video.SettingsGroup.json b/src/Settings/Video.SettingsGroup.json index 259218d6c9e7..deeb4236b1e5 100644 --- a/src/Settings/Video.SettingsGroup.json +++ b/src/Settings/Video.SettingsGroup.json @@ -142,7 +142,7 @@ "units": "s", "default": 8, "label": "Network Video Timeout", - "keywords": "rtsp,http,mjpeg,network,video timeout" + "keywords": "rtsp,http,mjpeg,websocket,ws,wss,network,video timeout" }, { "name": "streamEnabled", diff --git a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt index a9eab2ad4232..a7c0592d6d8d 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt +++ b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt @@ -2,6 +2,12 @@ if(NOT QGC_ENABLE_GST_VIDEOSTREAMING) return() endif() +find_package(Qt6 + ${QGC_QT_MINIMUM_VERSION}...${QGC_QT_MAXIMUM_VERSION} + REQUIRED + COMPONENTS WebSockets +) + # Linux adds Allocators (gstreamer-allocators-1.0) for the DMABuf header probe in Orchestrator.cmake. # RtspServer (gstreamer-rtsp-server-1.0) drives the MockLink test video stream server, which is # Debug-only (see Comms/MockLink/CMakeLists.txt) — so only request it in Debug to keep the diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h index 7d8539c1b8ff..e339da73c443 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h @@ -15,8 +15,9 @@ class QGCWebSocketVideoSource final { public: static constexpr qsizetype kMaximumJpegBytes = 16 * 1024 * 1024; - static constexpr int kMaximumJpegDimension = 16384; - static constexpr quint64 kMaximumDecodedPixels = 64 * 1024 * 1024; + static constexpr int kMaximumJpegDimension = 8192; + // Bound a decoded four-byte display surface to 64 MiB while retaining 4K and 5K sources. + static constexpr quint64 kMaximumDecodedPixels = 16 * 1024 * 1024; QGCWebSocketVideoSource(const QUrl& url, GstElement* appsrc); ~QGCWebSocketVideoSource(); diff --git a/test/VideoManager/GStreamer/GStreamerTest.h b/test/VideoManager/GStreamer/GStreamerTest.h index dd6a28de0726..eba8f2a5d140 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.h +++ b/test/VideoManager/GStreamer/GStreamerTest.h @@ -94,6 +94,7 @@ private slots: void _testSourceFactoryWebSocketJpeg(); void _testWebSocketJpegValidation(); void _testSourceFactoryWebSocketJpegDelivery(); + void _testSourceFactoryWebSocketJpegRejectsMalformedMessage(); void _testSourceFactoryWebSocketJpegWssTrusted(); void _testSourceFactoryWebSocketJpegWssRejectsUntrusted(); void _testSourceFactoryWebSocketJpegFailedHandshake(); diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 80ebde84e72c..1aaee4891f14 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -550,9 +550,16 @@ void GStreamerTest::_testWebSocketJpegValidation() QCOMPARE(jpegDimensions(excessiveWidth), QSize(QGCWebSocketVideoSource::kMaximumJpegDimension + 1, 1)); QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(excessiveWidth)); - const QByteArray excessivePixels = withJpegDimensions(jpeg, QGCWebSocketVideoSource::kMaximumJpegDimension, 4097); + const QByteArray uhd = withJpegDimensions(jpeg, 3840, 2160); + QVERIFY(!uhd.isEmpty()); + QVERIFY(QGCWebSocketVideoSource::isCompleteJpeg(uhd)); + + const quint16 excessiveHeight = static_cast( + (QGCWebSocketVideoSource::kMaximumDecodedPixels / QGCWebSocketVideoSource::kMaximumJpegDimension) + 1); + const QByteArray excessivePixels = + withJpegDimensions(jpeg, QGCWebSocketVideoSource::kMaximumJpegDimension, excessiveHeight); QVERIFY(!excessivePixels.isEmpty()); - QCOMPARE(jpegDimensions(excessivePixels), QSize(QGCWebSocketVideoSource::kMaximumJpegDimension, 4097)); + QCOMPARE(jpegDimensions(excessivePixels), QSize(QGCWebSocketVideoSource::kMaximumJpegDimension, excessiveHeight)); QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(excessivePixels)); } @@ -624,6 +631,52 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegDelivery() QVERIFY_SIGNAL_WAIT(disconnectedSpy, TestTimeout::mediumMs()); } +void GStreamerTest::_testSourceFactoryWebSocketJpegRejectsMalformedMessage() +{ + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse")) { + QSKIP("appsrc/jpegparse plugins unavailable"); + } + + QWebSocketServer server(QStringLiteral("QGC malformed WebSocket JPEG test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QSignalSpy connectionSpy(&server, &QWebSocketServer::newConnection); + + GStreamer::SourceFactory::Config config; + GstElement* source = + GStreamer::SourceFactory::create(QStringLiteral("ws://127.0.0.1:%1/video").arg(server.serverPort()), config); + GstElement* pipeline = gst_pipeline_new("websocket-jpeg-malformed-message-test"); + QVERIFY(source); + QVERIFY(pipeline); + const auto cleanup = qScopeGuard([&] { + GStreamer::SourceFactory::deactivate(source); + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); + QVERIFY(GStreamer::SourceFactory::activate(source)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_SIGNAL_WAIT(connectionSpy, TestTimeout::mediumMs()); + + QWebSocket* peer = server.nextPendingConnection(); + QVERIFY(peer); + QSignalSpy disconnectedSpy(peer, &QWebSocket::disconnected); + const QByteArray malformed = QByteArrayLiteral("not-a-jpeg"); + QCOMPARE(peer->sendBinaryMessage(malformed), static_cast(malformed.size())); + (void) peer->flush(); + + GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); + QVERIFY(bus); + const auto busCleanup = qScopeGuard([&] { gst_object_unref(bus); }); + GstMessage* message = gst_bus_timed_pop_filtered( + bus, static_cast(TestTimeout::mediumMs()) * GST_MSECOND, GST_MESSAGE_ERROR); + QVERIFY2(message, "A malformed WebSocket JPEG message must surface as a GStreamer bus error"); + gst_message_unref(message); + + QVERIFY_SIGNAL_WAIT(disconnectedSpy, TestTimeout::mediumMs()); + QCOMPARE(peer->closeCode(), QWebSocketProtocol::CloseCodeProtocolError); +} + void GStreamerTest::_testSourceFactoryWebSocketJpegWssTrusted() { if (!QSslSocket::supportsSsl()) { From 30c1d7a18718c16f99b4a0551ee8607b855f6cf1 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 09:09:50 +0000 Subject: [PATCH 11/16] test(VideoManager): fix WebSocket source test build --- .../GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 1aaee4891f14..265d165fd2d2 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -20,6 +20,7 @@ #include #include #include +#include #include #include "GstSourceFactory.h" @@ -135,7 +136,7 @@ QSslCertificate testTlsCertificate() "o28wbTAdBgNVHQ4EFgQUvOEXyJbTXCPncAQ5EWRp6XuZ1WgwHwYDVR0jBBgwFoAUvOEXyJbTXCPncAQ5EWRp6XuZ1WgwDwYD" "VR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwCgYIKoZIzj0EAwIDSAAwRQIhAP8NQO0HrtST24NQ" "2BiGZOoyNmjgnlJ7U9vkDcwNXjYKAiBkOpNock+KqNb9qooUWnkxz0VUBbkKBcs9EFtS3EQndw=="; - return QSslCertificate(QByteArray::fromBase64(QByteArrayView(kCertificateDer)), QSsl::Der); + return QSslCertificate(QByteArray::fromBase64(QByteArray(kCertificateDer)), QSsl::Der); } QSslKey testTlsPrivateKey() @@ -143,7 +144,7 @@ QSslKey testTlsPrivateKey() static constexpr auto kPrivateKeyDer = "MHcCAQEEIEdHEf9aQn1QFwpXUAWdVOLDXr8LbWlidiIpShmGvco5oAoGCCqGSM49AwEHoUQDQgAEeDkrDE1IcJd2uSj9fIHZ" "v0DbMCquTRYNCanlDRewYBcwGtdFlSgO/3OkxKGirEp99ol3rDlGCOxbNVh+8xbIwA=="; - return QSslKey(QByteArray::fromBase64(QByteArrayView(kPrivateKeyDer)), QSsl::Ec, QSsl::Der, QSsl::PrivateKey); + return QSslKey(QByteArray::fromBase64(QByteArray(kPrivateKeyDer)), QSsl::Ec, QSsl::Der, QSsl::PrivateKey); } bool configureSecureTestServer(QWebSocketServer& server, const QSslCertificate& certificate, const QSslKey& privateKey) From b1e1fe15b608f7e91a4a26179d5c80f7f27ae406 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 15:05:05 +0000 Subject: [PATCH 12/16] fix(VideoManager): preserve early source errors --- .../GStreamer/GstVideoReceiver.cc | 10 +++++-- .../GStreamerSourceFactoryTest.cc | 28 ++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc index 8a0f22821621..c67aa25ce92b 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc @@ -293,13 +293,19 @@ void GstVideoReceiver::start(uint32_t timeout) gst_clear_object(&bus); } + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-initial"); + if (gst_element_set_state(_pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { + break; + } + + // External source controllers report terminal failures through this pipeline's bus. + // Leave NULL first so early connection and TLS errors are not discarded while it is flushing. if (!GStreamer::SourceFactory::activate(_source)) { qCCritical(GstVideoReceiverLog) << "SourceFactory::activate() failed"; break; } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-initial"); - running = (gst_element_set_state(_pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + running = true; } while(0); if (!running) { diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 265d165fd2d2..1b971e8d3752 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -602,8 +602,8 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegDelivery() sink = nullptr; QVERIFY(gst_element_link(sourceBin, binSink)); - QVERIFY(GStreamer::SourceFactory::activate(sourceBin)); QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY(GStreamer::SourceFactory::activate(sourceBin)); QVERIFY_SIGNAL_WAIT(connectionSpy, TestTimeout::mediumMs()); QWebSocket* peer = server.nextPendingConnection(); QVERIFY(peer); @@ -619,7 +619,7 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegDelivery() (void) peer->flush(); GstSample* sample = nullptr; - QTRY_VERIFY_WITH_TIMEOUT((sample = gst_app_sink_try_pull_sample(GST_APP_SINK(binSink), 0)) != nullptr, + QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(binSink))) != nullptr, TestTimeout::mediumMs()); QVERIFY(gst_buffer_get_size(gst_sample_get_buffer(sample)) > 0); gst_sample_unref(sample); @@ -638,6 +638,9 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegRejectsMalformedMessage() QSKIP("appsrc/jpegparse plugins unavailable"); } + expectLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("Rejecting WebSocket message"))); + QWebSocketServer server(QStringLiteral("QGC malformed WebSocket JPEG test"), QWebSocketServer::NonSecureMode); QVERIFY(server.listen(QHostAddress::LocalHost, 0)); QSignalSpy connectionSpy(&server, &QWebSocketServer::newConnection); @@ -655,8 +658,8 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegRejectsMalformedMessage() }); QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); - QVERIFY(GStreamer::SourceFactory::activate(source)); QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY(GStreamer::SourceFactory::activate(source)); QVERIFY_SIGNAL_WAIT(connectionSpy, TestTimeout::mediumMs()); QWebSocket* peer = server.nextPendingConnection(); @@ -673,6 +676,7 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegRejectsMalformedMessage() bus, static_cast(TestTimeout::mediumMs()) * GST_MSECOND, GST_MESSAGE_ERROR); QVERIFY2(message, "A malformed WebSocket JPEG message must surface as a GStreamer bus error"); gst_message_unref(message); + verifyExpectedLogMessage(); QVERIFY_SIGNAL_WAIT(disconnectedSpy, TestTimeout::mediumMs()); QCOMPARE(peer->closeCode(), QWebSocketProtocol::CloseCodeProtocolError); @@ -718,8 +722,8 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegWssTrusted() }); QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); - QVERIFY(GStreamer::SourceFactory::activate(source)); QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY(GStreamer::SourceFactory::activate(source)); QVERIFY_SIGNAL_WAIT(connectionSpy, TestTimeout::mediumMs()); } @@ -732,6 +736,11 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegWssRejectsUntrusted() QSKIP("appsrc/jpegparse plugins unavailable"); } + expectLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("WebSocket TLS verification failed"))); + expectLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("WebSocket transport error code"))); + const QSslCertificate certificate = testTlsCertificate(); const QSslKey privateKey = testTlsPrivateKey(); QVERIFY(!certificate.isNull()); @@ -754,8 +763,8 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegWssRejectsUntrusted() }); QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); - QVERIFY(GStreamer::SourceFactory::activate(source)); QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY(GStreamer::SourceFactory::activate(source)); GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); QVERIFY(bus); @@ -765,6 +774,8 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegWssRejectsUntrusted() (message = gst_bus_pop_filtered(bus, static_cast(GST_MESSAGE_ERROR))) != nullptr, TestTimeout::mediumMs()); gst_message_unref(message); + verifyExpectedLogMessage(); + verifyExpectedLogMessage(); } void GStreamerTest::_testSourceFactoryWebSocketJpegFailedHandshake() @@ -791,8 +802,8 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegFailedHandshake() }); QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); - QVERIFY(GStreamer::SourceFactory::activate(source)); QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY(GStreamer::SourceFactory::activate(source)); GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); QVERIFY(bus); @@ -809,6 +820,9 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegRemoteDisconnect() QSKIP("appsrc/jpegparse plugins unavailable"); } + ignoreLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("WebSocket transport error code"))); + QWebSocketServer server(QStringLiteral("QGC WebSocket disconnect test"), QWebSocketServer::NonSecureMode); QVERIFY(server.listen(QHostAddress::LocalHost, 0)); QSignalSpy connectionSpy(&server, &QWebSocketServer::newConnection); @@ -826,8 +840,8 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegRemoteDisconnect() }); QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); - QVERIFY(GStreamer::SourceFactory::activate(source)); QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY(GStreamer::SourceFactory::activate(source)); QVERIFY_SIGNAL_WAIT(connectionSpy, TestTimeout::mediumMs()); QWebSocket* peer = server.nextPendingConnection(); From f98be6e9147524a8187cd246f1ba85ce5a3fff88 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 15:50:42 +0000 Subject: [PATCH 13/16] test(VideoManager): synchronize WebSocket source delivery --- .../SourceFactory/GStreamerSourceFactoryTest.cc | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 1b971e8d3752..e69bb58076d1 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -617,10 +617,11 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegDelivery() (void) peer->sendTextMessage(QStringLiteral("metadata is ignored")); QCOMPARE(peer->sendBinaryMessage(jpeg), static_cast(jpeg.size())); (void) peer->flush(); + QTRY_COMPARE_WITH_TIMEOUT(peer->bytesToWrite(), 0, TestTimeout::mediumMs()); - GstSample* sample = nullptr; - QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(binSink))) != nullptr, - TestTimeout::mediumMs()); + GstSample* sample = + tryPullSampleOrPreroll(GST_APP_SINK(binSink), TestTimeout::mediumDuration().count() * GST_MSECOND); + QVERIFY2(sample, "WebSocket JPEG stream did not deliver a sample before timeout"); QVERIFY(gst_buffer_get_size(gst_sample_get_buffer(sample)) > 0); gst_sample_unref(sample); @@ -763,12 +764,12 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegWssRejectsUntrusted() }); QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); - QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); - QVERIFY(GStreamer::SourceFactory::activate(source)); - GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); QVERIFY(bus); const auto busCleanup = qScopeGuard([&] { gst_object_unref(bus); }); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY(GStreamer::SourceFactory::activate(source)); + GstMessage* message = nullptr; QTRY_VERIFY_WITH_TIMEOUT( (message = gst_bus_pop_filtered(bus, static_cast(GST_MESSAGE_ERROR))) != nullptr, From e966cbb739e2f2db5f8774ef7f482326a6de4125 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 16:34:00 +0000 Subject: [PATCH 14/16] fix(VideoManager): post WebSocket errors to pipeline bus --- .../GStreamer/GstSourceFactory.cc | 31 ++++++++++++++++++- .../GStreamer/QGCWebSocketVideoSource.cc | 31 +++++++++++++------ .../GStreamer/QGCWebSocketVideoSource.h | 3 +- .../GStreamerSourceFactoryTest.cc | 15 +++++++-- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index b6fd248dfe21..c44e32a4814a 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -43,6 +43,24 @@ void configureAutopluggedParser([[maybe_unused]] GstBin* bin, [[maybe_unused]] G configureH26xParser(element); } +GstBus* pipelineBusForSource(GstElement* source) +{ + GstObject* current = GST_OBJECT(gst_object_ref(source)); + while (current) { + if (GST_IS_PIPELINE(current)) { + GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(current)); + gst_object_unref(current); + return bus; + } + + GstObject* parent = gst_object_get_parent(current); + gst_object_unref(current); + current = parent; + } + + return nullptr; +} + // Older Linux/system GStreamer needs an autoplug-query caps filter to keep parsebin on byte-stream output. #if defined(QGC_GST_ENABLE_LEGACY_PARSEBIN_CAPS_FILTER) gboolean filterParserCaps([[maybe_unused]] GstElement* bin, [[maybe_unused]] GstPad* pad, @@ -872,7 +890,18 @@ bool activate(GstElement* source) auto* context = static_cast(g_object_get_data(G_OBJECT(source), kWebSocketSourceContextKey)); - return !context || context->start(); + if (!context) { + return true; + } + + GstBus* bus = pipelineBusForSource(source); + if (!bus) { + qCWarning(GstSourceFactoryLog) << "WebSocket JPEG source must be attached to a pipeline before activation"; + return false; + } + const bool started = context->start(bus); + gst_object_unref(bus); + return started; } void deactivate(GstElement* source) diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc index 5eb255b8014b..6439d4de7a19 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc @@ -148,7 +148,7 @@ bool parseSingleJpeg(QByteArrayView message, QSize& dimensions) class WebSocketWorker final : public QObject { public: - WebSocketWorker(const QUrl& url, GstElement* appsrc) : _url(url), _appsrc(appsrc) {} + WebSocketWorker(const QUrl& url, GstElement* appsrc, GstBus* bus) : _url(url), _appsrc(appsrc), _bus(bus) {} ~WebSocketWorker() override { stop(); } @@ -225,7 +225,7 @@ class WebSocketWorker final : public QObject private: void _failStream(GQuark domain, gint code, const char* message, const QString& debug) { - if (!_accepting.load(std::memory_order_acquire) || !_running || _terminalError || !_appsrc) { + if (!_accepting.load(std::memory_order_acquire) || !_running || _terminalError || !_appsrc || !_bus) { return; } @@ -234,7 +234,9 @@ class WebSocketWorker final : public QObject const QByteArray debugUtf8 = debug.toUtf8(); GstMessage* errorMessage = gst_message_new_error(GST_OBJECT(_appsrc), error, debugUtf8.constData()); g_clear_error(&error); - (void) gst_element_post_message(_appsrc, errorMessage); + if (!gst_bus_post(_bus, errorMessage)) { + qCWarning(QGCWebSocketVideoSourceLog) << "Failed to post WebSocket terminal error to the pipeline bus"; + } } void _handleBinaryMessage(const QByteArray& message) @@ -283,6 +285,7 @@ class WebSocketWorker final : public QObject const QUrl _url; GstElement* _appsrc = nullptr; + GstBus* _bus = nullptr; QWebSocket* _webSocket = nullptr; std::atomic _accepting{true}; bool _running = false; @@ -298,14 +301,23 @@ class WebSocketThread final : public QThread setObjectName(QStringLiteral("QGCWebSocketVideo")); } - ~WebSocketThread() override { gst_clear_object(&_appsrc); } + ~WebSocketThread() override + { + gst_clear_object(&_bus); + gst_clear_object(&_appsrc); + } - bool startSession() + bool startSession(GstBus* bus) { + if (!bus) { + qCCritical(QGCWebSocketVideoSourceLog) << "WebSocket source activation requires a pipeline bus"; + return false; + } if (_started.exchange(true, std::memory_order_acq_rel)) { return isRunning() && !_stopRequested.load(std::memory_order_acquire); } + _bus = GST_BUS(gst_object_ref(bus)); start(); if (!_initialized.tryAcquire(1, kThreadLifecycleTimeoutMs)) { qCCritical(QGCWebSocketVideoSourceLog) << "Timed out starting the WebSocket video worker"; @@ -352,7 +364,7 @@ class WebSocketThread final : public QThread protected: void run() final { - WebSocketWorker worker(_url, _appsrc); + WebSocketWorker worker(_url, _appsrc, _bus); { QMutexLocker lock(&_workerMutex); _worker = &worker; @@ -379,6 +391,7 @@ class WebSocketThread final : public QThread private: const QUrl _url; GstElement* _appsrc = nullptr; + GstBus* _bus = nullptr; QMutex _workerMutex; WebSocketWorker* _worker = nullptr; QSemaphore _initialized; @@ -395,7 +408,7 @@ class QGCWebSocketVideoSource::Impl ~Impl() { stop(); } - bool start() { return _thread && _thread->startSession(); } + bool start(GstBus* bus) { return _thread && _thread->startSession(bus); } void stop() { @@ -417,9 +430,9 @@ QGCWebSocketVideoSource::QGCWebSocketVideoSource(const QUrl& url, GstElement* ap QGCWebSocketVideoSource::~QGCWebSocketVideoSource() = default; -bool QGCWebSocketVideoSource::start() +bool QGCWebSocketVideoSource::start(GstBus* bus) { - return _impl && _impl->start(); + return _impl && _impl->start(bus); } void QGCWebSocketVideoSource::stop() diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h index e339da73c443..49e5c065b17b 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h @@ -6,6 +6,7 @@ #include typedef struct _GstElement GstElement; +typedef struct _GstBus GstBus; /// Bridges one-JPEG-per-binary-message WebSocket streams into a bounded GStreamer appsrc. /// @@ -24,7 +25,7 @@ class QGCWebSocketVideoSource final Q_DISABLE_COPY_MOVE(QGCWebSocketVideoSource) - bool start(); + bool start(GstBus* bus); void stop(); static bool isCompleteJpeg(QByteArrayView message); diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index e69bb58076d1..369af8ee409d 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -872,9 +872,20 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegImmediateStop() GStreamer::SourceFactory::Config config; GstElement* source = GStreamer::SourceFactory::create(url, config); QVERIFY(source); + auto sourceCleanup = qScopeGuard([&] { gst_object_unref(source); }); + + GstElement* pipeline = gst_pipeline_new("websocket-jpeg-lifecycle-test"); + QVERIFY(pipeline); + const auto pipelineCleanup = qScopeGuard([&] { + GStreamer::SourceFactory::deactivate(source); + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); + sourceCleanup.dismiss(); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); QVERIFY(GStreamer::SourceFactory::activate(source)); - GStreamer::SourceFactory::deactivate(source); - gst_object_unref(source); } } From 34fba61128f9fb61e2176ac312da5566120b3283 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 17:55:12 +0000 Subject: [PATCH 15/16] test(VideoManager): retain polled WebSocket error --- .../GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 369af8ee409d..3b376e739fce 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -771,8 +771,11 @@ void GStreamerTest::_testSourceFactoryWebSocketJpegWssRejectsUntrusted() QVERIFY(GStreamer::SourceFactory::activate(source)); GstMessage* message = nullptr; + // QTRY re-evaluates its expression after the wait loop. Retain the first popped message so + // the final verification does not consume the bus a second time. QTRY_VERIFY_WITH_TIMEOUT( - (message = gst_bus_pop_filtered(bus, static_cast(GST_MESSAGE_ERROR))) != nullptr, + (message != nullptr) || + ((message = gst_bus_pop_filtered(bus, static_cast(GST_MESSAGE_ERROR))) != nullptr), TestTimeout::mediumMs()); gst_message_unref(message); verifyExpectedLogMessage(); From 743d6d08d986ef03997971b583d9e5934610b386 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Thu, 20 Aug 2026 09:14:32 +0000 Subject: [PATCH 16/16] fix(VideoManager): keep WebSocket stack independent --- .../VideoReceiver/GStreamer/GstSourceFactory.cc | 3 +-- .../VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index c44e32a4814a..31bbd4bae85b 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -561,8 +561,7 @@ GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) GstElement* buildWebSocketJpegSource(const QUrl& sourceUrl) { if (!sourceUrl.isValid() || sourceUrl.isRelative() || sourceUrl.host().isEmpty() || (sourceUrl.port() == 0)) { - qCWarning(GstSourceFactoryLog) << "Invalid WebSocket JPEG URL:" - << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); + qCWarning(GstSourceFactoryLog) << "Invalid WebSocket JPEG URL"; return nullptr; } if (!sourceUrl.userInfo().isEmpty()) { diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc index 6439d4de7a19..1203b7b33881 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc @@ -167,11 +167,11 @@ class WebSocketWorker final : public QObject _webSocket->setSslConfiguration(QGCNetworkHelper::createSslConfig()); } - (void) connect(_webSocket, &QWebSocket::connected, this, [this]() { - qCDebug(QGCWebSocketVideoSourceLog) << "Connected" << QGCNetworkHelper::redactedUrlForLogging(_url); + (void) connect(_webSocket, &QWebSocket::connected, this, []() { + qCDebug(QGCWebSocketVideoSourceLog) << "Connected to WebSocket video source"; }); (void) connect(_webSocket, &QWebSocket::disconnected, this, [this]() { - qCDebug(QGCWebSocketVideoSourceLog) << "Disconnected" << QGCNetworkHelper::redactedUrlForLogging(_url); + qCDebug(QGCWebSocketVideoSourceLog) << "Disconnected from WebSocket video source"; _failStream(GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_READ, "WebSocket video connection closed", QStringLiteral("Connection closed unexpectedly")); }); @@ -203,7 +203,7 @@ class WebSocketWorker final : public QObject _running = true; _terminalError = false; - qCDebug(QGCWebSocketVideoSourceLog) << "Opening" << QGCNetworkHelper::redactedUrlForLogging(_url); + qCDebug(QGCWebSocketVideoSourceLog) << "Opening WebSocket video source"; _webSocket->open(request); }