diff --git a/doc/tasks.md b/doc/tasks.md index 018c271c..9624bfb1 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -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с, diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 2a33c3b3..0c1abdac 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -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) diff --git a/tools/chatgui/src/stream_publish_engine.cpp b/tools/chatgui/src/stream_publish_engine.cpp index 553770ba..0d6bdf2d 100644 --- a/tools/chatgui/src/stream_publish_engine.cpp +++ b/tools/chatgui/src/stream_publish_engine.cpp @@ -34,7 +34,7 @@ extern "C" { #include #include #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 portal; #endif std::unique_ptr screenGrabTimer; -#ifdef UTUN_STREAM_X11_CURSOR +#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR) std::unique_ptr cursor; #endif std::unique_ptr 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(selected); #endif m->screenGrabTimer = std::make_unique(); 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(); 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()); diff --git a/tools/chatgui/src/stream_screen_cursor.h b/tools/chatgui/src/stream_screen_cursor.h index b531e5f3..2031a953 100644 --- a/tools/chatgui/src/stream_screen_cursor.h +++ b/tools/chatgui/src/stream_screen_cursor.h @@ -1,12 +1,13 @@ -/* Системный курсор X11 накладывается на физические пиксели QScreen::grabWindow до масштабирования. - * Только GUI-поток. Невидимое native-окно задаёт root и начало выбранного монитора, включая HiDPI. */ +/* Системный курсор Windows/X11 накладывается на QScreen::grabWindow до масштабирования. + * Только GUI-поток. Положение, hotspot и bitmap используются в физических пикселях выбранного монитора. */ #pragma once #include #include #include +#ifndef Q_OS_WIN #include - struct _XDisplay; +#endif class StreamScreenCursor { public: explicit StreamScreenCursor(QScreen* screen); @@ -14,7 +15,9 @@ public: bool draw(QImage& frame); private: QPointer m_screen; +#ifndef Q_OS_WIN QWindow m_origin; struct _XDisplay* m_display = nullptr; +#endif bool m_ready = false, m_reported = false; }; diff --git a/tools/chatgui/src/stream_screen_cursor_win.cpp b/tools/chatgui/src/stream_screen_cursor_win.cpp new file mode 100644 index 00000000..155918bb --- /dev/null +++ b/tools/chatgui/src/stream_screen_cursor_win.cpp @@ -0,0 +1,100 @@ +#include "stream_screen_cursor.h" +#include "debug_config.h" +#include +#include +#include +#include + +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(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(pixels), area.width(), area.height(), stride, QImage::Format_RGB32); + // GDI может обнулить alpha; фон экрана остаётся непрозрачным. + for (int y = 0; y < composed.height(); y++) { + auto* row = reinterpret_cast(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; +} diff --git a/tools/chatgui/tests/test_stream_publish.cpp b/tools/chatgui/tests/test_stream_publish.cpp index 6fde7e46..e59d6546 100644 --- a/tools/chatgui/tests/test_stream_publish.cpp +++ b/tools/chatgui/tests/test_stream_publish.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +54,11 @@ extern "C" { #undef None #undef Status #endif +#ifdef UTUN_STREAM_WIN_CURSOR +#include "../src/stream_screen_cursor.h" +#include +#include +#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("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("streamShowCursor"); QVERIFY(toggle); + QVERIFY(!toggle->isHidden()); QVERIFY(toggle->isChecked()); + } + } +#else + QSKIP("Windows cursor unavailable"); #endif } void broadcastAllowsRadioMicrophone() {