Add native Windows cursor overlay to screen broadcasts

This commit is contained in:
evgeny committed 2026-10-16 23:50:52 +02:00
1 parent 49d55a25a1
commit 4a6f95cebd
6 files changed
+202 -17

No files matched your search

+6
View File
@@ -4,6 +4,12 @@
## Текущие задачи
[ ] **Устранить коллизию порта при перезапуске узла в test_dm_e2e** — 16.10.2026:
Общий check.sh при проверке Windows-курсора: 155/156, test_dm_e2e упал в phase=9 на создании B2.
Повторный bind 127.0.0.1:33843 вернул Address already in use; отдельный повтор теста проходит.
Проверить владение/резервирование порта между остановкой B и созданием B2 при параллельном прогоне.
Логи: /tmp/win-stream-cursor-dm-e2e-failure.log, /tmp/win-stream-cursor-dm-e2e-retry.log.
[+] **Постепенно увеличивать аудиобуфер при подтверждённых сбоях backend** — 16.10.2026:
AudioDevice получает WASAPI DATA_DISCONTINUITY, ALSA EPIPE и PulseAudio hole/overflow/underflow.
Два опроса со сбоями за 5с увеличивают период на 5мс; между изменениями минимум 5с,
+5
View File
@@ -218,6 +218,11 @@ if(GUI_CAPTURE_ENABLED AND UNIX AND NOT APPLE AND QT_FEATURE_xcb)
target_compile_definitions(chatgui_stream_publish PUBLIC UTUN_STREAM_X11_CURSOR)
target_link_libraries(chatgui_stream_publish PRIVATE X11::X11 X11::Xfixes)
endif()
if(GUI_CAPTURE_ENABLED AND WIN32)
target_sources(chatgui_stream_publish PRIVATE src/stream_screen_cursor_win.cpp)
target_compile_definitions(chatgui_stream_publish PUBLIC UTUN_STREAM_WIN_CURSOR)
target_link_libraries(chatgui_stream_publish PRIVATE user32 gdi32)
endif()
add_library(chatgui_notifications STATIC src/message_notification.cpp src/user_activity_monitor.cpp)
target_link_libraries(chatgui_notifications PUBLIC ${QT_PACKAGE}::Widgets PRIVATE utun)
+15 -11
View File
@@ -34,7 +34,7 @@ extern "C" {
#include <QWindowCapture>
#include <QVideoFrame>
#endif
#ifdef UTUN_STREAM_X11_CURSOR
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
#include "stream_screen_cursor.h"
#endif
#ifdef UTUN_STREAM_PORTAL
@@ -113,7 +113,7 @@ struct StreamPublishEngine::Impl {
std::unique_ptr<StreamPortalCapture> portal;
#endif
std::unique_ptr<QTimer> screenGrabTimer;
#ifdef UTUN_STREAM_X11_CURSOR
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
std::unique_ptr<StreamScreenCursor> cursor;
#endif
std::unique_ptr<QWindowCapture> window;
@@ -235,6 +235,8 @@ bool StreamPublishEngine::cursorSupported(const StreamVideoSource& source) {
#endif
#ifdef UTUN_STREAM_X11_CURSOR
return source.kind == StreamVideoSource::Screen && source.screen && source.id != "portal" && QGuiApplication::platformName() == "xcb";
#elif defined(UTUN_STREAM_WIN_CURSOR)
return source.kind == StreamVideoSource::Screen && source.screen && QGuiApplication::platformName() == "windows";
#else
Q_UNUSED(source); return false;
#endif
@@ -373,9 +375,10 @@ bool StreamPublishEngine::openSource(const StreamPublishConfig& config) {
m->portal->start(config.showCursor);
} else
#endif
// Qt X11 screen capture ищет root по имени и путает мониторы; QScreen захватывает именно выбранный экран.
if (selected && QGuiApplication::platformName() == "xcb") {
#ifdef UTUN_STREAM_X11_CURSOR
// QScreen даёт выбранный монитор без курсора; накладываем его один раз до preview и кодирования.
// Для X11 это также исключает путаницу Qt Multimedia с одноимёнными root.
if (selected && (QGuiApplication::platformName() == "xcb" || cursorSupported(config.source))) {
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
m->cursor = std::make_unique<StreamScreenCursor>(selected);
#endif
m->screenGrabTimer = std::make_unique<QTimer>(); m->screenGrabTimer->setTimerType(Qt::PreciseTimer);
@@ -384,11 +387,11 @@ bool StreamPublishEngine::openSource(const StreamPublishConfig& config) {
int64_t captured = monotonicUs();
QImage image = screen ? screen->grabWindow(0).toImage() : QImage();
if (image.isNull()) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop X11 screen grab failed present=%d", !screen.isNull());
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop native screen grab failed present=%d", !screen.isNull());
m->screenGrabTimer->stop();
QMetaObject::invokeMethod(this, [failed] { failed(text("Video capture stopped")); }); return;
}
#ifdef UTUN_STREAM_X11_CURSOR
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
if (this->config().showCursor && !m->cursor->draw(image)) {
m->screenGrabTimer->stop();
QMetaObject::invokeMethod(this, [failed] { failed(text("Could not capture the mouse cursor")); }); return;
@@ -405,15 +408,16 @@ bool StreamPublishEngine::openSource(const StreamPublishConfig& config) {
} else frame = {};
#endif
if (!frame.isValid()) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop X11 video frame allocation failed size=%dx%d", image.width(), image.height());
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop native video frame allocation failed size=%dx%d", image.width(), image.height());
m->screenGrabTimer->stop();
QMetaObject::invokeMethod(this, [failed] { failed(text("Video frame conversion failed")); }); return;
}
frame.setStartTime(captured); m->sink->setVideoFrame(frame);
});
m->screenGrabTimer->start(std::max(1, 1000 / std::clamp(config.video.fps, 1, 60)));
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop X11 screen grab started source=%s interval_ms=%d cursor=%d supported=%d",
qPrintable(config.source.name), m->screenGrabTimer->interval(), config.showCursor, cursorSupported(config.source));
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop native screen grab started platform=%s source=%s interval_ms=%d cursor=%d supported=%d",
qPrintable(QGuiApplication::platformName()), qPrintable(config.source.name), m->screenGrabTimer->interval(),
config.showCursor, cursorSupported(config.source));
} else {
m->screen = std::make_unique<QScreenCapture>();
m->screen->setScreen(selected);
@@ -454,7 +458,7 @@ void StreamPublishEngine::closeSource() {
#ifdef UTUN_STREAM_PORTAL
m->portal.reset();
#endif
#ifdef UTUN_STREAM_X11_CURSOR
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
m->cursor.reset();
#endif
emit previewVideoChanged(QVariant());
+6 -3
View File
@@ -1,12 +1,13 @@
/* Системный курсор X11 накладывается на физические пиксели QScreen::grabWindow до масштабирования.
* Только GUI-поток. Невидимое native-окно задаёт root и начало выбранного монитора, включая HiDPI. */
/* Системный курсор Windows/X11 накладывается на QScreen::grabWindow до масштабирования.
* Только GUI-поток. Положение, hotspot и bitmap используются в физических пикселях выбранного монитора. */
#pragma once
#include <QImage>
#include <QPointer>
#include <QScreen>
#ifndef Q_OS_WIN
#include <QWindow>
struct _XDisplay;
#endif
class StreamScreenCursor {
public:
explicit StreamScreenCursor(QScreen* screen);
@@ -14,7 +15,9 @@ public:
bool draw(QImage& frame);
private:
QPointer<QScreen> m_screen;
#ifndef Q_OS_WIN
QWindow m_origin;
struct _XDisplay* m_display = nullptr;
#endif
bool m_ready = false, m_reported = false;
};
@@ -0,0 +1,100 @@
#include "stream_screen_cursor.h"
#include "debug_config.h"
#include <QPainter>
#include <QScopeGuard>
#include <qt_windows.h>
#include <cstring>
StreamScreenCursor::StreamScreenCursor(QScreen* screen) : m_screen(screen) {
if (!screen) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: Windows screen unavailable"); return; }
m_ready = true;
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop cursor opened screen=%s scale=%.3f backend=Win32",
qPrintable(screen->name()), screen->devicePixelRatio());
}
StreamScreenCursor::~StreamScreenCursor() {
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "desktop cursor closed ready=%d drawn=%d backend=Win32", m_ready, m_reported);
}
/* DrawIconEx работает с фоном кадра: это сохраняет и alpha, и AND/XOR системных курсоров. */
bool StreamScreenCursor::draw(QImage& frame) {
if (!m_ready || !m_screen || frame.isNull()) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor unavailable ready=%d screen=%d frame=%dx%d",
m_ready, !m_screen.isNull(), frame.width(), frame.height()); return false;
}
CURSORINFO cursor{}; cursor.cbSize = sizeof(cursor);
if (!GetCursorInfo(&cursor)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: GetCursorInfo failed error=%lu", GetLastError()); return false;
}
if (!(cursor.flags & CURSOR_SHOWING)) return true;
DEVMODEW mode{}; mode.dmSize = sizeof(mode);
const auto device = m_screen->name().toStdWString();
if (!EnumDisplaySettingsExW(device.c_str(), ENUM_CURRENT_SETTINGS, &mode, 0)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: display mode unavailable screen=%s error=%lu",
qPrintable(m_screen->name()), GetLastError()); return false;
}
// Qt geometry имеет логический размер; WinAPI возвращает физическое начало, в том числе отрицательное.
QPoint position(cursor.ptScreenPos.x - mode.dmPosition.x, cursor.ptScreenPos.y - mode.dmPosition.y);
ICONINFO icon{};
if (!GetIconInfo(cursor.hCursor, &icon)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: GetIconInfo failed error=%lu", GetLastError()); return false;
}
auto releaseIcon = qScopeGuard([&] {
if (icon.hbmColor) DeleteObject(icon.hbmColor);
if (icon.hbmMask) DeleteObject(icon.hbmMask);
});
BITMAP bitmap{};
if (!GetObjectW(icon.hbmColor ? icon.hbmColor : icon.hbmMask, sizeof(bitmap), &bitmap)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: bitmap size unavailable error=%lu", GetLastError()); return false;
}
// У монохромного курсора обе маски расположены друг под другом в hbmMask.
QSize size(bitmap.bmWidth, icon.hbmColor ? bitmap.bmHeight : bitmap.bmHeight / 2);
if (size.isEmpty()) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: invalid bitmap size=%dx%d", size.width(), size.height()); return false;
}
QPoint topLeft = position - QPoint(icon.xHotspot, icon.yHotspot);
QRect area = QRect(topLeft, size).intersected(frame.rect());
if (area.isEmpty()) return true;
QImage background = frame.copy(area).convertToFormat(QImage::Format_RGB32);
if (background.isNull()) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: background allocation failed size=%dx%d", area.width(), area.height()); return false;
}
HDC dc = CreateCompatibleDC(nullptr);
if (!dc) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: CreateCompatibleDC failed error=%lu", GetLastError()); return false; }
auto releaseDc = qScopeGuard([&] { DeleteDC(dc); });
BITMAPINFO dib{}; dib.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
dib.bmiHeader.biWidth = area.width(); dib.bmiHeader.biHeight = -area.height();
dib.bmiHeader.biPlanes = 1; dib.bmiHeader.biBitCount = 32; dib.bmiHeader.biCompression = BI_RGB;
void* pixels = nullptr;
HBITMAP target = CreateDIBSection(dc, &dib, DIB_RGB_COLORS, &pixels, nullptr, 0);
if (!target) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: CreateDIBSection failed error=%lu", GetLastError()); return false; }
auto releaseTarget = qScopeGuard([&] { DeleteObject(target); });
HGDIOBJ previous = SelectObject(dc, target);
if (!previous || previous == HGDI_ERROR) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: SelectObject failed error=%lu", GetLastError()); return false;
}
auto restoreTarget = qScopeGuard([&] { SelectObject(dc, previous); });
const int stride = area.width() * 4;
for (int y = 0; y < area.height(); y++) std::memcpy(static_cast<uchar*>(pixels) + y * stride, background.constScanLine(y), stride);
if (!DrawIconEx(dc, topLeft.x() - area.x(), topLeft.y() - area.y(), cursor.hCursor, size.width(), size.height(), 0, nullptr, DI_NORMAL)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: DrawIconEx failed error=%lu", GetLastError()); return false;
}
if (!GdiFlush()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: GdiFlush failed"); return false; }
QImage composed(static_cast<uchar*>(pixels), area.width(), area.height(), stride, QImage::Format_RGB32);
// GDI может обнулить alpha; фон экрана остаётся непрозрачным.
for (int y = 0; y < composed.height(); y++) {
auto* row = reinterpret_cast<QRgb*>(composed.scanLine(y));
for (int x = 0; x < composed.width(); x++) row[x] |= 0xff000000u;
}
frame.setDevicePixelRatio(1);
QPainter painter(&frame);
if (!painter.isActive()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor painter initialization failed"); return false; }
painter.drawImage(area.topLeft(), composed);
if (!m_reported) {
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop cursor drawn screen=%s origin=%ld,%ld position=%d,%d hotspot=%lu,%lu sprite=%dx%d frame=%dx%d",
qPrintable(m_screen->name()), mode.dmPosition.x, mode.dmPosition.y, position.x(), position.y(), icon.xHotspot, icon.yHotspot,
size.width(), size.height(), frame.width(), frame.height());
m_reported = true;
}
return true;
}
+70 -3
View File
@@ -14,6 +14,7 @@
#include <QLoggingCategory>
#include <QScopeGuard>
#include <QPainter>
#include <QPixmap>
#include <atomic>
#include <thread>
#include <cmath>
@@ -53,6 +54,11 @@ extern "C" {
#undef None
#undef Status
#endif
#ifdef UTUN_STREAM_WIN_CURSOR
#include "../src/stream_screen_cursor.h"
#include <QWindow>
#include <qt_windows.h>
#endif
#ifdef TEST_NULL_AUDIO
static ma_device_data_proc captureCallback;
@@ -304,7 +310,7 @@ private slots:
}
void nativeScreenCapture() {
#ifdef HAVE_STREAM_CAPTURE
if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run with X11 screen capture enabled");
if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run with native screen capture enabled");
QFETCH(int, codec);
StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.codecs().isEmpty(), 15000);
if (!engine.codecs().contains(codec)) QSKIP("Requested encoder unavailable");
@@ -317,7 +323,9 @@ private slots:
uint64_t epoch = 0; unsigned captured = 0;
for (const auto& source : engine.sources()) {
if (source.kind != StreamVideoSource::Screen || source.size.isEmpty()) continue;
QSize expected = stream_video_screen_size(source.size);
// При дробном DPI size*dpr округляется; сверяемся с физическими пикселями снимка.
QSize capturedSize = source.screen->grabWindow(0).size(); QVERIFY(!capturedSize.isEmpty());
QSize expected = stream_video_screen_size(capturedSize);
config.source = source; config.source.size = {}; // Как portal: размер станет известен только из первого кадра.
if (!engine.active()) {
QVERIFY(engine.start(group, config));
@@ -332,7 +340,7 @@ private slots:
matched = frame.format.width == expected.width() && frame.format.height == expected.height();
QTest::qWait(1);
}
QVERIFY(matched); QCOMPARE(engine.config().source.id, source.id); QCOMPARE(engine.config().source.size, source.size);
QVERIFY(matched); QCOMPARE(engine.config().source.id, source.id); QCOMPARE(engine.config().source.size, capturedSize);
QCOMPARE(engine.config().video.width, expected.width()); QCOMPARE(engine.config().video.height, expected.height());
QVERIFY(errors.isEmpty()); captured++;
uint64_t previous = frame.pts; unsigned following = 0;
@@ -405,6 +413,65 @@ private slots:
}
#else
QSKIP("X11 cursor unavailable");
#endif
}
void windowsScreenCursorOverlay() {
#ifdef UTUN_STREAM_WIN_CURSOR
if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run on an isolated Windows desktop");
if (QGuiApplication::platformName() != "windows") QSKIP("Requires the Windows platform plugin");
POINT saved{}; QVERIFY(GetCursorPos(&saved));
auto restorePosition = qScopeGuard([&] { SetCursorPos(saved.x, saved.y); });
for (auto* screen : QGuiApplication::screens()) {
DEVMODEW mode{}; mode.dmSize = sizeof(mode);
QVERIFY(EnumDisplaySettingsExW(screen->name().toStdWString().c_str(), ENUM_CURRENT_SETTINGS, &mode, 0));
QWindow target(screen); target.setFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
target.setGeometry(QRect(screen->geometry().topLeft(), QSize(200, 160))); target.show();
QVERIFY(QTest::qWaitForWindowExposed(&target));
BYTE mask[32]; memset(mask, 0xff, sizeof(mask));
// AND=1, XOR=1 инвертирует фон; прозрачная картинка курсора этот случай не воспроизводит.
HCURSOR cursor = CreateCursor(nullptr, 3, 4, 16, 16, mask, mask); QVERIFY(cursor);
auto releaseCursor = qScopeGuard([&] { DestroyCursor(cursor); });
HCURSOR previous = GetCursor();
auto restoreCursor = qScopeGuard([&] { SetCursor(previous); });
StreamScreenCursor overlay(screen);
const QColor background(0x12, 0x34, 0x56), inverted(0xed, 0xcb, 0xa9);
for (QPoint position : {QPoint(60, 70), QPoint(0, 0)}) {
QVERIFY(SetCursorPos(mode.dmPosition.x + position.x(), mode.dmPosition.y + position.y()));
SetCursor(cursor);
CURSORINFO info{}; info.cbSize = sizeof(info); QVERIFY(GetCursorInfo(&info));
QVERIFY(info.flags & CURSOR_SHOWING); QVERIFY(info.hCursor == cursor);
QImage frame(mode.dmPelsWidth, mode.dmPelsHeight, QImage::Format_RGB32);
frame.setDevicePixelRatio(screen->devicePixelRatio()); frame.fill(background);
QImage blank = frame;
QVERIFY(overlay.draw(frame));
QImage expected(frame.size(), QImage::Format_RGB32); expected.fill(background);
{ QPainter painter(&expected); painter.fillRect(QRect(position - QPoint(3, 4), QSize(16, 16)), inverted); }
QCOMPARE(frame, expected); // Hotspot, физический размер HiDPI и обрезание у края монитора.
QCOMPARE(frame.devicePixelRatio(), qreal(1));
SetCursor(nullptr); frame = blank;
QVERIFY(overlay.draw(frame)); QCOMPARE(frame, blank);
}
QVERIFY(SetCursorPos(mode.dmPosition.x + 60, mode.dmPosition.y + 70)); SetCursor(cursor);
for (auto* other : QGuiApplication::screens()) {
if (other == screen) continue;
StreamScreenCursor otherOverlay(other);
QImage frame(other->size() * other->devicePixelRatio(), QImage::Format_RGB32); frame.fill(background);
QImage blank = frame; QVERIFY(otherOverlay.draw(frame)); QCOMPARE(frame, blank);
}
StreamPublishEngine engine;
for (const auto& source : engine.sources()) {
if (source.kind != StreamVideoSource::Screen || source.screen != screen) continue;
QVERIFY(StreamPublishEngine::cursorSupported(source));
StreamPublishWindow controls(&engine, group);
auto* sources = controls.findChild<QComboBox*>("streamVideoSource"); QVERIFY(sources);
auto all = engine.sources();
for (int i = 0; i < all.size(); i++) if (all[i].sameSource(source)) sources->setCurrentIndex(i);
auto* toggle = controls.findChild<QCheckBox*>("streamShowCursor"); QVERIFY(toggle);
QVERIFY(!toggle->isHidden()); QVERIFY(toggle->isChecked());
}
}
#else
QSKIP("Windows cursor unavailable");
#endif
}
void broadcastAllowsRadioMicrophone() {