QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
GstAppSinkAdapter.cc
Go to the documentation of this file.
1#include "GstAppSinkAdapter.h"
5
6#include <QtCore/QMetaObject>
7#include <QtCore/QMutexLocker>
8
9#include <chrono>
10#include <QtMultimedia/QVideoFrame>
11#include <QtMultimedia/QVideoFrameFormat>
12#include <QtMultimedia/QVideoSink>
13
14#include <gst/app/gstappsink.h>
15#include <gst/video/gstvideometa.h>
16#include <gst/video/video-hdr.h>
17#include <gst/video/video-info.h>
18// Umbrella — provides GstVideoOrientationMethod, gst_video_orientation_from_tag, and (when
19// the build's gst-video supports it) the per-buffer GstVideoOrientationMeta accessor. Both
20// pieces work without QGC_HAS_GST_VIDEO_ORIENTATION_META except the buffer-meta lookup.
21#include <gst/video/video.h>
22#if GST_CHECK_VERSION(1, 24, 0)
23# include <gst/video/video-info-dma.h>
24#endif
25
26#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
28#include <QtGui/QGuiApplication>
29#include <qpa/qplatformnativeinterface.h>
30#include <EGL/egl.h>
31#endif
32#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH)
34#endif
35#if defined(QGC_HAS_GST_D3D11_GPU_PATH)
37#endif
38#if defined(QGC_HAS_GST_D3D12_GPU_PATH)
40#endif
41#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH)
43#endif
44#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
46#include <EGL/egl.h>
47#endif
48
49QGC_LOGGING_CATEGORY(GstAppSinkAdapterLog, "Video.GStreamer.GstAppSinkAdapter")
50
51QVideoFrameFormat::ColorSpace toQtColorSpace(GstVideoColorMatrix matrix)
52{
53 switch (matrix) {
54 case GST_VIDEO_COLOR_MATRIX_BT601: return QVideoFrameFormat::ColorSpace_BT601;
55 case GST_VIDEO_COLOR_MATRIX_BT709: return QVideoFrameFormat::ColorSpace_BT709;
56 case GST_VIDEO_COLOR_MATRIX_BT2020: return QVideoFrameFormat::ColorSpace_BT2020;
57 case GST_VIDEO_COLOR_MATRIX_SMPTE240M: return QVideoFrameFormat::ColorSpace_BT709; // closest Qt equivalent
58 case GST_VIDEO_COLOR_MATRIX_FCC: return QVideoFrameFormat::ColorSpace_BT601;
59 default: return QVideoFrameFormat::ColorSpace_Undefined;
60 }
61}
62
63QVideoFrameFormat::ColorTransfer toQtColorTransfer(GstVideoTransferFunction transfer)
64{
65 // Mapping mirrors qt6/qtmultimedia/.../qgst.cpp QGstCaps::formatAndVideoInfo() — keep
66 // in sync if Qt changes its mapping (last cross-check: Qt 6.10.3).
67 switch (transfer) {
68 case GST_VIDEO_TRANSFER_BT601: return QVideoFrameFormat::ColorTransfer_BT601;
69 case GST_VIDEO_TRANSFER_BT2020_10:
70 case GST_VIDEO_TRANSFER_BT2020_12:
71 case GST_VIDEO_TRANSFER_BT709: return QVideoFrameFormat::ColorTransfer_BT709;
72 case GST_VIDEO_TRANSFER_GAMMA20: return QVideoFrameFormat::ColorTransfer_BT709; // best fit per Qt
73 case GST_VIDEO_TRANSFER_SMPTE240M: return QVideoFrameFormat::ColorTransfer_BT709; // near-identical to BT.709 per Qt qgst.cpp:424
74 case GST_VIDEO_TRANSFER_GAMMA22:
75 case GST_VIDEO_TRANSFER_SRGB:
76 case GST_VIDEO_TRANSFER_ADOBERGB: return QVideoFrameFormat::ColorTransfer_Gamma22;
77 case GST_VIDEO_TRANSFER_GAMMA18: return QVideoFrameFormat::ColorTransfer_Gamma22; // closest Qt equivalent
78 case GST_VIDEO_TRANSFER_GAMMA28: return QVideoFrameFormat::ColorTransfer_Gamma28;
79 case GST_VIDEO_TRANSFER_GAMMA10: return QVideoFrameFormat::ColorTransfer_Linear;
80 case GST_VIDEO_TRANSFER_SMPTE2084: return QVideoFrameFormat::ColorTransfer_ST2084;
81 case GST_VIDEO_TRANSFER_ARIB_STD_B67: return QVideoFrameFormat::ColorTransfer_STD_B67;
82 // GST_VIDEO_TRANSFER_LOG100 / LOG316 have no Qt equivalent — leave as Unknown
83 default: return QVideoFrameFormat::ColorTransfer_Unknown;
84 }
85}
86
87QVideoFrameFormat::PixelFormat toQtPixelFormat(GstVideoFormat fmt)
88{
89 switch (fmt) {
90 case GST_VIDEO_FORMAT_BGRA: return QVideoFrameFormat::Format_BGRA8888;
91 case GST_VIDEO_FORMAT_RGBA: return QVideoFrameFormat::Format_RGBA8888;
92 case GST_VIDEO_FORMAT_BGRx: return QVideoFrameFormat::Format_BGRX8888;
93 case GST_VIDEO_FORMAT_RGBx: return QVideoFrameFormat::Format_RGBX8888;
94 // Qt6 has no 24-bit packed format; drop rather than corrupt stride arithmetic.
95 case GST_VIDEO_FORMAT_BGR:
96 case GST_VIDEO_FORMAT_RGB: return QVideoFrameFormat::Format_Invalid;
97 case GST_VIDEO_FORMAT_ARGB: return QVideoFrameFormat::Format_ARGB8888;
98 case GST_VIDEO_FORMAT_xRGB: return QVideoFrameFormat::Format_XRGB8888;
99 case GST_VIDEO_FORMAT_NV12: return QVideoFrameFormat::Format_NV12;
100 case GST_VIDEO_FORMAT_NV21: return QVideoFrameFormat::Format_NV21;
101 case GST_VIDEO_FORMAT_I420: return QVideoFrameFormat::Format_YUV420P;
102 case GST_VIDEO_FORMAT_Y42B: return QVideoFrameFormat::Format_YUV422P;
103 case GST_VIDEO_FORMAT_YV12: return QVideoFrameFormat::Format_YV12;
104 case GST_VIDEO_FORMAT_I420_10LE: return QVideoFrameFormat::Format_YUV420P10;
105 case GST_VIDEO_FORMAT_P010_10LE: return QVideoFrameFormat::Format_P010;
106 case GST_VIDEO_FORMAT_P016_LE: return QVideoFrameFormat::Format_P016;
107 case GST_VIDEO_FORMAT_AYUV: return QVideoFrameFormat::Format_AYUV;
108 case GST_VIDEO_FORMAT_YUY2: return QVideoFrameFormat::Format_YUYV;
109 case GST_VIDEO_FORMAT_UYVY: return QVideoFrameFormat::Format_UYVY;
110 case GST_VIDEO_FORMAT_GRAY8: return QVideoFrameFormat::Format_Y8;
111 case GST_VIDEO_FORMAT_GRAY16_LE: return QVideoFrameFormat::Format_Y16;
112 default: return QVideoFrameFormat::Format_Invalid;
113 }
114}
115
116namespace {
117
118QVideoFrameFormat::ColorRange toQtColorRange(GstVideoColorRange range)
119{
120 switch (range) {
121 case GST_VIDEO_COLOR_RANGE_0_255: return QVideoFrameFormat::ColorRange_Full;
122 case GST_VIDEO_COLOR_RANGE_16_235: return QVideoFrameFormat::ColorRange_Video;
123 default: return QVideoFrameFormat::ColorRange_Unknown;
124 }
125}
126
127} // namespace
128
129// Operates on the GstVideoOrientationMethod enum, which is in <gst/video/video.h>'s
130// always-present subset — independent of QGC_HAS_GST_VIDEO_ORIENTATION_META.
131void applyOrientationToFrame(QVideoFrame &frame, GstVideoOrientationMethod method)
132{
133 switch (method) {
134 case GST_VIDEO_ORIENTATION_IDENTITY:
135 frame.setRotation(QtVideo::Rotation::None);
136 frame.setMirrored(false);
137 break;
138 case GST_VIDEO_ORIENTATION_90R:
139 frame.setRotation(QtVideo::Rotation::Clockwise90);
140 frame.setMirrored(false);
141 break;
142 case GST_VIDEO_ORIENTATION_180:
143 frame.setRotation(QtVideo::Rotation::Clockwise180);
144 frame.setMirrored(false);
145 break;
146 case GST_VIDEO_ORIENTATION_90L:
147 frame.setRotation(QtVideo::Rotation::Clockwise270);
148 frame.setMirrored(false);
149 break;
150 case GST_VIDEO_ORIENTATION_HORIZ:
151 frame.setRotation(QtVideo::Rotation::None);
152 frame.setMirrored(true);
153 break;
154 case GST_VIDEO_ORIENTATION_VERT:
155 frame.setRotation(QtVideo::Rotation::Clockwise180);
156 frame.setMirrored(true);
157 break;
158 case GST_VIDEO_ORIENTATION_UL_LR:
159 frame.setRotation(QtVideo::Rotation::Clockwise90);
160 frame.setMirrored(true);
161 break;
162 case GST_VIDEO_ORIENTATION_UR_LL:
163 frame.setRotation(QtVideo::Rotation::Clockwise270);
164 frame.setMirrored(true);
165 break;
166 default:
167 static bool s_warnedUnhandled = false;
168 if (!s_warnedUnhandled) {
169 s_warnedUnhandled = true;
170 qCWarning(GstAppSinkAdapterLog) << "Unhandled GstVideoOrientationMethod" << method << "— treating as identity";
171 }
172 frame.setRotation(QtVideo::Rotation::None);
173 frame.setMirrored(false);
174 break;
175 }
176}
177
178namespace {
179
180void applyColorimetry(QVideoFrameFormat &format, const GstVideoInfo &info, GstCaps *caps)
181{
182 const GstVideoColorimetry &colorimetry = GST_VIDEO_INFO_COLORIMETRY(&info);
183 QVideoFrameFormat::ColorSpace colorSpace = toQtColorSpace(colorimetry.matrix);
184 // Live RTSP sources often omit colorimetry caps; resolution heuristic matches mpv.
185 if (colorSpace == QVideoFrameFormat::ColorSpace_Undefined) {
186 const int height = GST_VIDEO_INFO_HEIGHT(&info);
187 if (height > 0) {
188 colorSpace = (height <= 720) ? QVideoFrameFormat::ColorSpace_BT601
189 : QVideoFrameFormat::ColorSpace_BT709;
190 }
191 }
192 format.setColorSpace(colorSpace);
193 format.setColorTransfer(toQtColorTransfer(colorimetry.transfer));
194 QVideoFrameFormat::ColorRange range = toQtColorRange(colorimetry.range);
195 // H.264/H.265 omit VUI range but encode limited per spec — Unknown skips Qt's limited→full offset.
196 if (range == QVideoFrameFormat::ColorRange_Unknown
197 && colorimetry.matrix != GST_VIDEO_COLOR_MATRIX_RGB) {
198 range = QVideoFrameFormat::ColorRange_Video;
199 }
200 format.setColorRange(range);
201
202 // Prefer MaxCLL (tighter tone-mapping target) over mastering-display max-luminance.
203 GstVideoContentLightLevel cll;
204 bool clipApplied = false;
205 if (caps && gst_video_content_light_level_from_caps(&cll, caps)
206 && cll.max_content_light_level > 0) {
207 format.setMaxLuminance(static_cast<float>(cll.max_content_light_level));
208 clipApplied = true;
209 }
210 if (!clipApplied) {
211 GstVideoMasteringDisplayInfo masteringInfo;
212 if (caps && gst_video_mastering_display_info_from_caps(&masteringInfo, caps)) {
213 // GstVideoMasteringDisplayColorVolume max_luma is in 0.0001 cd/m².
214 const double maxLuminance = static_cast<double>(masteringInfo.max_display_mastering_luminance) / 10000.0;
215 if (maxLuminance > 0.0) {
216 format.setMaxLuminance(static_cast<float>(maxLuminance));
217 }
218 }
219 }
220}
221
222// Definition lives outside the anonymous namespace; declaration in GstAppSinkAdapter.h.
223
224void applyOrientationAndTiming(QVideoFrame &frame, [[maybe_unused]] GstBuffer *buffer,
225 int streamOrientation)
226{
227 // Per-buffer meta wins (per-frame override) — only available when the build's gst-video
228 // exports gst_buffer_get_video_orientation_meta. The stream-level fallback works on every
229 // gst-video install (gst_video_orientation_from_tag is in the umbrella header).
230#ifdef QGC_HAS_GST_VIDEO_ORIENTATION_META
231 if (GstVideoOrientationMeta *meta = gst_buffer_get_video_orientation_meta(buffer)) {
232 applyOrientationToFrame(frame, meta->orientation);
233 } else
234#endif
235 if (streamOrientation != static_cast<int>(GST_VIDEO_ORIENTATION_IDENTITY)) {
236 applyOrientationToFrame(frame, static_cast<GstVideoOrientationMethod>(streamOrientation));
237 }
238 if (GST_BUFFER_PTS_IS_VALID(buffer)) {
239 // GstClockTime is ns; QVideoFrame timestamps are µs.
240 frame.setStartTime(GST_BUFFER_PTS(buffer) / GST_USECOND);
241 if (GST_BUFFER_DURATION_IS_VALID(buffer)) {
242 frame.setEndTime((GST_BUFFER_PTS(buffer) + GST_BUFFER_DURATION(buffer)) / GST_USECOND);
243 }
244 }
245}
246
247void pushFrameQueued(QPointer<QVideoSink> sink, QVideoFrame &&frame)
248{
249 // Take QPointer by value: callers extract under _stateMutex and pass through, so we never construct a QPointer from a possibly-dangling raw pointer (UB) — the QPointer's own atomic guard tracks destruction across the snapshot→deliver window.
250 if (!sink) return;
251 // AutoConnection: direct call when already on the sink's thread, queued otherwise — mirrors Qt's qgstreamervideosink.cpp pattern.
252 QMetaObject::invokeMethod(sink.data(), [sink, f = std::move(frame)]() {
253 if (sink) sink->setVideoFrame(f);
254 }, Qt::AutoConnection);
255}
256
257} // namespace
258
259// QQuickVideoOutput computes its sample rect as viewport/frameSize (qquickvideooutput.cpp:498);
260// matches Qt's own gstreamer renderer (qgstvideorenderersink.cpp:230). externalTextureMatrix
261// is only consulted for Format_SamplerExternalOES, so it can't be used for crop on standard formats.
262QVideoFrameFormat applyCropMeta(QVideoFrameFormat format, GstBuffer *buffer)
263{
264 if (GstVideoCropMeta *crop = gst_buffer_get_video_crop_meta(buffer)) {
265 format.setViewport(QRect(crop->x, crop->y, crop->width, crop->height));
266 }
267 return format;
268}
269
270void GstAppSinkAdapter::_logFrameStats() const
271{
272 const quint64 cpu = _cpuFrames.load(std::memory_order_relaxed);
273 quint64 totalThisCall = cpu;
274 QString s = QStringLiteral("CPU:%1").arg(cpu);
275#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
276 const quint64 dma = _gpuFrames.load(std::memory_order_relaxed);
277 s += QStringLiteral(" DMABuf:%1/%2").arg(dma).arg(GstDmaBufVideoBuffer::peekMapFailureCount());
278 totalThisCall += dma;
279#endif
280#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH)
281 const quint64 gl = _glFrames.load(std::memory_order_relaxed);
282 s += QStringLiteral(" GL:%1/%2").arg(gl).arg(GstGlVideoBuffer::peekMapFailureCount());
283 totalThisCall += gl;
284#endif
285#if defined(QGC_HAS_GST_D3D11_GPU_PATH)
286 const quint64 d3d = _d3d11Frames.load(std::memory_order_relaxed);
287 s += QStringLiteral(" D3D11:%1/%2").arg(d3d).arg(GstD3D11VideoBuffer::peekMapFailureCount());
288 totalThisCall += d3d;
289#endif
290#if defined(QGC_HAS_GST_D3D12_GPU_PATH)
291 const quint64 d3d12 = _d3d12Frames.load(std::memory_order_relaxed);
292 s += QStringLiteral(" D3D12:%1/%2").arg(d3d12).arg(GstD3D12VideoBuffer::peekMapFailureCount());
293 totalThisCall += d3d12;
294#endif
295#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH)
296 const quint64 ios = _iosurfaceFrames.load(std::memory_order_relaxed);
297 s += QStringLiteral(" IOSurface:%1/%2").arg(ios).arg(GstIOSurfaceVideoBuffer::peekMapFailureCount());
298 totalThisCall += ios;
299#endif
300#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
301 const quint64 ahwb = _ahwbFrames.load(std::memory_order_relaxed);
302 s += QStringLiteral(" AHWBuf:%1/%2").arg(ahwb).arg(GstAHardwareBufferVideoBuffer::peekMapFailureCount());
303 totalThisCall += ahwb;
304#endif
305
306 QString tail;
307 {
308 QMutexLocker locker(&_stateMutex);
309 if (!_cachedAllocatorName.isEmpty()) {
310 const int w = GST_VIDEO_INFO_WIDTH(&_cachedInfo);
311 const int h = GST_VIDEO_INFO_HEIGHT(&_cachedInfo);
312 const char *fmt = gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&_cachedInfo));
313 tail = QStringLiteral(" (alloc=%1 %2x%3 %4")
314 .arg(_cachedAllocatorName).arg(w).arg(h).arg(QLatin1String(fmt));
315 }
316 }
317
318 const qint64 nowNs = std::chrono::duration_cast<std::chrono::nanoseconds>(
319 std::chrono::steady_clock::now().time_since_epoch()).count();
320 const qint64 prevNs = _lastStatsAtNs.exchange(nowNs, std::memory_order_relaxed);
321 if (prevNs != 0 && nowNs > prevNs && !tail.isEmpty()) {
322 // Approximate: total counters are cumulative, so derive delta against the previous _logFrameStats call.
323 // Skip the first call (prevNs == 0) since no window exists yet.
324 const double seconds = static_cast<double>(nowNs - prevNs) / 1e9;
325 const quint64 prevTotal = _lastStatsTotal.exchange(totalThisCall, std::memory_order_relaxed);
326 if (totalThisCall > prevTotal && seconds > 0.0) {
327 const double fps = static_cast<double>(totalThisCall - prevTotal) / seconds;
328 tail += QStringLiteral(" ~%1fps").arg(fps, 0, 'f', 1);
329 }
330 tail += QLatin1Char(')');
331 } else if (!tail.isEmpty()) {
332 _lastStatsTotal.store(totalThisCall, std::memory_order_relaxed);
333 tail += QLatin1Char(')');
334 }
335
336 qCDebug(GstAppSinkAdapterLog).noquote() << "Frame stats —" << s << tail;
337}
338
340 : QObject(parent)
341{
342}
343
344// Pipeline must be NULL before destroying the adapter; teardown() does not block in-flight callbacks.
349
350bool GstAppSinkAdapter::setup(GstElement *sinkBin, QVideoSink *videoSink)
351{
352 if (!sinkBin || !videoSink) {
353 qCWarning(GstAppSinkAdapterLog) << "setup() called with null arguments";
354 return false;
355 }
356
357 teardown();
358
359 if (!GST_IS_QGC_VIDEO_SINK_BIN(sinkBin)) {
360 qCWarning(GstAppSinkAdapterLog) << "sinkBin is not a GstQgcVideoSinkBin";
361 return false;
362 }
363
364 _appsink = gst_qgc_video_sink_bin_get_appsink(GST_QGC_VIDEO_SINK_BIN(sinkBin));
365 if (!_appsink) {
366 qCWarning(GstAppSinkAdapterLog) << "qgcvideosinkbin has no appsink (not constructed?)";
367 return false;
368 }
369
370 {
371 QMutexLocker locker(&_stateMutex);
372 _videoSink = videoSink;
373 }
374
375#if defined(QGC_HAS_ANY_GPU_PATH)
376 // Bin owns the path: it set its `gpu-zerocopy` GObject prop at construct time. Reading it back here keeps adapter telemetry in lockstep with whichever pipeline the bin actually built — no fact-vs-bin desync.
377 {
378 gboolean binZeroCopy = FALSE;
379 g_object_get(sinkBin, "gpu-zerocopy", &binZeroCopy, NULL);
380 _gpuPathEnabled = binZeroCopy ? true : false;
381 }
382#endif
383#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
384 if (_gpuPathEnabled) {
385 // Construction-time hint only — render-time the buffer's mapTextures() prefers eglGetCurrentDisplay() over this so xcb_egl mismatches between Qt's actual EGLDisplay and eglGetDisplay(EGL_DEFAULT_DISPLAY) don't cause silent black-frame imports.
386 _eglDisplay = EGL_NO_DISPLAY;
387 const QString platform = QGuiApplication::platformName();
388 if (platform == QLatin1String("wayland") || platform == QLatin1String("wayland-egl")) {
389 if (auto *ni = QGuiApplication::platformNativeInterface()) {
390 _eglDisplay = static_cast<EGLDisplay>(ni->nativeResourceForIntegration("egldisplay"));
391 }
392 }
393 if (_eglDisplay == EGL_NO_DISPLAY) {
394 _eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
395 }
396 if (_eglDisplay == EGL_NO_DISPLAY) {
397 qCWarning(GstAppSinkAdapterLog) << "GPU zero-copy requested but EGLDisplay unavailable on platform"
398 << platform << "— DMABuf path disabled";
399 } else {
400 qCInfo(GstAppSinkAdapterLog) << "DMABuf zero-copy path available on" << platform
401 << "— actual path chosen at caps negotiation";
402 }
403 }
404#endif
405#if defined(QGC_HAS_GST_D3D11_GPU_PATH)
406 if (_gpuPathEnabled) {
407 qCInfo(GstAppSinkAdapterLog) << "D3D11 zero-copy path available — actual path chosen at caps negotiation";
408 }
409#endif
410#if defined(QGC_HAS_GST_D3D12_GPU_PATH)
411 if (_gpuPathEnabled) {
412 qCInfo(GstAppSinkAdapterLog) << "D3D12 zero-copy path available — actual path chosen at caps negotiation";
413 }
414#endif
415#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH)
416 if (_gpuPathEnabled) {
417 qCInfo(GstAppSinkAdapterLog) << "IOSurface zero-copy path available — actual path chosen at caps negotiation";
418 }
419#endif
420#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
421 if (_gpuPathEnabled) {
422 _ahwbEglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
423 if (_ahwbEglDisplay == EGL_NO_DISPLAY) {
424 qCWarning(GstAppSinkAdapterLog) << "AHardwareBuffer path: EGLDisplay unavailable";
425 } else {
426 qCInfo(GstAppSinkAdapterLog) << "AHardwareBuffer zero-copy path available"
427 << "— actual path chosen at caps negotiation";
428 }
429 }
430#endif
431
432 GstAppSinkCallbacks callbacks{};
433 callbacks.new_sample = onNewSample;
434 // teardown() clears callbacks before unref'ing _appsink — destroy_notify=NULL is safe.
435 gst_app_sink_set_callbacks(GST_APP_SINK(_appsink), &callbacks, this, nullptr);
436
437 // Install a sink-pad probe so we can observe every buffer that *reaches* the appsink,
438 // even ones the appsink later drops via max-buffers=1/drop=TRUE. Difference vs delivered
439 // counters = appsink-level drop pressure (separate from decoder QoS drops).
440 _appsinkInputFrames.store(0, std::memory_order_relaxed);
441 _flushing.store(false, std::memory_order_relaxed);
442 if (GstPad *sinkPad = gst_element_get_static_pad(_appsink, "sink")) {
443 // BUFFER + EVENT_DOWNSTREAM + EVENT_FLUSH in one probe. BUFFER feeds the input counter.
444 // EVENT_DOWNSTREAM catches serialized events (FLUSH_STOP arrives serialized after data).
445 // EVENT_FLUSH is REQUIRED to catch FLUSH_START — that event is non-serialized and bypasses
446 // the streaming thread, so EVENT_DOWNSTREAM alone would miss it (per gstpad.h:484-487).
447 _appsinkProbeId = gst_pad_add_probe(sinkPad,
448 GstPadProbeType(GST_PAD_PROBE_TYPE_BUFFER
449 | GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM
450 | GST_PAD_PROBE_TYPE_EVENT_FLUSH),
451 &GstAppSinkAdapter::appsinkBufferProbe, this, nullptr);
452 if (_appsinkProbeId == 0) {
453 qCWarning(GstAppSinkAdapterLog) << "gst_pad_add_probe(BUFFER) returned 0 — appsink drop counter disabled";
454 gst_object_unref(sinkPad);
455 } else {
456 // Hold the ref: removal in teardown() targets the pad regardless of _appsink lifetime.
457 _appsinkProbePad = sinkPad;
458 }
459 } else {
460 qCWarning(GstAppSinkAdapterLog) << "Could not obtain appsink sink pad — drop counter disabled";
461 }
462
463 _telemetryEmitTimer.setInterval(1000);
464 _telemetryEmitTimer.setSingleShot(false);
465 // setup() is idempotent — disconnect first so re-setup doesn't stack lambda emitters.
466 QObject::disconnect(&_telemetryEmitTimer, &QTimer::timeout, this, nullptr);
467 QObject::connect(&_telemetryEmitTimer, &QTimer::timeout, this, [this]() {
468 quint64 total = _cpuFrames.load(std::memory_order_relaxed);
469#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
470 total += _gpuFrames.load(std::memory_order_relaxed);
471#endif
472#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH)
473 total += _glFrames.load(std::memory_order_relaxed);
474#endif
475#if defined(QGC_HAS_GST_D3D11_GPU_PATH)
476 total += _d3d11Frames.load(std::memory_order_relaxed);
477#endif
478#if defined(QGC_HAS_GST_D3D12_GPU_PATH)
479 total += _d3d12Frames.load(std::memory_order_relaxed);
480#endif
481#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH)
482 total += _iosurfaceFrames.load(std::memory_order_relaxed);
483#endif
484#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
485 total += _ahwbFrames.load(std::memory_order_relaxed);
486#endif
487 if (total != _lastEmittedFrameTotal) {
488 _lastEmittedFrameTotal = total;
489 emit frameCountsChanged();
490 }
491 });
492 // QTimer::start must run on its owning thread (GUI); setup() may be on the streaming thread.
493 QMetaObject::invokeMethod(&_telemetryEmitTimer, qOverload<>(&QTimer::start), Qt::QueuedConnection);
494
495 qCDebug(GstAppSinkAdapterLog) << "Installed appsink callbacks";
496 return true;
497}
498
500{
501 if (hz < 1.0) {
502 return;
503 }
504 const quint64 periodNs = static_cast<quint64>(GST_SECOND / hz);
505 _refreshPeriodNs.store(periodNs, std::memory_order_release);
506
507 // Apply baseline now so first-frame budget isn't capped by the bin's 33 ms default.
508#if defined(QGC_GST_BUILD_VERSION_MAJOR) && \
509 (QGC_GST_BUILD_VERSION_MAJOR > 1 || \
510 (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR >= 24))
511 QMutexLocker locker(&_stateMutex);
512 if (_appsink) {
513 gst_app_sink_set_max_time(GST_APP_SINK(_appsink), static_cast<GstClockTime>(periodNs));
514 }
515#endif
516}
517
518void GstAppSinkAdapter::setSmoothingEnabled(bool enabled, qreal refreshHz)
519{
520 if (enabled == _smoothingEnabled.load(std::memory_order_acquire)) {
521 return;
522 }
523 if (enabled) {
524 // refreshHz may be 0 (headless) or NaN (broken QScreen) — clamp.
525 const int hz = (refreshHz >= 1.0 && refreshHz <= 240.0) ? int(qRound(refreshHz)) : 60;
526 const int periodMs = qMax(1, int(qRound(1000.0 / hz)));
527 // Start clock before publishing enabled=true so streaming thread sees a started clock.
528 _smoothingClock.start();
529 connect(&_smoothingTickTimer, &QTimer::timeout,
530 this, &GstAppSinkAdapter::_onSmoothingTick, Qt::UniqueConnection);
531 _smoothingTickTimer.setInterval(periodMs);
532 _smoothingTickTimer.setTimerType(Qt::PreciseTimer);
533 _smoothingEnabled.store(true, std::memory_order_release);
534 QMetaObject::invokeMethod(&_smoothingTickTimer, qOverload<>(&QTimer::start),
535 Qt::QueuedConnection);
536 qCInfo(GstAppSinkAdapterLog) << "Smoothing ring on: tick=" << periodMs
537 << "ms threshold=" << (kSmoothingThresholdNs / 1000000) << "ms capacity="
538 << kSmoothingRingCapacity;
539 } else {
540 _smoothingEnabled.store(false, std::memory_order_release);
541 QMetaObject::invokeMethod(&_smoothingTickTimer, &QTimer::stop, Qt::QueuedConnection);
542 QMutexLocker lock(&_smoothingMutex);
543 _smoothingRing.clear();
544 _smoothingFirstPtsNs = -1;
545 }
546}
547
548void GstAppSinkAdapter::_deliverFrame(QPointer<QVideoSink> sink, QVideoFrame &&frame, int64_t ptsNs)
549{
550 if (!_smoothingEnabled.load(std::memory_order_acquire)) {
551 pushFrameQueued(sink, std::move(frame));
552 return;
553 }
554 const qint64 nowNs = _smoothingClock.nsecsElapsed();
555 QMutexLocker lock(&_smoothingMutex);
556 if (_smoothingRing.size() >= kSmoothingRingCapacity) {
557 _smoothingRing.removeFirst();
558 const quint64 c = _smoothingDroppedFrames.fetch_add(1, std::memory_order_relaxed) + 1;
559 if ((c & 0x3F) == 1) {
560 qCDebug(GstAppSinkAdapterLog) << "Smoothing ring overflow; dropped oldest (total=" << c << ")";
561 }
562 }
563 _smoothingRing.append({std::move(frame),
564 ptsNs >= 0 ? ptsNs : static_cast<int64_t>(nowNs),
565 nowNs});
566}
567
568void GstAppSinkAdapter::_onSmoothingTick()
569{
570 QPointer<QVideoSink> sinkSnapshot;
571 {
572 QMutexLocker locker(&_stateMutex);
573 sinkSnapshot = _videoSink;
574 }
575 if (!sinkSnapshot) {
576 return;
577 }
578
579 QVideoFrame chosen;
580 {
581 QMutexLocker lock(&_smoothingMutex);
582 if (_smoothingRing.isEmpty()) {
583 return; // hold last good frame on freeze
584 }
585 if (_smoothingFirstPtsNs < 0) {
586 _smoothingFirstPtsNs = _smoothingRing.first().ptsNs;
587 _smoothingFirstClockNs = _smoothingRing.first().enqueuedNs;
588 }
589 const qint64 nowNs = _smoothingClock.nsecsElapsed();
590 const int64_t targetPts = _smoothingFirstPtsNs + (nowNs - _smoothingFirstClockNs);
591 int bestIdx = 0;
592 int64_t bestDelta = std::llabs(_smoothingRing[0].ptsNs - targetPts);
593 for (int i = 1; i < _smoothingRing.size(); ++i) {
594 const int64_t d = std::llabs(_smoothingRing[i].ptsNs - targetPts);
595 if (d < bestDelta) {
596 bestDelta = d;
597 bestIdx = i;
598 }
599 }
600 if (bestDelta > kSmoothingThresholdNs) {
601 // Re-anchor on next tick; ring contents become candidates for the new anchor.
602 _smoothingFirstPtsNs = -1;
603 return;
604 }
605 chosen = _smoothingRing[bestIdx].frame;
606 // Drop chosen + older so the same frame can't be picked again.
607 for (int i = bestIdx; i >= 0; --i) {
608 _smoothingRing.removeAt(i);
609 }
610 }
611 pushFrameQueued(sinkSnapshot, std::move(chosen));
612}
613
615{
616 // teardown() may fire from the streaming thread; queue stop to the timer's owner.
617 QMetaObject::invokeMethod(&_telemetryEmitTimer, &QTimer::stop, Qt::QueuedConnection);
618 // acq_rel exchange flips enabled then drains the ring so late _deliverFrame() can't refill.
619 if (_smoothingEnabled.exchange(false, std::memory_order_acq_rel)) {
620 QMetaObject::invokeMethod(&_smoothingTickTimer, &QTimer::stop, Qt::QueuedConnection);
621 QMutexLocker lock(&_smoothingMutex);
622 _smoothingRing.clear();
623 _smoothingFirstPtsNs = -1;
624 }
625
626 {
627 QString stats = QStringLiteral("CPU:%1").arg(_cpuFrames.load(std::memory_order_relaxed));
628 quint64 totalFrames = _cpuFrames.load(std::memory_order_relaxed);
629#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
630 const quint64 dmaFailures = GstDmaBufVideoBuffer::takeMapFailureCount();
631 stats += QStringLiteral(" DMABuf:%1 DMABuf-failures:%2").arg(_gpuFrames.load(std::memory_order_relaxed)).arg(dmaFailures);
632 totalFrames += _gpuFrames.load(std::memory_order_relaxed) + dmaFailures;
633 _gpuFrames.store(0, std::memory_order_relaxed);
634#endif
635#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH)
636 const quint64 glFailures = GstGlVideoBuffer::takeMapFailureCount();
637 const quint64 glReuse = GstGlVideoBuffer::takeTextureReuseHits();
638 quint64 glGpuWaits = 0;
639 const quint64 glCpuWaits = GstGlVideoBuffer::takeSyncWaitCounts(glGpuWaits);
640 stats += QStringLiteral(" GL:%1 GL-failures:%2 GL-reuse:%3 GL-wait[gpu/cpu]:%4/%5")
641 .arg(_glFrames.load(std::memory_order_relaxed))
642 .arg(glFailures).arg(glReuse).arg(glGpuWaits).arg(glCpuWaits);
643 totalFrames += _glFrames.load(std::memory_order_relaxed) + glFailures;
644 _glFrames.store(0, std::memory_order_relaxed);
645#endif
646#if defined(QGC_HAS_GST_D3D11_GPU_PATH)
647 const quint64 d3dFailures = GstD3D11VideoBuffer::takeMapFailureCount();
648 stats += QStringLiteral(" D3D11:%1 D3D11-failures:%2").arg(_d3d11Frames.load(std::memory_order_relaxed)).arg(d3dFailures);
649 totalFrames += _d3d11Frames.load(std::memory_order_relaxed) + d3dFailures;
650 _d3d11Frames.store(0, std::memory_order_relaxed);
651#endif
652#if defined(QGC_HAS_GST_D3D12_GPU_PATH)
653 const quint64 d3d12Failures = GstD3D12VideoBuffer::takeMapFailureCount();
654 stats += QStringLiteral(" D3D12:%1 D3D12-failures:%2").arg(_d3d12Frames.load(std::memory_order_relaxed)).arg(d3d12Failures);
655 totalFrames += _d3d12Frames.load(std::memory_order_relaxed) + d3d12Failures;
656 _d3d12Frames.store(0, std::memory_order_relaxed);
657#endif
658#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH)
659 const quint64 iosFailures = GstIOSurfaceVideoBuffer::takeMapFailureCount();
660 stats += QStringLiteral(" IOSurface:%1 IOSurface-failures:%2").arg(_iosurfaceFrames.load(std::memory_order_relaxed)).arg(iosFailures);
661 totalFrames += _iosurfaceFrames.load(std::memory_order_relaxed) + iosFailures;
662 _iosurfaceFrames.store(0, std::memory_order_relaxed);
663#endif
664#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
665 const quint64 ahwbFailures = GstAHardwareBufferVideoBuffer::takeMapFailureCount();
666 stats += QStringLiteral(" AHWBuf:%1 AHWBuf-failures:%2").arg(_ahwbFrames.load(std::memory_order_relaxed)).arg(ahwbFailures);
667 totalFrames += _ahwbFrames.load(std::memory_order_relaxed) + ahwbFailures;
668 _ahwbFrames.store(0, std::memory_order_relaxed);
669#endif
670 if (totalFrames > 0) {
671 qCInfo(GstAppSinkAdapterLog).noquote() << "Adapter teardown —" << stats;
672 }
673 }
674 _cpuFrames.store(0, std::memory_order_relaxed);
675 _lastEmittedFrameTotal = 0;
676
677 if (_appsink) {
678 // userData=nullptr ensures any racing new_sample callback receives nullptr and bails early;
679 // set_callbacks takes appsink's internal mutex before swapping the callbacks struct.
680 GstAppSinkCallbacks empty{};
681 gst_app_sink_set_callbacks(GST_APP_SINK(_appsink), &empty, nullptr, nullptr);
682 // Drain any buffer queued before the callbacks swap took effect.
683 while (GstSample *s = gst_app_sink_try_pull_sample(GST_APP_SINK(_appsink), 0)) {
684 gst_sample_unref(s);
685 }
686 }
687 // Drop the probe before releasing the pad ref. gst_pad_remove_probe is a no-op on id=0.
688 if (_appsinkProbeId != 0 && _appsinkProbePad) {
689 gst_pad_remove_probe(_appsinkProbePad, _appsinkProbeId);
690 }
691 _appsinkProbeId = 0;
692 gst_clear_object(&_appsinkProbePad);
693 _appsinkInputFrames.store(0, std::memory_order_relaxed);
694 gst_clear_object(&_appsink);
695
696 {
697 QMutexLocker locker(&_stateMutex);
698 _videoSink = nullptr;
699 // Drop the held caps ref; next setup() call repopulates on first sample.
700 if (_cachedCapsKey) {
701 gst_caps_unref(_cachedCapsKey);
702 _cachedCapsKey = nullptr;
703 }
704 _cachedFormat = QVideoFrameFormat();
705 _cachedPixelFormat = QVideoFrameFormat::Format_Invalid;
706 }
707
708#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
709 _eglDisplay = EGL_NO_DISPLAY;
710#endif
711#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
712 _ahwbEglDisplay = EGL_NO_DISPLAY;
713#endif
714#if defined(QGC_HAS_ANY_GPU_PATH)
715 _gpuPathEnabled = false;
716#endif
717 _active.store(true, std::memory_order_release);
718 _qosSampleCount = 0;
719 _qosAvgRate = 1.0;
720 _qosLastPts = GST_CLOCK_TIME_NONE;
721 _qosLastArrivalNs = 0;
722 _pipelineMinLatencyNs = 0;
723 _latencyValid = false;
724 _latencyRefreshPending.store(false, std::memory_order_relaxed);
725}
726
728{
729 const bool was = _active.exchange(active, std::memory_order_acq_rel);
730 if (was == active) return;
731 if (active) return;
732 // false transition: clear the sink with one empty frame so the previous stream's last
733 // image doesn't ghost. snapshot the QVideoSink under lock — same dangling-pointer
734 // contract as onNewSample (sink may be destroyed on its owner thread).
735 QPointer<QVideoSink> sinkSnapshot;
736 {
737 QMutexLocker locker(&_stateMutex);
738 sinkSnapshot = _videoSink;
739 }
740 if (sinkSnapshot) {
741 pushFrameQueued(sinkSnapshot, QVideoFrame{});
742 }
743}
744
745GstFlowReturn GstAppSinkAdapter::onNewSample(GstAppSink *appsink, gpointer userData)
746{
747 auto *self = static_cast<GstAppSinkAdapter *>(userData);
748 // nullptr after teardown() swaps in an empty callbacks struct with userData=nullptr.
749 if (!self) return GST_FLOW_OK;
750
751 // Drop the sample if a flush is in progress: a new_sample callback can race ahead of
752 // FLUSH_START between the upstream serializer and the appsink's queue. Returning
753 // GST_FLOW_FLUSHING tells appsink to discard without state-change side effects.
754 if (self->_flushing.load(std::memory_order_acquire)) {
755 if (GstSample *drop = gst_app_sink_try_pull_sample(appsink, 0)) {
756 gst_sample_unref(drop);
757 }
758 return GST_FLOW_FLUSHING;
759 }
760
761 // Inactive sink: pull-and-discard so appsink's queue doesn't back-pressure upstream.
762 if (!self->_active.load(std::memory_order_acquire)) {
763 if (GstSample *drop = gst_app_sink_try_pull_sample(appsink, 0)) {
764 gst_sample_unref(drop);
765 }
766 return GST_FLOW_OK;
767 }
768
769 GstSample *sample = gst_app_sink_pull_sample(appsink);
770 if (!sample) {
771 return GST_FLOW_ERROR;
772 }
773
774 GstBuffer *buffer = gst_sample_get_buffer(sample);
775 GstCaps *caps = gst_sample_get_caps(sample);
776 if (!buffer || !caps) {
777 gst_sample_unref(sample);
778 return GST_FLOW_ERROR;
779 }
780
781 // Copy the QPointer member directly so the snapshot stays sin-aware across the queued-delivery window — extracting a raw pointer here would dangle if the QVideoSink is destroyed on its owner thread before pushFrameQueued constructs its QPointer.
782 QPointer<QVideoSink> sinkSnapshot;
783 {
784 QMutexLocker locker(&self->_stateMutex);
785 sinkSnapshot = self->_videoSink;
786 }
787 if (!sinkSnapshot) {
788 gst_sample_unref(sample);
789 return GST_FLOW_OK;
790 }
791
792 // Lock pattern: check key → on miss parse outside lock → relock to store.
793 GstVideoInfo localInfo{};
794 QVideoFrameFormat localFormat;
795 int localPixelFormat = 0;
796
797 {
798 QMutexLocker locker(&self->_stateMutex);
799 if (caps == self->_cachedCapsKey) {
800 localInfo = self->_cachedInfo;
801 localFormat = self->_cachedFormat;
802 localPixelFormat = self->_cachedPixelFormat;
803 } else {
804 localPixelFormat = -1; // sentinel: cache miss, parse below
805 }
806 }
807
808 if (localPixelFormat == -1) {
809 GstVideoInfo parsedInfo{};
810 // GStreamer 1.24+ va plugin advertises DMABuf as format=DMA_DRM with drm-format=<fourcc:modifier>; gst_video_info_from_caps doesn't understand DMA_DRM and would fail here, so go through the DMA-DRM-aware variant first.
811#if GST_CHECK_VERSION(1, 24, 0)
812 if (gst_video_is_dma_drm_caps(caps)) {
813 GstVideoInfoDmaDrm drmInfo;
814 gst_video_info_dma_drm_init(&drmInfo);
815 if (!gst_video_info_dma_drm_from_caps(&drmInfo, caps)
816 || !gst_video_info_dma_drm_to_video_info(&drmInfo, &parsedInfo)) {
817 qCWarning(GstAppSinkAdapterLog) << "Failed to parse DMA-DRM video info from caps";
818 gst_sample_unref(sample);
819 return GST_FLOW_ERROR;
820 }
821 } else
822#endif
823 if (!gst_video_info_from_caps(&parsedInfo, caps)) {
824 qCWarning(GstAppSinkAdapterLog) << "Failed to parse video info from caps";
825 gst_sample_unref(sample);
826 return GST_FLOW_ERROR;
827 }
828 const QVideoFrameFormat::PixelFormat pixelFormat = toQtPixelFormat(
829 GST_VIDEO_INFO_FORMAT(&parsedInfo));
830 if (pixelFormat == QVideoFrameFormat::Format_Invalid) {
831 const GstVideoFormat fmt = GST_VIDEO_INFO_FORMAT(&parsedInfo);
832 if (self->_lastWarnedFormat.exchange(fmt, std::memory_order_relaxed) != fmt) {
833 qCWarning(GstAppSinkAdapterLog) << "Unsupported video format"
834 << gst_video_format_to_string(fmt);
835 }
836 gst_sample_unref(sample);
837 return GST_FLOW_ERROR;
838 }
839 const int w = GST_VIDEO_INFO_WIDTH(&parsedInfo);
840 const int h = GST_VIDEO_INFO_HEIGHT(&parsedInfo);
841 if (w <= 0 || h <= 0) {
842 gst_sample_unref(sample);
843 return GST_FLOW_ERROR;
844 }
845 QVideoFrameFormat parsedFormat(QSize(w, h), pixelFormat);
846 applyColorimetry(parsedFormat, parsedInfo, caps);
847 const int fpsN = GST_VIDEO_INFO_FPS_N(&parsedInfo);
848 const int fpsD = GST_VIDEO_INFO_FPS_D(&parsedInfo);
849 if (fpsN > 0 && fpsD > 0) {
850 parsedFormat.setStreamFrameRate(static_cast<qreal>(fpsN) / static_cast<qreal>(fpsD));
851 // max(refreshPeriod, framePeriod): refresh is floor (don't drop early), frame is ceiling.
852#if defined(QGC_GST_BUILD_VERSION_MAJOR) && \
853 (QGC_GST_BUILD_VERSION_MAJOR > 1 || \
854 (QGC_GST_BUILD_VERSION_MAJOR == 1 && QGC_GST_BUILD_VERSION_MINOR >= 24))
855 const quint64 framePeriodNs = static_cast<quint64>(GST_SECOND) * static_cast<quint64>(fpsD)
856 / static_cast<quint64>(fpsN);
857 const quint64 refreshNs = self->_refreshPeriodNs.load(std::memory_order_acquire);
858 const GstClockTime maxTime = static_cast<GstClockTime>(qMax(framePeriodNs, refreshNs));
859 gst_app_sink_set_max_time(appsink, maxTime);
860#endif
861 }
862
863 QString allocName;
864 {
865 GstMemory *mem0 = gst_buffer_peek_memory(buffer, 0);
866 allocName = QString::fromUtf8((mem0 && mem0->allocator) ? mem0->allocator->mem_type : "(none)");
867 qCInfo(GstAppSinkAdapterLog).noquote()
868 << "Caps changed — allocator:" << allocName
869 << "format:" << gst_video_format_to_string(GST_VIDEO_INFO_FORMAT(&parsedInfo))
870 << GST_VIDEO_INFO_WIDTH(&parsedInfo) << "x" << GST_VIDEO_INFO_HEIGHT(&parsedInfo);
871 }
872
873 // QVideoFrameFormat has no setPixelAspectRatio; GPU branch can't normalize non-1/1 PAR.
874 const int parN = GST_VIDEO_INFO_PAR_N(&parsedInfo);
875 const int parD = GST_VIDEO_INFO_PAR_D(&parsedInfo);
876 if (parN > 0 && parD > 0 && parN != parD) {
877#if defined(QGC_HAS_ANY_GPU_PATH)
878 if (self->_gpuPathEnabled) {
879 qCWarning(GstAppSinkAdapterLog).noquote()
880 << "Source has non-square PAR" << parN << "/" << parD
881 << "— GPU zero-copy renders distorted (no Qt API to compensate)."
882 << "Disable GPU zero-copy on this source for correct geometry.";
883 }
884#endif
885 }
886
887 gst_caps_ref(caps);
888 {
889 QMutexLocker locker(&self->_stateMutex);
890 if (self->_cachedCapsKey) {
891 gst_caps_unref(self->_cachedCapsKey);
892 }
893 self->_cachedCapsKey = caps;
894 self->_cachedInfo = parsedInfo;
895 self->_cachedFormat = parsedFormat;
896 self->_cachedPixelFormat = pixelFormat;
897 self->_cachedAllocatorName = allocName;
898 }
899
900 localInfo = parsedInfo;
901 localFormat = parsedFormat;
902 localPixelFormat = pixelFormat;
903 } else {
904 // v4l2h264dec can silently upgrade allocator (sysmem→DMABuf) without re-negotiation.
905 if (GstMemory *mem0 = gst_buffer_peek_memory(buffer, 0)) {
906 const QString memType = QString::fromUtf8(
907 (mem0->allocator) ? mem0->allocator->mem_type : "(none)");
908 QMutexLocker locker(&self->_stateMutex);
909 if (memType != self->_cachedAllocatorName) {
910 qCInfo(GstAppSinkAdapterLog).noquote()
911 << "Allocator changed mid-stream:" << self->_cachedAllocatorName
912 << "→" << memType;
913 self->_cachedAllocatorName = memType;
914 }
915 }
916 }
917
918 const GstVideoInfo &videoInfo = localInfo;
919
920 // PTS monotonicity guard: a regressed timestamp wedges QVideoOutput's internal advance.
921 if (GST_BUFFER_PTS_IS_VALID(buffer)) {
922 if (GST_BUFFER_FLAG_IS_SET(buffer, GST_BUFFER_FLAG_DISCONT)) {
923 self->_lastDeliveredPtsNs.store(-1, std::memory_order_release);
924 }
925 const int64_t pts = static_cast<int64_t>(GST_BUFFER_PTS(buffer));
926 const int64_t lastPts = self->_lastDeliveredPtsNs.load(std::memory_order_acquire);
927 if (lastPts >= 0 && pts < lastPts) {
928 const int fpsN = GST_VIDEO_INFO_FPS_N(&videoInfo);
929 const int fpsD = GST_VIDEO_INFO_FPS_D(&videoInfo);
930 const int64_t framePeriodNs = (fpsN > 0 && fpsD > 0)
931 ? static_cast<int64_t>(GST_SECOND) * fpsD / fpsN
932 : 33000000;
933 if (lastPts - pts > framePeriodNs) {
934 static std::atomic<quint64> s_ptsRegressionDrops{0};
935 const quint64 c = s_ptsRegressionDrops.fetch_add(1, std::memory_order_relaxed) + 1;
936 if ((c & 0x3F) == 1) {
937 qCWarning(GstAppSinkAdapterLog)
938 << "PTS regression — dropping buffer (pts=" << pts
939 << "last=" << lastPts << "delta=" << (lastPts - pts) << "ns; total drops=" << c << ")";
940 }
941 gst_sample_unref(sample);
942 return GST_FLOW_OK;
943 }
944 }
945 self->_lastDeliveredPtsNs.store(pts, std::memory_order_release);
946 }
947
948#if defined(QGC_HAS_ANY_GPU_PATH)
949 {
951 auto hwBuf = makeHwVideoBuffer(
952 sample, videoInfo, applyCropMeta(localFormat, buffer),
953 self->_gpuPathEnabled,
954#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
955 self->_eglDisplay,
956#else
957 nullptr,
958#endif
959#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
960 self->_ahwbEglDisplay,
961#else
962 nullptr,
963#endif
964 matchedPath);
965 if (hwBuf) {
966 QVideoFrame gpuFrame(std::move(hwBuf));
967 applyOrientationAndTiming(gpuFrame, buffer,
968 self->_streamOrientation.load(std::memory_order_acquire));
969 gst_sample_unref(sample);
970 switch (matchedPath) {
971#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
973 const quint64 c = self->_gpuFrames.fetch_add(1, std::memory_order_relaxed) + 1;
974 if ((c & 0xFF) == 0) self->_logFrameStats();
975 break;
976 }
977#endif
978#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH)
980 const quint64 c = self->_glFrames.fetch_add(1, std::memory_order_relaxed) + 1;
981 if ((c & 0xFF) == 0) self->_logFrameStats();
982 break;
983 }
984#endif
985#if defined(QGC_HAS_GST_D3D11_GPU_PATH)
987 const quint64 c = self->_d3d11Frames.fetch_add(1, std::memory_order_relaxed) + 1;
988 if ((c & 0xFF) == 0) self->_logFrameStats();
989 break;
990 }
991#endif
992#if defined(QGC_HAS_GST_D3D12_GPU_PATH)
994 const quint64 c = self->_d3d12Frames.fetch_add(1, std::memory_order_relaxed) + 1;
995 if ((c & 0xFF) == 0) self->_logFrameStats();
996 break;
997 }
998#endif
999#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH)
1001 const quint64 c = self->_iosurfaceFrames.fetch_add(1, std::memory_order_relaxed) + 1;
1002 if ((c & 0xFF) == 0) self->_logFrameStats();
1003 break;
1004 }
1005#endif
1006#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
1008 const quint64 c = self->_ahwbFrames.fetch_add(1, std::memory_order_relaxed) + 1;
1009 if ((c & 0xFF) == 0) self->_logFrameStats();
1010 break;
1011 }
1012#endif
1013 default: break;
1014 }
1015 const int64_t ptsNs = GST_BUFFER_PTS_IS_VALID(buffer)
1016 ? static_cast<int64_t>(GST_BUFFER_PTS(buffer)) : -1;
1017 self->_deliverFrame(sinkSnapshot, std::move(gpuFrame), ptsNs);
1018 self->_pushQosUpstream(appsink, buffer);
1019 return GST_FLOW_OK;
1020 }
1021 }
1022#endif
1023
1024 GstVideoFrame gstFrame;
1025 // gst_video_frame_map honors GstVideoMeta strides; bypass would require manual offset handling.
1026 if (!gst_video_frame_map(&gstFrame, const_cast<GstVideoInfo *>(&videoInfo), buffer, GST_MAP_READ)) {
1027 // PTS is the most useful clue for diagnosing hardware-fence / timeout failures.
1028 static std::atomic<quint64> s_failCount{0};
1029 const quint64 count = s_failCount.fetch_add(1, std::memory_order_relaxed) + 1;
1030 if ((count & 0x3F) == 1) {
1031 qCWarning(GstAppSinkAdapterLog) << "gst_video_frame_map failed; pts="
1032 << (GST_BUFFER_PTS_IS_VALID(buffer) ? GST_BUFFER_PTS(buffer) : 0)
1033 << "consecutive=" << count;
1034 }
1035 gst_sample_unref(sample);
1036 return GST_FLOW_ERROR;
1037 }
1038
1039 QVideoFrame videoFrame(applyCropMeta(localFormat, buffer));
1040
1041 if (!videoFrame.map(QVideoFrame::WriteOnly)) {
1042 qCWarning(GstAppSinkAdapterLog) << "Failed to map QVideoFrame for writing";
1043 gst_video_frame_unmap(&gstFrame);
1044 gst_sample_unref(sample);
1045 return GST_FLOW_ERROR;
1046 }
1047
1048 const int planes = GST_VIDEO_INFO_N_PLANES(&videoInfo);
1049 for (int p = 0; p < planes; ++p) {
1050 const int dstStride = videoFrame.bytesPerLine(p);
1051 const int compWidth = GST_VIDEO_FRAME_COMP_WIDTH(&gstFrame, p);
1052 const int compPstride = GST_VIDEO_FRAME_COMP_PSTRIDE(&gstFrame, p);
1053 const int srcStride = GST_VIDEO_FRAME_PLANE_STRIDE(&gstFrame, p);
1054 const int planeHeight = GST_VIDEO_FRAME_COMP_HEIGHT(&gstFrame, p);
1055 const int activeRowBytes = compWidth * compPstride;
1056 const uchar *src = static_cast<const uchar *>(GST_VIDEO_FRAME_PLANE_DATA(&gstFrame, p));
1057 uchar *dst = videoFrame.bits(p);
1058 if (!dst) {
1059 continue;
1060 }
1061 if (activeRowBytes > dstStride) {
1062 // Qt allocated less than the active pixel width — should be impossible.
1063 static bool s_warnedStrideOverflow = false;
1064 if (!s_warnedStrideOverflow) {
1065 s_warnedStrideOverflow = true;
1066 qCWarning(GstAppSinkAdapterLog)
1067 << "Plane" << p << ": activeRowBytes" << activeRowBytes
1068 << "> dstStride" << dstStride << "— skipping frame";
1069 }
1070 videoFrame.unmap();
1071 gst_video_frame_unmap(&gstFrame);
1072 gst_sample_unref(sample);
1073 return GST_FLOW_ERROR;
1074 }
1075 if (srcStride == dstStride && activeRowBytes == srcStride) {
1076 memcpy(dst, src, static_cast<size_t>(planeHeight) * srcStride);
1077 } else {
1078 for (int y = 0; y < planeHeight; ++y) {
1079 memcpy(dst + y * dstStride, src + y * srcStride, activeRowBytes);
1080 }
1081 }
1082 }
1083
1084 videoFrame.unmap();
1085 gst_video_frame_unmap(&gstFrame);
1086
1087 applyOrientationAndTiming(videoFrame, buffer,
1088 self->_streamOrientation.load(std::memory_order_acquire));
1089 const quint64 c = self->_cpuFrames.fetch_add(1, std::memory_order_relaxed) + 1;
1090 if ((c & 0xFF) == 0) self->_logFrameStats();
1091 const int64_t ptsNs = GST_BUFFER_PTS_IS_VALID(buffer)
1092 ? static_cast<int64_t>(GST_BUFFER_PTS(buffer)) : -1;
1093 gst_sample_unref(sample);
1094 self->_deliverFrame(sinkSnapshot, std::move(videoFrame), ptsNs);
1095 self->_pushQosUpstream(appsink, buffer);
1096 return GST_FLOW_OK;
1097}
1098
1099void GstAppSinkAdapter::_refreshLatency()
1100{
1101 if (!_appsink) return;
1102 GstQuery *q = gst_query_new_latency();
1103 if (!q) return;
1104 // Query on the appsink element propagates upstream through the live pipeline graph.
1105 if (gst_element_query(_appsink, q)) {
1106 gboolean live = FALSE;
1107 GstClockTime minLat = 0, maxLat = 0;
1108 gst_query_parse_latency(q, &live, &minLat, &maxLat);
1109 _pipelineMinLatencyNs = (minLat != GST_CLOCK_TIME_NONE) ? minLat : 0;
1110 _latencyValid = true;
1111 qCDebug(GstAppSinkAdapterLog) << "Pipeline latency refreshed: live=" << live
1112 << "min=" << _pipelineMinLatencyNs << "ns max=" << maxLat << "ns";
1113 }
1114 // On failure, _latencyValid stays false; _pushQosUpstream retries every frame.
1115 gst_query_unref(q);
1116}
1117
1118void GstAppSinkAdapter::_pushQosUpstream(GstAppSink * /*appsink*/, GstBuffer *buffer)
1119{
1120 if (!_qosUpstreamEnabled) return;
1121 if (!GST_BUFFER_PTS_IS_VALID(buffer)) return;
1122
1123 // Retry every frame until valid; then every N frames or when bus thread pokes the flag.
1124 if (!_latencyValid || _latencyRefreshPending.exchange(false, std::memory_order_relaxed)
1125 || (_qosSampleCount > 0 && (_qosSampleCount % kLatencyRefreshInterval) == 0)) {
1126 _refreshLatency();
1127 }
1128
1129 const GstClockTime pts = GST_BUFFER_PTS(buffer);
1130
1131 // Measure wall-clock arrival interval using steady_clock; PTS spacing gives expected interval.
1132 const GstClockTime nowNs = static_cast<GstClockTime>(
1133 std::chrono::duration_cast<std::chrono::nanoseconds>(
1134 std::chrono::steady_clock::now().time_since_epoch()).count());
1135
1136 ++_qosSampleCount;
1137
1138 if (_qosLastPts == GST_CLOCK_TIME_NONE || _qosLastArrivalNs == 0
1139 || pts <= _qosLastPts) {
1140 _qosLastPts = pts;
1141 _qosLastArrivalNs = nowNs;
1142 return;
1143 }
1144
1145 const GstClockTime ptsDelta = pts - _qosLastPts;
1146 const GstClockTime arrivalDelta = nowNs - _qosLastArrivalNs;
1147
1148 _qosLastPts = pts;
1149 _qosLastArrivalNs = nowNs;
1150
1151 if (ptsDelta == 0) return;
1152
1153 // rate = arrivalDelta / ptsDelta; >1 means we're consuming slower than the stream.
1154 const double rate = static_cast<double>(arrivalDelta) / static_cast<double>(ptsDelta);
1155
1156 // EWMA: positive deviations averaged over window=16, negative over window=4 —
1157 // mirrors gstbasesink.c UPDATE_RUNNING_AVG_P/N so slow-path feedback stabilises.
1158 if (rate > _qosAvgRate) {
1159 _qosAvgRate = (rate + 15.0 * _qosAvgRate) / 16.0; // UPDATE_RUNNING_AVG_P
1160 } else {
1161 _qosAvgRate = (rate + 3.0 * _qosAvgRate) / 4.0; // UPDATE_RUNNING_AVG_N
1162 }
1163
1164 // Skip warmup samples and only emit every kQosInterval frames to avoid event spam.
1165 if (_qosSampleCount < kQosWarmup) return;
1166 if ((_qosSampleCount % kQosInterval) != 0) return;
1167
1168 // High-latency pipelines (e.g. RTSP jitter buffer) look "slow" by _pipelineMinLatencyNs;
1169 // if absolute lateness is within the pipeline's own minimum latency, treat as steady-state.
1170 if (_pipelineMinLatencyNs > 0) {
1171 // GST_CLOCK_DIFF avoids UB on guint64 subtraction; returns signed GstClockTimeDiff.
1172 const GstClockTimeDiff absLateness = GST_CLOCK_DIFF(ptsDelta, arrivalDelta);
1173 if (absLateness < static_cast<GstClockTimeDiff>(_pipelineMinLatencyNs)) {
1174 return;
1175 }
1176 }
1177
1178 // proportion is clamped to [0.0, 16.0] per gstbasesink convention.
1179 const gdouble proportion = qBound(0.0, _qosAvgRate, 16.0);
1180
1181 // diff=0: we don't sync to the pipeline clock so pipeline-clock lateness is unavailable.
1182 const GstClockTimeDiff diff = 0;
1183
1184 GstQOSType type;
1185 if (proportion >= 16.0) {
1186 type = GST_QOS_TYPE_THROTTLE; // catastrophically behind
1187 } else if (proportion > 1.0) {
1188 type = GST_QOS_TYPE_UNDERFLOW; // we're behind; upstream should slow its production rate
1189 } else {
1190 type = GST_QOS_TYPE_OVERFLOW; // we have headroom; upstream is producing too fast
1191 }
1192
1193 GstEvent *event = gst_event_new_qos(type, proportion, diff, pts);
1194 if (event) {
1195 if (!_appsinkProbePad) {
1196 gst_event_unref(event);
1197 return;
1198 }
1199 // _appsinkProbePad is ref-held at setup(); reuse avoids a per-frame get/unref pair.
1200 const bool pushed = gst_pad_push_event(_appsinkProbePad, event);
1201 if (!pushed) {
1202 static bool s_qosPushFailed = false;
1203 if (!s_qosPushFailed) {
1204 s_qosPushFailed = true;
1205 qCDebug(GstAppSinkAdapterLog) << "QoS upstream push failed (silenced after first)";
1206 }
1207 }
1208 }
1209}
1210
1211quint64 GstAppSinkAdapter::gpuFrameCount() const noexcept
1212{
1213 QMutexLocker locker(&_stateMutex); // snapshot read; torn-write-safe on supported arches
1214 quint64 count = 0;
1215#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
1216 count += _gpuFrames.load(std::memory_order_relaxed);
1217#endif
1218#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH)
1219 count += _glFrames.load(std::memory_order_relaxed);
1220#endif
1221#if defined(QGC_HAS_GST_D3D11_GPU_PATH)
1222 count += _d3d11Frames.load(std::memory_order_relaxed);
1223#endif
1224#if defined(QGC_HAS_GST_D3D12_GPU_PATH)
1225 count += _d3d12Frames.load(std::memory_order_relaxed);
1226#endif
1227#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH)
1228 count += _iosurfaceFrames.load(std::memory_order_relaxed);
1229#endif
1230#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
1231 count += _ahwbFrames.load(std::memory_order_relaxed);
1232#endif
1233 return count;
1234}
1235
1236quint64 GstAppSinkAdapter::cpuFrameCount() const noexcept
1237{
1238 QMutexLocker locker(&_stateMutex); // snapshot read; torn-write-safe on supported arches
1239 return _cpuFrames.load(std::memory_order_relaxed);
1240}
1241
1243{
1244 QMutexLocker locker(&_stateMutex); // snapshot read; torn-write-safe on supported arches
1245#if defined(QGC_HAS_ANY_GPU_PATH)
1246 return _gpuPathEnabled ? _cpuFrames.load(std::memory_order_relaxed) : quint64(0);
1247#else
1248 return 0;
1249#endif
1250}
1251
1252quint64 GstAppSinkAdapter::_deliveredFrames() const noexcept
1253{
1254 quint64 count = _cpuFrames.load(std::memory_order_relaxed);
1255#if defined(QGC_HAS_GST_DMABUF_GPU_PATH)
1256 count += _gpuFrames.load(std::memory_order_relaxed);
1257#endif
1258#if defined(QGC_HAS_GST_GLMEMORY_GPU_PATH)
1259 count += _glFrames.load(std::memory_order_relaxed);
1260#endif
1261#if defined(QGC_HAS_GST_D3D11_GPU_PATH)
1262 count += _d3d11Frames.load(std::memory_order_relaxed);
1263#endif
1264#if defined(QGC_HAS_GST_D3D12_GPU_PATH)
1265 count += _d3d12Frames.load(std::memory_order_relaxed);
1266#endif
1267#if defined(QGC_HAS_GST_IOSURFACE_GPU_PATH)
1268 count += _iosurfaceFrames.load(std::memory_order_relaxed);
1269#endif
1270#if defined(QGC_HAS_GST_AHARDWAREBUFFER_GPU_PATH)
1271 count += _ahwbFrames.load(std::memory_order_relaxed);
1272#endif
1273 return count;
1274}
1275
1277{
1278 return _appsinkInputFrames.load(std::memory_order_relaxed);
1279}
1280
1282{
1283 const quint64 in = _appsinkInputFrames.load(std::memory_order_relaxed);
1284 const quint64 out = _deliveredFrames();
1285 // The probe runs synchronously on the streaming thread before the buffer is enqueued;
1286 // delivered counters lag by at most the in-flight callback. Underflow is therefore possible
1287 // for a single-buffer race window — clamp to zero so QML never sees a wraparound value.
1288 return in > out ? in - out : 0;
1289}
1290
1291GstPadProbeReturn GstAppSinkAdapter::appsinkBufferProbe(GstPad *, GstPadProbeInfo *info, gpointer userData)
1292{
1293 auto *self = static_cast<GstAppSinkAdapter *>(userData);
1294 if (!self) return GST_PAD_PROBE_OK;
1295 const auto type = GST_PAD_PROBE_INFO_TYPE(info);
1296 // FLUSH_START arrives with only EVENT_FLUSH set (non-serialized); FLUSH_STOP is serialized
1297 // and arrives with EVENT_DOWNSTREAM set. Accept either to catch both halves of the flush.
1298 if (type & (GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM | GST_PAD_PROBE_TYPE_EVENT_FLUSH)) {
1299 // FLUSH_START is non-serialized: arrives even when the streaming thread is mid-callback.
1300 // Set the flag before letting the event through so a parallel new_sample sees it.
1301 if (GstEvent *event = GST_PAD_PROBE_INFO_EVENT(info)) {
1302 switch (GST_EVENT_TYPE(event)) {
1303 case GST_EVENT_FLUSH_START:
1304 self->_flushing.store(true, std::memory_order_release);
1305 break;
1306 case GST_EVENT_FLUSH_STOP:
1307 self->_flushing.store(false, std::memory_order_release);
1308 // New source may not re-emit orientation tag; reset to identity.
1309 self->_streamOrientation.store(static_cast<int>(GST_VIDEO_ORIENTATION_IDENTITY),
1310 std::memory_order_release);
1311 self->_lastDeliveredPtsNs.store(-1, std::memory_order_release);
1312 // Smoothing-ring PTS belongs to the old timeline.
1313 if (self->_smoothingEnabled.load(std::memory_order_acquire)) {
1314 QMutexLocker lock(&self->_smoothingMutex);
1315 self->_smoothingRing.clear();
1316 self->_smoothingFirstPtsNs = -1;
1317 }
1318 break;
1319 case GST_EVENT_TAG: {
1320 GstTagList *taglist = nullptr;
1321 gst_event_parse_tag(event, &taglist);
1322 GstVideoOrientationMethod method = GST_VIDEO_ORIENTATION_IDENTITY;
1323 if (taglist && gst_video_orientation_from_tag(taglist, &method)) {
1324 self->_streamOrientation.store(static_cast<int>(method),
1325 std::memory_order_release);
1326 }
1327 break;
1328 }
1329 default:
1330 break;
1331 }
1332 }
1333 return GST_PAD_PROBE_OK;
1334 }
1335 if (type & GST_PAD_PROBE_TYPE_BUFFER) {
1336 self->_appsinkInputFrames.fetch_add(1, std::memory_order_relaxed);
1337 }
1338 return GST_PAD_PROBE_OK;
1339}
QVideoFrameFormat::PixelFormat toQtPixelFormat(GstVideoFormat fmt)
QVideoFrameFormat::ColorTransfer toQtColorTransfer(GstVideoTransferFunction transfer)
QVideoFrameFormat applyCropMeta(QVideoFrameFormat format, GstBuffer *buffer)
QVideoFrameFormat::ColorSpace toQtColorSpace(GstVideoColorMatrix matrix)
void applyOrientationToFrame(QVideoFrame &frame, GstVideoOrientationMethod method)
QVideoFrameFormat::PixelFormat toQtPixelFormat(GstVideoFormat fmt)
QVideoFrameFormat applyCropMeta(QVideoFrameFormat format, GstBuffer *buffer)
std::unique_ptr< QHwVideoBuffer > makeHwVideoBuffer(GstSample *sample, const GstVideoInfo &info, QVideoFrameFormat format, bool gpuEnabled, void *, void *, HwVideoBufferPath &matchedPath)
HwVideoBufferPath
Identifies which GPU path was chosen; used by the adapter to increment the right counter.
struct _GstElement GstElement
#define QGC_LOGGING_CATEGORY(name, categoryStr)
Bridges a GStreamer appsink to a Qt QVideoSink.
void teardown()
Disconnect the callback (safe to call multiple times).
quint64 gpuFrameCount() const noexcept
void frameCountsChanged()
void setActive(bool active)
void setSmoothingEnabled(bool enabled, qreal refreshHz)
quint64 cpuFrameCount() const noexcept
GstAppSinkAdapter(QObject *parent=nullptr)
Frames that reached the appsink sink pad (counted via pad probe).
void setRefreshRate(qreal hz)
bool setup(GstElement *sinkBin, QVideoSink *videoSink)
quint64 gpuFallbackCount() const noexcept
quint64 appsinkInputFrames() const noexcept
quint64 appsinkDroppedFrames() const noexcept
GstElement * gst_qgc_video_sink_bin_get_appsink(GstQgcVideoSinkBin *self)
QByteArray format(const QList< LogEntry > &entries, int fmt)