diff --git a/src/FlyView/CMakeLists.txt b/src/FlyView/CMakeLists.txt index e0f2b40f4aa..e0afebb6a31 100644 --- a/src/FlyView/CMakeLists.txt +++ b/src/FlyView/CMakeLists.txt @@ -16,6 +16,7 @@ qt_add_qml_module(FlyViewModule FlightDisplayViewDummy.qml FlightDisplayViewGStreamer.qml FlightDisplayViewGStreamerD3D11.qml + FlightDisplayViewMetal.qml FlightDisplayViewQtMultimedia.qml FlightDisplayViewUVC.qml FlightDisplayViewVideo.qml diff --git a/src/FlyView/FlightDisplayViewMetal.qml b/src/FlyView/FlightDisplayViewMetal.qml new file mode 100644 index 00000000000..72110e391c5 --- /dev/null +++ b/src/FlyView/FlightDisplayViewMetal.qml @@ -0,0 +1,22 @@ +import QtQuick +import QtMultimedia + +import QGroundControl + +// macOS Metal rendering path for GStreamer video. +// Frames are pushed from the C++ GstAppSinkAdapter to this VideoOutput's QVideoSink. +VideoOutput { + objectName: "videoContent" + fillMode: VideoOutput.PreserveAspectFit + + Connections { + target: QGroundControl.videoManager + function onImageFileChanged(filename) { + grabToImage(function(result) { + if (!result.saveToFile(filename)) { + console.error('Error capturing video frame'); + } + }); + } + } +} diff --git a/src/FlyView/FlightDisplayViewVideo.qml b/src/FlyView/FlightDisplayViewVideo.qml index 9531fa0e04c..a3d26615baa 100644 --- a/src/FlyView/FlightDisplayViewVideo.qml +++ b/src/FlyView/FlightDisplayViewVideo.qml @@ -137,6 +137,11 @@ Item { } } } + Component { + id: videoBackgroundMetalComponent + FlightDisplayViewMetal { + } + } Loader { // GStreamer is causing crashes on Lenovo laptop OpenGL Intel drivers. In order to workaround this // we don't load a QGCVideoBackground object when video is disabled. This prevents any video rendering @@ -146,7 +151,9 @@ Item { visible: _showStreamLoader sourceComponent: QGroundControl.videoManager.gstreamerD3D11Sink ? videoBackgroundD3D11Component - : videoBackgroundGLComponent + : QGroundControl.videoManager.gstreamerAppleSink + ? videoBackgroundMetalComponent + : videoBackgroundGLComponent property bool videoDisabled: QGroundControl.settingsManager.videoSettings.videoSource.rawValue === QGroundControl.settingsManager.videoSettings.disabledVideoSource } @@ -233,7 +240,10 @@ Item { anchors.fill: parent opacity: _camera ? (_camera.thermalMode === MavlinkCameraControlInterface.THERMAL_BLEND ? _camera.thermalOpacity / 100 : 1.0) : 0 sourceComponent: QGroundControl.videoManager.gstreamerD3D11Sink - ? thermalBackgroundD3D11 : thermalBackgroundGL + ? thermalBackgroundD3D11 + : QGroundControl.videoManager.gstreamerAppleSink + ? thermalBackgroundMetal + : thermalBackgroundGL onLoaded: { if (item) item.objectName = "thermalVideo" } Component { @@ -244,6 +254,10 @@ Item { id: thermalBackgroundD3D11 QGCVideoBackgroundD3D11 {} } + Component { + id: thermalBackgroundMetal + FlightDisplayViewMetal {} + } } } //-- Zoom diff --git a/src/QGCApplication.cc b/src/QGCApplication.cc index cf601aea504..ea389a17bc6 100644 --- a/src/QGCApplication.cc +++ b/src/QGCApplication.cc @@ -239,10 +239,11 @@ void QGCApplication::init() bool QGCApplication::_initVideo() { #ifdef QGC_GST_STREAMING - // GStreamer video playback requires OpenGL. On platforms where OpenGL - // is unavailable (e.g. recent macOS with only Metal), fall back to the - // default graphics API — video streaming won't work but the rest of - // QGC remains functional. + // GStreamer video rendering backend selection: + // - Windows D3D11: native RHI, no OpenGL needed. + // - macOS: appsink → QVideoSink → Metal RHI VideoOutput, no OpenGL needed. + // - Linux/other: qml6glsink requires OpenGL. Probe for a working GL context + // and fall back to the default graphics API if unavailable. // // The offscreen platform (used in CI boot tests) never provides a real // GL context, so skip the probe there — just set OpenGL API to exercise @@ -255,15 +256,17 @@ bool QGCApplication::_initVideo() QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); } qCDebug(QGCApplicationLog) << "D3D11 video sink available, using default graphics API"; +#elif defined(Q_OS_MACOS) + // macOS Metal rendering path: appsink → QVideoSink → VideoOutput. + // Do NOT force OpenGL — let Qt use the default Metal RHI backend. + // The appsink path in qgcvideosinkbin avoids the GL-dependent qml6glsink. + if (isOffscreen) { + QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); + } else { + qCDebug(QGCApplicationLog) << "macOS: using default RHI backend (Metal) for appsink video path"; + } #else - const bool skipGLProbe = isOffscreen -#if defined(Q_OS_MACOS) - // macOS still provides OpenGL (deprecated but functional). The - // QOpenGLContext::create() probe is unreliable without a native - // surface, so skip it and force OpenGL for qml6glsink. - || true -#endif - ; + const bool skipGLProbe = isOffscreen; if (skipGLProbe) { QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); @@ -276,7 +279,7 @@ bool QGCApplication::_initVideo() << "Using default graphics API (Metal/Vulkan)."; } } -#endif // QGC_GST_D3D11_SINK +#endif // QGC_GST_D3D11_SINK / Q_OS_MACOS #endif QGCCorePlugin::instance(); // CorePlugin must be initialized before VideoManager for Video Cleanup diff --git a/src/VideoManager/VideoManager.cc b/src/VideoManager/VideoManager.cc index 3d139c43ea2..d0cb76163da 100644 --- a/src/VideoManager/VideoManager.cc +++ b/src/VideoManager/VideoManager.cc @@ -22,6 +22,11 @@ #include "UVCReceiver.h" #ifdef QGC_GST_STREAMING #include "GStreamerHelpers.h" +#include "GStreamer.h" +#endif +#if defined(QGC_GST_STREAMING) && defined(Q_OS_MACOS) +#include +#include #endif #include @@ -501,6 +506,15 @@ bool VideoManager::gstreamerD3D11Sink() #endif } +bool VideoManager::gstreamerAppleSink() +{ +#if defined(QGC_GST_STREAMING) && defined(Q_OS_MACOS) && !defined(QGC_GST_D3D11_SINK) + return true; +#else + return false; +#endif +} + bool VideoManager::uvcEnabled() { return UVCReceiver::enabled(); @@ -888,6 +902,22 @@ void VideoManager::_initVideoReceiver(VideoReceiver *receiver, QQuickWindow *win } receiver->setSink(sink); +#if defined(QGC_GST_STREAMING) && defined(Q_OS_MACOS) && !defined(QGC_GST_D3D11_SINK) + // macOS Metal path: connect appsink inside the sinkbin to the QVideoSink + // belonging to the QML VideoOutput widget. + if (sink && widget) { + auto *videoOutput = qobject_cast(widget); + if (videoOutput) { + QVideoSink *videoSink = videoOutput->videoSink(); + if (!GStreamer::setupAppleSinkAdapter(sink, videoSink, receiver)) { + qCWarning(VideoManagerLog) << "setupAppleSinkAdapter failed" << receiver->name(); + } + } else { + qCWarning(VideoManagerLog) << "Widget is not a VideoOutput, cannot connect appsink" << receiver->name(); + } + } +#endif + (void) connect(receiver, &VideoReceiver::onStartComplete, this, [this, receiver](VideoReceiver::STATUS status) { qCDebug(VideoManagerLog) << "Video" << receiver->name() << "Start complete, status:" << status; switch (status) { diff --git a/src/VideoManager/VideoManager.h b/src/VideoManager/VideoManager.h index 86cf0473b17..bf3e8a9d0ff 100644 --- a/src/VideoManager/VideoManager.h +++ b/src/VideoManager/VideoManager.h @@ -27,6 +27,7 @@ class VideoManager : public QObject Q_PROPERTY(bool gstreamerEnabled READ gstreamerEnabled CONSTANT) Q_PROPERTY(bool gstreamerD3D11Sink READ gstreamerD3D11Sink CONSTANT) + Q_PROPERTY(bool gstreamerAppleSink READ gstreamerAppleSink CONSTANT) Q_PROPERTY(bool qtmultimediaEnabled READ qtmultimediaEnabled CONSTANT) Q_PROPERTY(bool uvcEnabled READ uvcEnabled CONSTANT) Q_PROPERTY(bool autoStreamConfigured READ autoStreamConfigured NOTIFY autoStreamConfiguredChanged) @@ -83,6 +84,7 @@ class VideoManager : public QObject void setfullScreen(bool on); static bool gstreamerEnabled(); static bool gstreamerD3D11Sink(); + static bool gstreamerAppleSink(); static bool qtmultimediaEnabled(); static bool uvcEnabled(); diff --git a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt index e3bca0e2647..b5efd52e28b 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt +++ b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt @@ -4,7 +4,7 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE VideoItemStub.h) if(QGC_ENABLE_GST_VIDEOSTREAMING) find_package(QGCGStreamer REQUIRED - COMPONENTS Core Base Video Gl GlPrototypes Rtsp + COMPONENTS Core Base Video Gl GlPrototypes Rtsp App OPTIONAL_COMPONENTS GlEgl GlWayland GlX11 ) @@ -20,6 +20,19 @@ if(QGC_ENABLE_GST_VIDEOSTREAMING) GstVideoReceiver.h ) + if(APPLE) + target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + GstAppSinkAdapter.cc + GstAppSinkAdapter.h + ) + target_link_libraries(${CMAKE_PROJECT_NAME} + PRIVATE + Qt6::Multimedia + Qt6::MultimediaQuickPrivate + ) + endif() + add_subdirectory(gstqgc) endif() diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc index 8946c87aee3..f84d5f66c26 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc @@ -4,6 +4,10 @@ #include "AppSettings.h" #include "GstVideoReceiver.h" +#ifdef Q_OS_MACOS +#include "GstAppSinkAdapter.h" +#endif + #include #include #include @@ -870,4 +874,24 @@ VideoReceiver *createVideoReceiver(QObject *parent) return new GstVideoReceiver(parent); } +bool setupAppleSinkAdapter(void *sinkBin, QVideoSink *videoSink, QObject *adapterParent) +{ +#ifdef Q_OS_MACOS + if (!sinkBin || !videoSink) { + return false; + } + + auto *adapter = new GstAppSinkAdapter(adapterParent); + if (!adapter->setup(GST_ELEMENT(sinkBin), videoSink)) { + qCCritical(GStreamerLog) << "GstAppSinkAdapter::setup() failed"; + adapter->deleteLater(); + return false; + } + return true; +#else + Q_UNUSED(sinkBin); Q_UNUSED(videoSink); Q_UNUSED(adapterParent); + return false; +#endif +} + } // namespace GStreamer diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.h b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.h index ebe9f161a21..b9ffb7dbd15 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.h @@ -7,6 +7,7 @@ Q_DECLARE_LOGGING_CATEGORY(GStreamerAPILog) Q_DECLARE_LOGGING_CATEGORY(GStreamerDecoderRanksLog) class QQuickItem; +class QVideoSink; class VideoReceiver; namespace GStreamer @@ -31,4 +32,8 @@ void *createVideoSink(QQuickItem *widget, QObject *parent = nullptr); void releaseVideoSink(void *sink); VideoReceiver *createVideoReceiver(QObject *parent = nullptr); +/// On macOS: connect the appsink inside the sinkbin to a QVideoSink. +/// Returns true on success. No-op (returns false) on other platforms. +bool setupAppleSinkAdapter(void *sinkBin, QVideoSink *videoSink, QObject *adapterParent); + } diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.cc b/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.cc new file mode 100644 index 00000000000..c1a120383b5 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.cc @@ -0,0 +1,151 @@ +#include "GstAppSinkAdapter.h" +#include "QGCLoggingCategory.h" + +#include +#include +#include + +#include +#include + +QGC_LOGGING_CATEGORY(GstAppSinkAdapterLog, "Video.GstAppSinkAdapter") + +GstAppSinkAdapter::GstAppSinkAdapter(QObject *parent) + : QObject(parent) +{ +} + +GstAppSinkAdapter::~GstAppSinkAdapter() +{ + teardown(); +} + +bool GstAppSinkAdapter::setup(GstElement *sinkBin, QVideoSink *videoSink) +{ + if (!sinkBin || !videoSink) { + qCWarning(GstAppSinkAdapterLog) << "setup() called with null arguments"; + return false; + } + + teardown(); + + _appsink = gst_bin_get_by_name(GST_BIN(sinkBin), "qgcappsink"); + if (!_appsink) { + qCWarning(GstAppSinkAdapterLog) << "Could not find 'qgcappsink' in sink bin"; + return false; + } + + _videoSink = videoSink; + _signalId = g_signal_connect(_appsink, "new-sample", G_CALLBACK(onNewSample), this); + qCDebug(GstAppSinkAdapterLog) << "Connected to appsink, signal id:" << _signalId; + return true; +} + +void GstAppSinkAdapter::teardown() +{ + if (_appsink && _signalId) { + g_signal_handler_disconnect(_appsink, _signalId); + _signalId = 0; + } + gst_clear_object(&_appsink); + _videoSink = nullptr; +} + +GstFlowReturn GstAppSinkAdapter::onNewSample(GstElement *appsink, gpointer userData) +{ + auto *self = static_cast(userData); + + GstSample *sample = gst_app_sink_pull_sample(GST_APP_SINK(appsink)); + if (!sample) { + return GST_FLOW_ERROR; + } + + GstBuffer *buffer = gst_sample_get_buffer(sample); + GstCaps *caps = gst_sample_get_caps(sample); + if (!buffer || !caps) { + gst_sample_unref(sample); + return GST_FLOW_ERROR; + } + + GstVideoInfo videoInfo; + if (!gst_video_info_from_caps(&videoInfo, caps)) { + qCWarning(GstAppSinkAdapterLog) << "Failed to parse video info from caps"; + gst_sample_unref(sample); + return GST_FLOW_ERROR; + } + + if (GST_VIDEO_INFO_FORMAT(&videoInfo) != GST_VIDEO_FORMAT_BGRA) { + qCWarning(GstAppSinkAdapterLog) << "Unexpected video format (expected BGRA)"; + gst_sample_unref(sample); + return GST_FLOW_ERROR; + } + + const int width = GST_VIDEO_INFO_WIDTH(&videoInfo); + const int height = GST_VIDEO_INFO_HEIGHT(&videoInfo); + if (width <= 0 || height <= 0) { + gst_sample_unref(sample); + return GST_FLOW_ERROR; + } + + GstMapInfo mapInfo; + if (!gst_buffer_map(buffer, &mapInfo, GST_MAP_READ)) { + qCWarning(GstAppSinkAdapterLog) << "Failed to map GStreamer buffer"; + gst_sample_unref(sample); + return GST_FLOW_ERROR; + } + + const QSize frameSize(width, height); + const QVideoFrameFormat format(frameSize, QVideoFrameFormat::Format_BGRA8888); + QVideoFrame videoFrame(format); + + if (!videoFrame.map(QVideoFrame::WriteOnly)) { + qCWarning(GstAppSinkAdapterLog) << "Failed to map QVideoFrame for writing"; + gst_buffer_unmap(buffer, &mapInfo); + gst_sample_unref(sample); + return GST_FLOW_ERROR; + } + + const int dstStride = videoFrame.bytesPerLine(0); + const int srcStride = GST_VIDEO_INFO_PLANE_STRIDE(&videoInfo, 0); + const uchar *src = mapInfo.data; + uchar *dst = videoFrame.bits(0); + + const int rowBytes = width * 4; // BGRA = 4 bytes per pixel + if (rowBytes > srcStride || rowBytes > dstStride) { + qCWarning(GstAppSinkAdapterLog) << "Stride smaller than row size:" + << "rowBytes" << rowBytes + << "srcStride" << srcStride + << "dstStride" << dstStride; + videoFrame.unmap(); + gst_buffer_unmap(buffer, &mapInfo); + gst_sample_unref(sample); + return GST_FLOW_ERROR; + } + + const gsize requiredSize = static_cast(height - 1) * srcStride + rowBytes; + if (mapInfo.size < requiredSize) { + qCWarning(GstAppSinkAdapterLog) << "Buffer too small:" << mapInfo.size << "<" << requiredSize; + videoFrame.unmap(); + gst_buffer_unmap(buffer, &mapInfo); + gst_sample_unref(sample); + return GST_FLOW_ERROR; + } + + if (srcStride == dstStride) { + memcpy(dst, src, static_cast(height) * srcStride); + } else { + for (int y = 0; y < height; ++y) { + memcpy(dst + y * dstStride, src + y * srcStride, rowBytes); + } + } + + videoFrame.unmap(); + gst_buffer_unmap(buffer, &mapInfo); + gst_sample_unref(sample); + + if (self->_videoSink) { + self->_videoSink->setVideoFrame(videoFrame); + } + + return GST_FLOW_OK; +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.h b/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.h new file mode 100644 index 00000000000..d6e8594d0ba --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/GstAppSinkAdapter.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include + +Q_DECLARE_LOGGING_CATEGORY(GstAppSinkAdapterLog) + +class QVideoSink; + +/// Bridges a GStreamer appsink to a Qt QVideoSink. +/// +/// Each decoded frame arriving at the appsink is copied into a QVideoFrame +/// and pushed to the QVideoSink, which renders through Qt's native RHI +/// backend (Metal on macOS, Vulkan/D3D elsewhere). +class GstAppSinkAdapter : public QObject +{ + Q_OBJECT + +public: + explicit GstAppSinkAdapter(QObject *parent = nullptr); + ~GstAppSinkAdapter() override; + + /// Connect to the named appsink inside @p sinkBin and push frames to @p videoSink. + /// Returns true on success. + bool setup(GstElement *sinkBin, QVideoSink *videoSink); + + /// Disconnect the callback (safe to call multiple times). + void teardown(); + +private: + static GstFlowReturn onNewSample(GstElement *appsink, gpointer userData); + + QVideoSink *_videoSink = nullptr; + GstElement *_appsink = nullptr; + gulong _signalId = 0; +}; diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.cc b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.cc index fcf8caeb0a4..2f5f50891f1 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.cc @@ -2,6 +2,9 @@ #include "gstqgcelements.h" #include +#if defined(__APPLE__) && defined(__MACH__) +#include +#endif #define GST_CAT_DEFAULT gst_qgc_video_sink_bin_debug GST_DEBUG_CATEGORY_STATIC(GST_CAT_DEFAULT); @@ -152,6 +155,7 @@ static void gst_qgc_video_sink_bin_init(GstQgcVideoSinkBin *self) { self->using_d3d11 = FALSE; + self->using_appsink = FALSE; #ifdef QGC_GST_D3D11_SINK // Prefer D3D11 sink on Windows — zero-copy from D3D hardware decoders, @@ -174,6 +178,47 @@ gst_qgc_video_sink_bin_init(GstQgcVideoSinkBin *self) } #endif +#if defined(__APPLE__) && defined(__MACH__) + // macOS Metal path: use appsink with videoconvert to avoid OpenGL dependency. + // Frames are extracted via the new-sample callback and pushed to a QVideoSink, + // which renders through Qt's native Metal RHI backend. + self->videoconvert = gst_element_factory_make("videoconvert", NULL); + self->appsink = gst_element_factory_make("appsink", "qgcappsink"); + if (self->videoconvert && self->appsink) { + // Accept BGRA so QVideoFrame can use a simple single-plane copy + GstCaps *caps = gst_caps_from_string("video/x-raw,format=BGRA"); + g_object_set(self->appsink, + "caps", caps, + "emit-signals", TRUE, + "max-buffers", 2, + "drop", TRUE, + "sync", FALSE, + NULL); + gst_caps_unref(caps); + + gst_bin_add_many(GST_BIN(self), self->videoconvert, self->appsink, NULL); + if (gst_element_link(self->videoconvert, self->appsink) + && gst_qgc_video_sink_bin_ghost_pad(self, self->videoconvert)) { + self->using_appsink = TRUE; + GST_INFO_OBJECT(self, "Using appsink (macOS Metal rendering path)"); + return; + } + + GST_ERROR_OBJECT(self, "Failed to link appsink path elements"); + gst_bin_remove(GST_BIN(self), self->videoconvert); + gst_bin_remove(GST_BIN(self), self->appsink); + self->videoconvert = NULL; + self->appsink = NULL; + return; + } else { + GST_ERROR_OBJECT(self, "Failed to create appsink path elements: videoconvert=%p appsink=%p", + (void *)self->videoconvert, (void *)self->appsink); + gst_clear_object(&self->videoconvert); + gst_clear_object(&self->appsink); + return; + } +#endif + // GL path: glsinkbin wraps qml6glsink with automatic glupload/glcolorconvert self->glsinkbin = gst_element_factory_make("glsinkbin", NULL); if (!self->glsinkbin) { @@ -210,9 +255,14 @@ gst_qgc_video_sink_bin_set_property(GObject *object, guint prop_id, const GValue { GstQgcVideoSinkBin *self = GST_QGC_VIDEO_SINK_BIN(object); - // Route properties to the active sink element - GstElement *activeSink = self->using_d3d11 ? self->d3d11sink : self->qmlglsink; - GstElement *activeBin = self->using_d3d11 ? self->d3d11sink : self->glsinkbin; + // Route properties to the active sink element. + // For the appsink path, widget/force-aspect-ratio/pixel-aspect-ratio are no-ops. + GstElement *activeSink = self->using_d3d11 ? self->d3d11sink + : self->using_appsink ? self->appsink + : self->qmlglsink; + GstElement *activeBin = self->using_d3d11 ? self->d3d11sink + : self->using_appsink ? self->appsink + : self->glsinkbin; switch (prop_id) { case PROP_ENABLE_LAST_SAMPLE: @@ -223,6 +273,8 @@ gst_qgc_video_sink_bin_set_property(GObject *object, guint prop_id, const GValue NULL); break; case PROP_WIDGET: + if (self->using_appsink) + break; // appsink path does not use a widget if (G_LIKELY(activeSink)) g_object_set(activeSink, PROP_WIDGET_NAME, @@ -230,6 +282,8 @@ gst_qgc_video_sink_bin_set_property(GObject *object, guint prop_id, const GValue NULL); break; case PROP_FORCE_ASPECT_RATIO: + if (self->using_appsink) + break; if (G_LIKELY(activeSink)) g_object_set(activeSink, PROP_FORCE_ASPECT_RATIO_NAME, @@ -237,6 +291,8 @@ gst_qgc_video_sink_bin_set_property(GObject *object, guint prop_id, const GValue NULL); break; case PROP_PIXEL_ASPECT_RATIO: { + if (self->using_appsink) + break; const gint num = gst_value_get_fraction_numerator(value); const gint den = gst_value_get_fraction_denominator(value); if (G_LIKELY(activeSink)) @@ -272,8 +328,12 @@ gst_qgc_video_sink_bin_get_property(GObject *object, guint prop_id, GValue *valu { GstQgcVideoSinkBin *self = GST_QGC_VIDEO_SINK_BIN(object); - GstElement *activeSink = self->using_d3d11 ? self->d3d11sink : self->qmlglsink; - GstElement *activeBin = self->using_d3d11 ? self->d3d11sink : self->glsinkbin; + GstElement *activeSink = self->using_d3d11 ? self->d3d11sink + : self->using_appsink ? self->appsink + : self->qmlglsink; + GstElement *activeBin = self->using_d3d11 ? self->d3d11sink + : self->using_appsink ? self->appsink + : self->glsinkbin; switch (prop_id) { case PROP_ENABLE_LAST_SAMPLE: { @@ -300,6 +360,10 @@ gst_qgc_video_sink_bin_get_property(GObject *object, guint prop_id, GValue *valu break; } case PROP_WIDGET: { + if (self->using_appsink) { + g_value_set_pointer(value, NULL); + break; + } gpointer widget = NULL; if (G_LIKELY(activeSink)) g_object_get(activeSink, @@ -310,6 +374,10 @@ gst_qgc_video_sink_bin_get_property(GObject *object, guint prop_id, GValue *valu break; } case PROP_FORCE_ASPECT_RATIO: { + if (self->using_appsink) { + g_value_set_boolean(value, FALSE); + break; + } gboolean enable = FALSE; if (G_LIKELY(activeSink)) g_object_get(activeSink, @@ -320,6 +388,10 @@ gst_qgc_video_sink_bin_get_property(GObject *object, guint prop_id, GValue *valu break; } case PROP_PIXEL_ASPECT_RATIO: { + if (self->using_appsink) { + gst_value_set_fraction(value, 1, 1); + break; + } gint num = 0, den = 1; if (G_LIKELY(activeSink)) g_object_get(activeSink, diff --git a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.h b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.h index 3534011ec23..7e25b93d14a 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.h +++ b/src/VideoManager/VideoReceiver/GStreamer/gstqgc/gstqgcvideosinkbin.h @@ -13,7 +13,10 @@ struct _GstQgcVideoSinkBin GstElement *glsinkbin; GstElement *qmlglsink; GstElement *d3d11sink; + GstElement *appsink; + GstElement *videoconvert; gboolean using_d3d11; + gboolean using_appsink; }; G_END_DECLS diff --git a/test/VideoManager/GStreamer/GStreamerTest.cc b/test/VideoManager/GStreamer/GStreamerTest.cc index 0a29004dbe7..b213c6eade8 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.cc +++ b/test/VideoManager/GStreamer/GStreamerTest.cc @@ -11,6 +11,12 @@ #include #include +#ifdef Q_OS_MACOS +#include "GstAppSinkAdapter.h" +#include +#include +#endif + void GStreamerTest::init() { UnitTest::init(); @@ -351,6 +357,104 @@ void GStreamerTest::_testRuntimeVersionCheck() #endif } +void GStreamerTest::_testAppsinkFrameDelivery() +{ +#ifndef Q_OS_MACOS + QSKIP("Appsink frame delivery test is macOS-only"); +#else + // Ensure the qgc plugin (including qgcvideosinkbin) is registered. + // _testCompleteInit runs before this slot, but guard against reorder. + GstElementFactory *guardFactory = gst_element_factory_find("qgcvideosinkbin"); + if (!guardFactory) { + GStreamer::completeInit(); + } else { + gst_object_unref(guardFactory); + } + + GstElementFactory *factory = gst_element_factory_find("qgcvideosinkbin"); + QVERIFY2(factory, "qgcvideosinkbin factory not found"); + gst_object_unref(factory); + + // Build pipeline: videotestsrc → videoconvert → qgcvideosinkbin(appsink) + GError *error = nullptr; + GstElement *pipeline = gst_parse_launch( + "videotestsrc num-buffers=10 ! " + "video/x-raw,format=I420,width=320,height=240,framerate=30/1 ! " + "videoconvert ! " + "video/x-raw,format=BGRA ! " + "qgcvideosinkbin name=sink", + &error); + if (error) { + const QString msg = QString::fromUtf8(error->message); + g_clear_error(&error); + QFAIL(qPrintable(QStringLiteral("Pipeline parse error: %1").arg(msg))); + } + QVERIFY2(pipeline, "Failed to create appsink test pipeline"); + + // Get the sink bin element + GstElement *sinkBin = gst_bin_get_by_name(GST_BIN(pipeline), "sink"); + QVERIFY2(sinkBin, "Could not find 'sink' element in pipeline"); + + // Create a QVideoSink and adapter + QVideoSink videoSink; + GstAppSinkAdapter adapter; + + int frameCount = 0; + QSize lastFrameSize; + QObject::connect(&videoSink, &QVideoSink::videoFrameChanged, &adapter, [&](const QVideoFrame &frame) { + frameCount++; + lastFrameSize = frame.size(); + }); + + const bool setupOk = adapter.setup(sinkBin, &videoSink); + QVERIFY2(setupOk, "GstAppSinkAdapter::setup() failed"); + + gst_object_unref(sinkBin); + + // Run the pipeline to completion + GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); + QVERIFY2(ret != GST_STATE_CHANGE_FAILURE, "Pipeline failed to transition to PLAYING"); + + GstBus *bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + + GstMessage *msg = gst_bus_timed_pop_filtered(bus, 10 * GST_SECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + QVERIFY2(msg, "Pipeline timed out waiting for EOS or ERROR"); + + if (GST_MESSAGE_TYPE(msg) == GST_MESSAGE_ERROR) { + GError *err = nullptr; + gchar *debug = nullptr; + gst_message_parse_error(msg, &err, &debug); + const QString errMsg = QStringLiteral("%1 (%2)") + .arg(err ? QString::fromUtf8(err->message) : QStringLiteral("unknown")) + .arg(debug ? QString::fromUtf8(debug) : QString()); + g_clear_error(&err); + g_free(debug); + gst_message_unref(msg); + gst_object_unref(bus); + adapter.teardown(); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + QFAIL(qPrintable(QStringLiteral("Pipeline error: %1").arg(errMsg))); + } + + QCOMPARE(GST_MESSAGE_TYPE(msg), GST_MESSAGE_EOS); + gst_message_unref(msg); + gst_object_unref(bus); + + // Wait for frames to be delivered via queued videoFrameChanged signals + QTRY_VERIFY_WITH_TIMEOUT(frameCount > 0, 5000); + + // Verify frames have the expected size + QCOMPARE(lastFrameSize, QSize(320, 240)); + + adapter.teardown(); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); +#endif // Q_OS_MACOS +} + #else void GStreamerTest::init() { UnitTest::init(); QSKIP("GStreamer not enabled"); } @@ -366,6 +470,7 @@ void GStreamerTest::_testCompleteInit() { QSKIP("GStreamer not enabled"); } void GStreamerTest::_testCreateVideoReceiver() { QSKIP("GStreamer not enabled"); } void GStreamerTest::_testPipelineSmokeTest() { QSKIP("GStreamer not enabled"); } void GStreamerTest::_testRuntimeVersionCheck() { QSKIP("GStreamer not enabled"); } +void GStreamerTest::_testAppsinkFrameDelivery() { QSKIP("GStreamer not enabled"); } #endif diff --git a/test/VideoManager/GStreamer/GStreamerTest.h b/test/VideoManager/GStreamer/GStreamerTest.h index f2dcdb42457..e2274ca44ec 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.h +++ b/test/VideoManager/GStreamer/GStreamerTest.h @@ -21,4 +21,5 @@ private slots: void _testCreateVideoReceiver(); void _testPipelineSmokeTest(); void _testRuntimeVersionCheck(); + void _testAppsinkFrameDelivery(); };