From eed07cb358b1d7ee09f91f29a7e29e0f758db5e8 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 1 Nov 2026 20:06:56 +0100 Subject: [PATCH] Feed publisher CPU frames and decouple FIN from capture teardown --- tools/chatgui/src/stream_publish_engine.cpp | 501 +++--------------- tools/chatgui/src/stream_publish_engine.h | 25 +- tools/chatgui/src/stream_publish_window.cpp | 13 +- tools/chatgui/src/streamgrid_window.cpp | 14 +- ...era_capture.cpp => test_video_capture.cpp} | 0 5 files changed, 106 insertions(+), 447 deletions(-) rename tools/chatgui/tests/{test_call_camera_capture.cpp => test_video_capture.cpp} (100%) diff --git a/tools/chatgui/src/stream_publish_engine.cpp b/tools/chatgui/src/stream_publish_engine.cpp index fcffc730d..bce6f3dd6 100644 --- a/tools/chatgui/src/stream_publish_engine.cpp +++ b/tools/chatgui/src/stream_publish_engine.cpp @@ -2,6 +2,7 @@ #include "sound_manager.h" #include "audio_event_queue.h" #include "stream_qp_control.h" +#include "video_capture.h" #include "../transport/stream_bridge.h" #include "debug_config.h" #include @@ -25,26 +26,11 @@ extern "C" { #include "opus_codec.h" #include "audio_compressor.h" +#include "../../../src/call/call_video_playout.h" } -#ifdef HAVE_STREAM_CAPTURE -#include -#include -#include -#include -#include -#include -#include -#endif -#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR) -#include "stream_screen_cursor.h" -#endif -#ifdef UTUN_STREAM_PORTAL -#include "stream_portal_capture.h" -#endif namespace { -int64_t monotonicUs() { return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count(); } +int64_t monotonicUs() { return call_video_playout_now_us(); } QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } #ifdef HAVE_STREAM_CAPTURE /* Имя QScreen не уникально (например, несколько X11 screens); номер различает одинаковые дескрипторы. */ @@ -58,24 +44,7 @@ QByteArray screenId(QScreen* screen) { return QJsonDocument(QJsonArray{screen->name(), screen->manufacturer(), screen->model(), screen->serialNumber(), duplicate}) .toJson(QJsonDocument::Compact); } -#ifdef UTUN_STREAM_WIN_CURSOR -QVideoFrame previewFrameFromImage(const QImage& source) { -#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) - return QVideoFrame(source); -#else - QImage image = source.convertToFormat(QImage::Format_RGBA8888); - QVideoFrame frame(QVideoFrameFormat(image.size(), QVideoFrameFormat::Format_RGBA8888)); - if (!frame.map(QVideoFrame::WriteOnly)) { - DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "desktop preview frame map failed size=%dx%d", image.width(), image.height()); - return {}; - } - for (int y = 0; y < image.height(); y++) - std::memcpy(frame.bits(0) + y * frame.bytesPerLine(0), image.constScanLine(y), image.width() * 4); - frame.unmap(); - return frame; -#endif -} -#endif + #endif } struct StreamPublishEngine::Impl { @@ -83,8 +52,7 @@ struct StreamPublishEngine::Impl { std::mutex lock; std::condition_variable wake; std::thread videoThread, audioThread, joinThread; - std::unique_ptr imageThread; - std::atomic workersJoined{false}, videoExited{false}, audioExited{false}, imageJoined{false}; + std::atomic workersJoined{false}, videoExited{false}, audioExited{false}; bool stopping = false, finSent = false, destroying = false; int64_t stopStarted = 0, stopReport = 0; std::atomic running{false}, muted{false}; @@ -102,9 +70,6 @@ struct StreamPublishEngine::Impl { uint64_t captureReportRaw = 0, captureReportConverted = 0, captureReportEncoded = 0, captureReportDelivered = 0; std::atomic videoStage{"stopped"}; std::atomic videoStageAt{0}; - std::atomic imageStage{"starting"}; - std::atomic imageStageAt{monotonicUs()}; - std::atomic imageFrames{0}; std::atomic convertedFrames{0}, encodedFrames{0}, deliveredFrames{0}, repeatedFrames{0}; std::atomic encodedBytes{0}, lastKeyBytes{0}; // Видео до сетевой отправки, без повторов транспорта. std::atomic videoMaxQp{-1}; @@ -113,9 +78,6 @@ struct StreamPublishEngine::Impl { void videoProgress(const char* stage) { videoStageAt.store(monotonicUs()); videoStage.store(stage); } - void imageProgress(const char* stage) { - imageStageAt.store(monotonicUs()); imageStage.store(stage); - } bool hasGood = false, rollingBack = false, videoReady = false; uint64_t softwareRequest = 0; int softwareCodec = 0; @@ -124,28 +86,9 @@ struct StreamPublishEngine::Impl { QList codecs; stream_publisher_status stats{}; #ifdef HAVE_STREAM_CAPTURE - // Очередь преобразования принадлежит capture backend; без Qt Multimedia нет ни QVideoFrame, ни её worker. - std::condition_variable imageWake; - QVideoFrame imageInput; - uint64_t imageGeneration = 0; - int64_t imagePts = -1; - bool imagePending = false, imageStop = false, imageDrawCursor = false; - std::unique_ptr session; - std::unique_ptr sink; - std::unique_ptr camera; - std::unique_ptr screen; -#ifdef UTUN_STREAM_PORTAL - std::unique_ptr portal; -#endif - std::unique_ptr screenGrabTimer; -#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR) - std::shared_ptr cursor; -#endif - std::unique_ptr window; - QImage frame; - QImage latestImage; - QVideoFrame previewFrame; - QVideoFrame presentedPreview; + std::unique_ptr capture; + QList sources; + QImage frame, latestImage, previewFrame, presentedPreview; int64_t lastImageCaptureAt = 0; int64_t previewTickAt = 0, previewReportAt = 0, previewGapMax = 0, previewDispatchMax = 0; unsigned previewUpdates = 0; // Диагностика GUI-предпросмотра; только GUI-поток. @@ -156,15 +99,39 @@ StreamPublishEngine::StreamPublishEngine(QObject* parent) : QObject(parent), m(s m_timer.setTimerType(Qt::PreciseTimer); m_timer.setInterval(100); connect(&m_timer, &QTimer::timeout, this, &StreamPublishEngine::tick); m_timer.start(); #ifdef HAVE_STREAM_CAPTURE - auto* devices = new QMediaDevices(this); - connect(devices, &QMediaDevices::videoInputsChanged, this, &StreamPublishEngine::sourcesChanged); - auto watchScreen = [this](QScreen* screen) { - connect(screen, &QScreen::geometryChanged, this, &StreamPublishEngine::sourcesChanged); - connect(screen, &QScreen::physicalDotsPerInchChanged, this, &StreamPublishEngine::sourcesChanged); - }; - for (auto* screen : QGuiApplication::screens()) watchScreen(screen); - connect(qGuiApp, &QGuiApplication::screenAdded, this, [this, watchScreen](QScreen* screen) { watchScreen(screen); emit sourcesChanged(); }); - connect(qGuiApp, &QGuiApplication::screenRemoved, this, &StreamPublishEngine::sourcesChanged); + m->capture = std::make_unique(nullptr, VideoCapture::Usage::Stream); + connect(m->capture.get(), &VideoCapture::devicesChanged, this, [this](const VideoCaptureDevices& devices) { + m->sources.clear(); + for (const auto& device : devices) { + StreamVideoSource source; + source.kind = static_cast(device.kind); + source.id = device.id; source.name = device.id == "portal" ? text("Screen (system selection)") : device.name; + source.size = device.size; source.modes = device.modes; + if (source.kind == StreamVideoSource::Screen) + for (auto* screen : QGuiApplication::screens()) if (screenId(screen) == source.id) source.screen = screen; + m->sources.append(source); + } + DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "desktop capture source snapshot count=%lld", (long long)m->sources.size()); + emit sourcesChanged(); + }); + connect(m->capture.get(), &VideoCapture::failed, this, [this](const QString& error) { + if (m->running) sourceFailed(m->generation, error, config().source.kind == StreamVideoSource::Camera); + }); + connect(m->capture.get(), &VideoCapture::frameReady, this, [this](const QImage& image, qint64 pts, qint64) { + if (!m->running) return; + const int64_t now = monotonicUs(); + { + std::lock_guard guard(m->lock); + if (m->framePts < 0 && m->wanted.source.id == "portal") m->started = now; + m->frame = m->latestImage = m->previewFrame = image; + m->framePts = std::max(1, pts - m->origin); m->frameGeneration = m->generation; + ++m->capturedFrames; + if (!m->convertedCaptureFrames++) DEBUG_INFO(DEBUG_CATEGORY_VIDEO, + "desktop first CPU capture frame id=%llu source_generation=%llu size=%dx%d age_us=%lld", + (unsigned long long)m->id, (unsigned long long)m->sourceGeneration, image.width(), image.height(), (long long)(now - pts)); + } + m->wake.notify_all(); + }); m_previewTimer.setTimerType(Qt::PreciseTimer); m_previewTimer.setInterval(33); connect(&m_previewTimer, &QTimer::timeout, this, [this] { if (!m->running) { @@ -175,10 +142,10 @@ StreamPublishEngine::StreamPublishEngine(QObject* parent) : QObject(parent), m(s if (m->previewTickAt) m->previewGapMax = std::max(m->previewGapMax, tickAt - m->previewTickAt); m->previewTickAt = tickAt; if (!m->previewReportAt) m->previewReportAt = tickAt; - QVideoFrame frame; + QImage frame; { std::lock_guard guard(m->lock); - if (m->previewFrame != m->presentedPreview) frame = m->presentedPreview = m->previewFrame; } - if (frame.isValid()) { + if (m->previewFrame.cacheKey() != m->presentedPreview.cacheKey()) frame = m->presentedPreview = m->previewFrame; } + if (!frame.isNull()) { int64_t dispatchAt = monotonicUs(); emit previewVideoChanged(QVariant::fromValue(frame)); m->previewDispatchMax = std::max(m->previewDispatchMax, monotonicUs() - dispatchAt); m->previewUpdates++; @@ -199,57 +166,23 @@ StreamPublishEngine::StreamPublishEngine(QObject* parent) : QObject(parent), m(s if (m->running && m->wanted.audio) publicationFailed(m->id, text("The audio device was disconnected. Select a microphone and start again.")); m_capture.close(); // Контекст нельзя освободить до завершения фонового закрытия устройства. }); -#ifdef HAVE_STREAM_CAPTURE - m->imageThread.reset(QThread::create([this] { - DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "desktop image worker started tid=%llu", (unsigned long long)(quintptr)QThread::currentThreadId()); - imageLoop(); - m->imageProgress("release-thread-resources"); - DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "desktop image loop returned; releasing thread resources frames=%llu tid=%llu", - (unsigned long long)m->imageFrames.load(), (unsigned long long)(quintptr)QThread::currentThreadId()); - })); - m->imageThread->start(); -#else - m->imageJoined = true; - DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop stream capture unavailable in this build; image worker disabled"); -#endif + } StreamPublishEngine::~StreamPublishEngine() { const int64_t destroyAt = monotonicUs(); - DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "desktop publisher destruction begin id=%llu running=%d stopping=%d image_stage=%s image_frames=%llu", - (unsigned long long)m->id, m->running.load(), m->stopping, m->imageStage.load(), (unsigned long long)m->imageFrames.load()); m->destroying = true; blockSignals(true); - stop(); m_timer.stop(); m_previewTimer.stop(); - closeSource(); + stop(); m_timer.stop(); m_previewTimer.stop(); closeSource(); #ifdef HAVE_STREAM_CAPTURE - { std::lock_guard guard(m->lock); m->imageStop = true; m->imagePending = false; m->imageInput = {}; } - m->imageWake.notify_all(); + // Supervisor переживает GUI-владельца и завершает capture без участия engine/ядра. + m->capture.reset(); #endif - if (!finishStop() || !finishImageStop()) { - // Qt освобождает thread-local графику через GUI. Даже при выходе приложения нельзя блокировать его join(). - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop publisher destruction waits for workers stopping=%d image_joined=%d image_stage=%s", - m->stopping, m->imageJoined.load(), m->imageStage.load()); + if (!finishStop()) { QEventLoop loop; QTimer timer; - int64_t reportAt = destroyAt; - connect(&timer, &QTimer::timeout, &loop, [this, &loop, destroyAt, &reportAt] { - const bool imageDone = finishImageStop(); - if (finishStop() && imageDone) { loop.quit(); return; } - const int64_t now = monotonicUs(); - if (now - reportAt < 5000000) return; - reportAt = now; - DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop publisher destruction waiting elapsed_ms=%lld stopping=%d workers_joined=%d " - "video_stage=%s image_joined=%d image_stage=%s image_stage_age_ms=%lld image_frames=%llu microphone_closing=%d", - (long long)((now - destroyAt) / 1000), m->stopping, m->workersJoined.load(), m->videoStage.load(), - m->imageJoined.load(), m->imageStage.load(), (long long)((now - m->imageStageAt.load()) / 1000), - (unsigned long long)m->imageFrames.load(), m_capture.closing()); - }); - timer.start(10); - const int result = loop.exec(QEventLoop::ExcludeUserInputEvents); - DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "desktop publisher destruction GUI loop exited result=%d stopping=%d image_joined=%d", - result, m->stopping, m->imageJoined.load()); - if (m->stopping || !m->imageJoined) - DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop publisher destruction GUI loop exited before worker cleanup completed"); + connect(&timer, &QTimer::timeout, &loop, [this, &loop] { if (finishStop()) loop.quit(); }); + timer.start(10); loop.exec(QEventLoop::ExcludeUserInputEvents); } - DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "desktop publisher destruction complete elapsed_ms=%lld", (long long)((monotonicUs() - destroyAt) / 1000)); + DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "desktop publisher destruction complete elapsed_ms=%lld", + (long long)((monotonicUs() - destroyAt) / 1000)); } bool StreamPublishEngine::supported() { #ifdef HAVE_STREAM_CAPTURE @@ -266,7 +199,7 @@ uint64_t StreamPublishEngine::videoBitrate() const { return active() ? m->videoB int StreamPublishEngine::videoMaxQp() const { return active() ? m->videoMaxQp.load() : -1; } QVariant StreamPublishEngine::previewFrame() const { #ifdef HAVE_STREAM_CAPTURE - std::lock_guard guard(m->lock); return QVariant::fromValue(m->previewFrame); + std::lock_guard guard(m->lock); return m->previewFrame.isNull() ? QVariant() : QVariant::fromValue(m->previewFrame); #else return {}; #endif @@ -277,9 +210,9 @@ bool StreamPublishEngine::cursorSupported(const StreamVideoSource& source) { if (source.kind == StreamVideoSource::Screen && source.id == "portal") return true; #endif #ifdef UTUN_STREAM_X11_CURSOR - return source.kind == StreamVideoSource::Screen && source.screen && source.id != "portal" && QGuiApplication::platformName() == "xcb"; + return source.kind == StreamVideoSource::Screen && source.id != "portal" && QGuiApplication::platformName() == "xcb"; #elif defined(UTUN_STREAM_WIN_CURSOR) - return source.kind == StreamVideoSource::Screen && source.screen && QGuiApplication::platformName() == "windows"; + return source.kind == StreamVideoSource::Screen && QGuiApplication::platformName() == "windows"; #else Q_UNUSED(source); return false; #endif @@ -297,303 +230,41 @@ void StreamPublishEngine::setMuted(bool muted) { emit configurationChanged(); } -QList StreamPublishEngine::sources() { - QList sources; +QList StreamPublishEngine::sources() const { #ifdef HAVE_STREAM_CAPTURE - for (const auto& camera : QMediaDevices::videoInputs()) { - StreamVideoSource source{StreamVideoSource::Camera, camera.description(), camera.id(), {}}; - for (const auto& format : camera.videoFormats()) { - stream_video_add_camera_modes(source.modes, format.resolution(), format.minFrameRate(), format.maxFrameRate()); - DEBUG_TRACE(DEBUG_CATEGORY_VIDEO, "desktop camera capability device=%s size=%dx%d pixel=%d fps=%.3f..%.3f", - camera.id().constData(), format.resolution().width(), format.resolution().height(), format.pixelFormat(), - format.minFrameRate(), format.maxFrameRate()); - } - if (source.modes.isEmpty()) DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "desktop camera has no usable modes device=%s", camera.id().constData()); - sources.append(source); - } - if (QGuiApplication::platformName().contains("wayland") || qEnvironmentVariable("XDG_SESSION_TYPE") == "wayland" - || qEnvironmentVariableIsSet("WAYLAND_DISPLAY")) { - sources.push_back({StreamVideoSource::Screen, text("Screen (system selection)"), "portal", {}}); - } else { - int number = 0; - for (auto* screen : QGuiApplication::screens()) { - QSize size = screen->size() * screen->devicePixelRatio(); - sources.push_back({StreamVideoSource::Screen, QStringLiteral("%1: %2").arg(++number).arg(screen->name()), - screenId(screen), {}, size, {}, screen}); - DEBUG_TRACE(DEBUG_CATEGORY_VIDEO, "desktop screen capability source=%s logical=%dx%d scale=%.3f pixels=%dx%d", - qPrintable(screen->name()), screen->size().width(), screen->size().height(), screen->devicePixelRatio(), size.width(), size.height()); - } - } + return m->sources; +#else + return {}; #endif - return sources; } -/* Source создаётся в GUI; callbacks старого поколения не меняют новый capture. */ +/* Команда не обращается к Qt capture: создание/замена backend происходит в отдельном процессе. */ bool StreamPublishEngine::openSource(const StreamPublishConfig& config) { - closeSource(); #ifdef HAVE_STREAM_CAPTURE - uint64_t generation; - { std::lock_guard guard(m->lock); generation = ++m->sourceGeneration; - m->backendOrigin = m->backendAnchor = -1; m->capturedFrames = m->convertedCaptureFrames = 0; m->lastImageCaptureAt = 0; } - m->session = std::make_unique(); m->sink = std::make_unique(this); - m->session->setVideoSink(m->sink.get()); - connect(m->sink.get(), &QVideoSink::videoFrameChanged, this, [this, generation](const QVideoFrame& frame) { - if (!frame.isValid()) { DEBUG_TRACE(DEBUG_CATEGORY_VIDEO, "desktop capture sink invalid frame generation=%llu", (unsigned long long)generation); return; } - if (!m->running) return; - int64_t callbackAt = monotonicUs(), capture = frame.startTime(), framePts = 0; - bool convert = false; - { - std::lock_guard guard(m->lock); - if (generation != m->sourceGeneration) return; - if (!m->capturedFrames++) DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop capture first raw frame kind=%d size=%dx%d pixel=%d PTS=%lld", - m->wanted.source.kind, frame.width(), frame.height(), frame.pixelFormat(), (long long)capture); - if (capture >= 0) { - if (m->backendOrigin < 0) { - m->backendOrigin = capture; m->backendAnchor = callbackAt; - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop capture timing generation=%llu source_PTS=%lld anchor=%lld calibration=arrival-estimate", - (unsigned long long)generation, (long long)capture, (long long)callbackAt); - } - callbackAt = m->backendAnchor + capture - m->backendOrigin; - } else if (m->backendAnchor < 0) { - m->backendAnchor = callbackAt; - DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "desktop capture has no source PTS; using monotonic callback estimate"); - } - int fps = std::clamp(m->wanted.video.fps, 1, 60); - convert = !m->lastImageCaptureAt || callbackAt - m->lastImageCaptureAt >= 1000000 / fps; -#ifndef UTUN_STREAM_WIN_CURSOR - m->previewFrame = frame; -#endif - if (!convert) { m->wake.notify_all(); return; } - m->lastImageCaptureAt = callbackAt; - if (m->framePts < 0 && m->wanted.source.id == "portal") m->started = monotonicUs(); - framePts = std::max(1, callbackAt - m->origin); - } - { - std::lock_guard guard(m->lock); - if (!m->running || generation != m->sourceGeneration) return; - m->imageInput = frame; m->imageGeneration = generation; m->imagePts = framePts; - m->imageDrawCursor = m->wanted.showCursor && cursorSupported(m->wanted.source); m->imagePending = true; - } - m->imageWake.notify_one(); - }, Qt::DirectConnection); - auto failed = [this, generation, camera = config.source.kind == StreamVideoSource::Camera](const QString& error) { - if (generation == m->sourceGeneration) sourceFailed(m->generation, error, camera); - }; - if (config.source.kind == StreamVideoSource::Camera) { - QCameraDevice selected; - for (const auto& camera : QMediaDevices::videoInputs()) if (camera.id() == config.source.id) selected = camera; - if (selected.isNull()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop camera disappeared"); return false; } - m->camera = std::make_unique(selected); - QCameraFormat best; - for (const auto& format : selected.videoFormats()) { - if (!stream_video_camera_supports(format.resolution(), format.minFrameRate(), format.maxFrameRate(), - {QSize(config.video.width, config.video.height), config.video.fps})) continue; - if (best.isNull() || format.maxFrameRate() < best.maxFrameRate()) best = format; - } - if (best.isNull()) { - DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop camera mode unavailable device=%s size=%dx%d fps=%d", - config.source.id.constData(), config.video.width, config.video.height, config.video.fps); return false; - } - m->camera->setCameraFormat(best); m->session->setCamera(m->camera.get()); - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop camera selected device=%s size=%dx%d pixel=%d capture_fps=%.3f..%.3f output_fps=%d", - config.source.id.constData(), best.resolution().width(), best.resolution().height(), best.pixelFormat(), - best.minFrameRate(), best.maxFrameRate(), config.video.fps); - connect(m->camera.get(), &QCamera::activeChanged, this, [this, generation](bool active) { - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop camera active=%d generation=%llu", active, (unsigned long long)generation); - if (!active && generation == m->sourceGeneration) sourceFailed(m->generation, text("Video capture stopped"), true); - }, Qt::QueuedConnection); - connect(m->camera.get(), &QCamera::errorOccurred, this, [failed](QCamera::Error, const QString& error) { failed(error); }, Qt::QueuedConnection); - m->camera->start(); - } else if (config.source.kind == StreamVideoSource::Screen) { - QScreen* selected = nullptr; - if (config.source.id != "portal") { - for (auto* screen : QGuiApplication::screens()) - if (screen == config.source.screen && screenId(screen) == config.source.id) { selected = screen; break; } - if (!selected) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop screen disappeared"); return false; } - } -#ifdef UTUN_STREAM_PORTAL - if (config.source.id == "portal") { - m->portal = std::make_unique(); - connect(m->portal.get(), &StreamPortalCapture::frameReady, m->sink.get(), &QVideoSink::setVideoFrame); - connect(m->portal.get(), &StreamPortalCapture::errorOccurred, this, failed, Qt::QueuedConnection); - m->portal->start(config.showCursor); - } else -#endif - // QScreen даёт выбранный монитор без курсора; накладываем его один раз до preview и кодирования. - // Для X11 это также исключает путаницу Qt Multimedia с одноимёнными root. - if (selected && QGuiApplication::platformName() == "xcb") { -#if defined(UTUN_STREAM_X11_CURSOR) - { std::lock_guard guard(m->lock); m->cursor = std::make_shared(selected); } -#endif - m->screenGrabTimer = std::make_unique(); m->screenGrabTimer->setTimerType(Qt::PreciseTimer); - connect(m->screenGrabTimer.get(), &QTimer::timeout, this, [this, generation, screen = QPointer(selected), failed] { - if (generation != m->sourceGeneration || !m->running) return; - int64_t captured = monotonicUs(); - QImage image = screen ? screen->grabWindow(0).toImage() : QImage(); - if (image.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; - } -#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; - } -#endif -#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) - QVideoFrame frame(image); -#else - image = image.convertToFormat(QImage::Format_RGBA8888); - QVideoFrame frame(QVideoFrameFormat(image.size(), QVideoFrameFormat::Format_RGBA8888)); - if (frame.map(QVideoFrame::WriteOnly)) { - for (int y = 0; y < image.height(); y++) std::memcpy(frame.bits(0) + y * frame.bytesPerLine(0), image.constScanLine(y), image.width() * 4); - frame.unmap(); - } else frame = {}; -#endif - if (!frame.isValid()) { - 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 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 { -#ifdef UTUN_STREAM_WIN_CURSOR - if (selected) { std::lock_guard guard(m->lock); m->cursor = std::make_shared(selected); } -#endif - m->screen = std::make_unique(); - m->screen->setScreen(selected); - m->session->setScreenCapture(m->screen.get()); - connect(m->screen.get(), &QScreenCapture::activeChanged, this, [this, generation](bool active) { - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop screen capture active=%d generation=%llu", active, (unsigned long long)generation); - if (!active && generation == m->sourceGeneration) sourceFailed(m->generation, text("Video capture stopped")); - }, Qt::QueuedConnection); - connect(m->screen.get(), &QScreenCapture::errorOccurred, this, [failed](QScreenCapture::Error, const QString& error) { failed(error); }, Qt::QueuedConnection); - m->screen->start(); -#ifdef UTUN_STREAM_WIN_CURSOR - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop Windows screen capture backend=QtMultimedia cursor_overlay=frame_worker source=%s", - qPrintable(config.source.name)); -#endif - } - } else { - auto window = config.source.window.value(); - if (!window.isValid()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop window is unavailable"); return false; } - m->window = std::make_unique(); m->window->setWindow(window); m->session->setWindowCapture(m->window.get()); - connect(m->window.get(), &QWindowCapture::activeChanged, this, [this, generation](bool active) { - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop window capture active=%d generation=%llu", active, (unsigned long long)generation); - if (!active && generation == m->sourceGeneration) sourceFailed(m->generation, text("Video capture stopped")); - }, Qt::QueuedConnection); - connect(m->window.get(), &QWindowCapture::errorOccurred, this, [failed](QWindowCapture::Error, const QString& error) { failed(error); }, Qt::QueuedConnection); - m->window->start(); + { + std::lock_guard guard(m->lock); + ++m->sourceGeneration; m->frame = {}; m->latestImage = {}; m->previewFrame = {}; m->presentedPreview = {}; + m->framePts = -1; m->capturedFrames = m->convertedCaptureFrames = 0; } - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop capture started generation=%llu kind=%d source=%s target=%dx%d/%dfps", - (unsigned long long)generation, config.source.kind, qPrintable(config.source.name), config.video.width, config.video.height, config.video.fps); + m->capture->configure({true, config.source.id, config.video.width, config.video.height, config.video.fps, + static_cast(config.source.kind), config.showCursor && cursorSupported(config.source)}); + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop capture requested id=%llu source_generation=%llu kind=%d source=%s", + (unsigned long long)m->id, (unsigned long long)m->sourceGeneration, config.source.kind, qPrintable(config.source.name)); return true; #else Q_UNUSED(config); return false; #endif } + +/* Только CPU-данные GUI. Аппаратное закрытие имеет независимое владение и deadline у supervisor. */ void StreamPublishEngine::closeSource() { #ifdef HAVE_STREAM_CAPTURE { std::lock_guard guard(m->lock); - ++m->sourceGeneration; m->imagePending = false; m->imageInput = {}; - m->frame = {}; m->latestImage = {}; m->previewFrame = {}; m->presentedPreview = {}; m->framePts = -1; -#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR) - m->cursor.reset(); -#endif + ++m->sourceGeneration; m->frame = {}; m->latestImage = {}; m->previewFrame = {}; m->presentedPreview = {}; m->framePts = -1; } - m->screenGrabTimer.reset(); -#ifdef UTUN_STREAM_PORTAL - m->portal.reset(); -#endif + if (m->capture) m->capture->stop(); emit previewVideoChanged(QVariant()); - if (m->sink) disconnect(m->sink.get(), nullptr, this, nullptr); - if (m->camera) { disconnect(m->camera.get(), nullptr, this, nullptr); m->camera->stop(); } - if (m->screen) { disconnect(m->screen.get(), nullptr, this, nullptr); m->screen->stop(); } - if (m->window) { disconnect(m->window.get(), nullptr, this, nullptr); m->window->stop(); } - m->session.reset(); m->camera.reset(); m->screen.reset(); m->window.reset(); m->sink.reset(); -#endif -} - -/* Долгоживущий worker выполняет Qt Multimedia преобразование; video worker его TLS не создаёт. */ -void StreamPublishEngine::imageLoop() { -#ifdef HAVE_STREAM_CAPTURE - for (;;) { - QVideoFrame frame; uint64_t generation; int64_t pts; bool drawCursor; -#ifdef UTUN_STREAM_WIN_CURSOR - std::shared_ptr cursor; -#endif - m->imageProgress("wait-frame"); - { - std::unique_lock lock(m->lock); - m->imageWake.wait(lock, [this] { return m->imageStop || m->imagePending; }); - if (m->imageStop) return; - frame = std::move(m->imageInput); generation = m->imageGeneration; pts = m->imagePts; - drawCursor = m->imageDrawCursor; m->imagePending = false; - if (!m->running || generation != m->sourceGeneration) continue; -#ifdef UTUN_STREAM_WIN_CURSOR - cursor = m->cursor; -#endif - } - int64_t convertAt = monotonicUs(); - m->imageProgress("convert-frame"); - QImage image = frame.toImage(); - // Отметка ставится перед деструкторами image/frame, включая return/continue внутри итерации. - auto releaseFrame = qScopeGuard([this] { m->imageProgress("release-frame"); }); - m->imageFrames++; - m->imageProgress("transform-image"); - if (image.isNull()) { - DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop captured frame conversion failed generation=%llu format=%d size=%dx%d", - (unsigned long long)generation, frame.pixelFormat(), frame.width(), frame.height()); - QMetaObject::invokeMethod(this, [this, generation] { - if (generation == m->sourceGeneration) sourceFailed(m->generation, text("Video frame conversion failed")); - }); - continue; - } -#ifdef UTUN_STREAM_WIN_CURSOR - if (drawCursor && (!cursor || !cursor->draw(image))) { - if (!cursor) DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor missing for captured frame generation=%llu", (unsigned long long)generation); - QMetaObject::invokeMethod(this, [this, generation] { - if (generation == m->sourceGeneration) sourceFailed(m->generation, text("Could not capture the mouse cursor")); - }); - continue; - } -#endif -#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) - if (frame.rotation() != QtVideo::Rotation::None) image = image.transformed(QTransform().rotate(static_cast(frame.rotation()))); - if (frame.mirrored()) image = image.transformed(QTransform().scale(-1, 1)); -#else - if (frame.rotationAngle()) image = image.transformed(QTransform().rotate(frame.rotationAngle())); -#endif -#ifdef UTUN_STREAM_WIN_CURSOR - QVideoFrame preview = previewFrameFromImage(image); - if (!preview.isValid()) DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "desktop preview frame unavailable generation=%llu", - (unsigned long long)generation); -#endif - m->imageProgress("publish-image"); - { - std::lock_guard guard(m->lock); - if (m->imageStop) return; - if (!m->running || generation != m->sourceGeneration) continue; -#ifdef UTUN_STREAM_WIN_CURSOR - if (preview.isValid()) m->previewFrame = std::move(preview); -#endif - if (m->framePts < 0 && m->wanted.source.id == "portal") m->started = monotonicUs(); - m->latestImage = image; m->frame = std::move(image); m->framePts = pts; m->frameGeneration = m->generation; - if (!m->convertedCaptureFrames++) DEBUG_INFO(DEBUG_CATEGORY_VIDEO, - "desktop first image conversion complete generation=%llu size=%dx%d callback_tid=%llu gui_thread=%d convert_us=%lld", - (unsigned long long)generation, m->latestImage.width(), m->latestImage.height(), - (unsigned long long)(quintptr)QThread::currentThreadId(), QThread::currentThread() == qApp->thread(), - (long long)(monotonicUs() - convertAt)); - } - m->wake.notify_all(); - } #endif } @@ -694,9 +365,6 @@ bool StreamPublishEngine::reconfigure(const StreamPublishConfig& config) { sourceFailed(m->generation, text("Could not open the selected video source"), config.source.kind == StreamVideoSource::Camera); return false; } -#ifdef HAVE_STREAM_CAPTURE - if (m->screenGrabTimer) m->screenGrabTimer->setInterval(std::max(1, 1000 / std::clamp(config.video.fps, 1, 60))); -#endif m->wake.notify_all(); emit stateChanged(text("Applying video settings…")); return true; } @@ -732,8 +400,11 @@ void StreamPublishEngine::stop() { { std::lock_guard guard(m->lock); m->softwareRequest = 0; } m->wake.notify_all(); DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop stop closing video source"); + gui_stream_publish_stop(m->id); m->finSent = true; + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop publisher admission closed id=%llu; accepted data drains independently of capture", + (unsigned long long)m->id); closeSource(); - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop stop video source closed elapsed_ms=%lld", (long long)((monotonicUs() - started) / 1000)); + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop stop video source close requested elapsed_ms=%lld", (long long)((monotonicUs() - started) / 1000)); m_capture.closeAsync(); DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop stop waiting asynchronously for workers stage=%s", m->videoStage.load()); emit stateChanged(text("Finishing stream…")); @@ -758,25 +429,13 @@ bool StreamPublishEngine::finishStop() { (unsigned long long)m->id, (long long)((monotonicUs() - m->stopStarted) / 1000), m_capture.closing()); } if (m_capture.closing()) return false; +#ifdef HAVE_STREAM_CAPTURE + if (m->capture && m->capture->closing()) return false; +#endif m_microphone.release(); m->stopping = false; return true; } -/* Проверка QThread с нулевым таймаутом сохраняет GUI event loop доступным для native cleanup. */ -bool StreamPublishEngine::finishImageStop() { - if (m->imageJoined) return true; - if (!m->imageThread) { - DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop image worker missing before native thread completion"); - return false; - } - if (!m->imageThread->wait(0)) return false; - m->imageThread.reset(); - m->imageJoined = true; - DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "desktop image native thread cleanup completed frames=%llu", - (unsigned long long)m->imageFrames.load()); - return true; -} - uint64_t StreamPublishEngine::softwareEncodingRequest() const { std::lock_guard guard(m->lock); return m->softwareRequest; } diff --git a/tools/chatgui/src/stream_publish_engine.h b/tools/chatgui/src/stream_publish_engine.h index b70e9980f..bcb015bfb 100644 --- a/tools/chatgui/src/stream_publish_engine.h +++ b/tools/chatgui/src/stream_publish_engine.h @@ -1,10 +1,11 @@ /* Desktop publisher принадлежит MainWindow и переживает закрытие окна управления. * Публичные методы — GUI-поток: start открывает actor/capture, но первый KEY и Live приходят асинхронно. * reconfigure публикует желаемую конфигурацию; ошибка видео откатывает к последней рабочей, повторная ошибка останавливает. - * Audio callback только копирует PCM; Opus/video кодируются в отдельных workers, QVideoFrame преобразует долгоживущий image worker. - * Capture/preview хранят последний кадр, данные уходят через общий C publisher actor. - * stop закрывает capture, оставляя GUI доступным; отдельный join ждёт завершения workers/TLS, затем finishStop отправляет FIN. - * active остаётся true до окончания actor; новый start разрешён после active=false. Деструктор ждёт также image worker, обрабатывая Qt events. */ + * Захват/конвертация изолированы в video capture процессе; GUI и encoder получают только собственные CPU QImage. + * Audio callback копирует PCM; Opus/video кодируются в отдельных workers, данные уходят через общий C publisher actor. + * stop сразу закрывает admission publisher и асинхронно запрашивает выход capture, FIN дренируется независимо. + * active остаётся true до окончания actor, workers и capture; только после этого разрешён следующий start. + * Деструктор передаёт закрытие capture независимому supervisor, ожидая лишь encoder/audio с обработкой Qt events. */ #pragma once #include #include @@ -21,11 +22,11 @@ #include "../../../src/stream/stream_publisher.h" struct StreamVideoSource { - enum Kind { Camera, Screen, Window }; // Window поддерживается openSource, но sources() перечисляет только camera/screen. + enum Kind { Camera, Screen, Window }; // Соответствует VideoCaptureKind; native handles остаются в worker. Kind kind = Camera; // Backend захвата. QString name; // Отображаемое имя; не часть идентичности источника. QByteArray id; // Camera device-id, дескриптор screen либо "portal" для системного выбора Wayland. - QVariant window; // QCapturableWindow для Kind::Window; пустой у camera/screen. + QVariant window; // Зарезервировано пустым; окна выбираются по id из snapshot worker. QSize size; // Физический размер screen; может быть неизвестен до первого кадра portal. QList modes; // Режимы камеры из объявленных возможностей; screen-режимы строятся отдельно. QPointer screen; // Живой монитор; удаление устройства не должно переключать захват на его одноимённого соседа. @@ -57,9 +58,9 @@ public: StreamPublishConfig config() const; // Копия wanted под lock; может ещё ожидать применения/отката. uint64_t videoBitrate() const; // Фактический выход video encoder за последний интервал около секунды, бит/с; читать в GUI-потоке. int videoMaxQp() const; // Текущий потолок QP с учётом адаптации; -1 — настройки encoder. - QVariant previewFrame() const; // Shared QVideoFrame последнего capture, пустой/невалидный без кадра; сетевое кодирование не требуется. + QVariant previewFrame() const; // Собственный CPU QImage; пустой QVariant без кадра, lifetime не связан с capture. QList codecs() const; // Кодеки LiveVideoEncoder, найденные при создании; пусто без поддержки capture. - static QList sources(); // Текущие камеры и screen: Wayland даёт один portal, остальные платформы — QScreen handles. + QList sources() const; // Последний асинхронный snapshot worker; до sourcesChanged список может быть пуст. static bool supported(); // Возможности сборки HAVE_STREAM_CAPTURE; не гарантия наличия устройства или рабочего encoder. static bool cursorSupported(const StreamVideoSource& source); // Наличие cursor backend для выбранного screen/платформы. void setMuted(bool muted); // Только running+audio: меняет флаг actor и обнуляет исходящий PCM; ошибку смены трактует как отказ публикации. @@ -72,7 +73,7 @@ signals: void sourcesChanged(); // Камеры/мониторы изменились либо уточнён размер захваченного screen; GUI перечитывает sources/config. void stateChanged(const QString& state); // Пользовательский текст запуска, Live, применения, остановки или ожидания CPU/portal. void configurationChanged(); // Настройки подтверждены либо изменилось состояние start/stop/mute; перечитать config/active/running. - void previewVideoChanged(const QVariant& frame); // GUI публикует новый QVideoFrame до ~30fps; пустой QVariant очищает preview при closeSource. + void previewVideoChanged(const QVariant& frame); // CPU QImage до ~30fps; пустой QVariant очищает preview при closeSource. void telemetryChanged(const stream_publisher_status& status); // Снимок actor, примерно каждые 100мс вне ожидания workers. void errorOccurred(const QString& error); // Ошибка может привести к rollback либо stop; сама не означает active=false. void audioChannelsChanged(int channels); // Фактический mono/stereo вход; mono дублируется в stereo-транспорт. @@ -85,16 +86,14 @@ private: AudioCaptureLease m_microphone{AudioCaptureLease::Mode::Exclusive}; // Удерживается до закрытия устройства и завершения workers. QTimer m_timer; // GUI 100мс: finishStop, состояние actor, telemetry, bitrate и таймауты capture. QTimer m_previewTimer; // GUI 33мс: отправляет только изменившийся previewFrame. - bool openSource(const StreamPublishConfig& config); // Пересоздаёт capture backend в GUI, true — принят запуск; ошибки возможны позже. + bool openSource(const StreamPublishConfig& config); // Команда отдельному capture-процессу; ошибки приходят асинхронно. void closeSource(); // Меняет sourceGeneration, очищает кадры, отключает callbacks и закрывает backend. bool openMicrophone(); // true — открыт вход; нормализует каналы и подключает captureAudio. // Только текущее поколение: rollback к good либо stop; cameraError добавляет пояснение об ошибке подключения камеры. void sourceFailed(uint64_t generation, const QString& error, bool cameraError = false); void publicationFailed(uint64_t id, const QString& error); // Ошибка всей текущей публикации, без video rollback: errorOccurred и stop. void tick(); // GUI-проверка состояния actor/остановки, capture/audio timeout и статистики. - bool finishStop(); // true — workers join и закрытие микрофона завершены, FIN уже отправлен; actor ещё может дренироваться. - bool finishImageStop(); // Неблокирующий wait(0) image QThread при уничтожении; true — native TLS уже освобождён. - void imageLoop(); // Долгоживущий QThread: последний QVideoFrame → QImage, rotation/mirror и cursor Windows. + bool finishStop(); // true — workers, микрофон и capture завершились; FIN уже дренируется независимо в actor. void videoLoop(StreamPublishConfig initial); // Video worker: encoder/revision, HW→CPU consent, KEY, bitrate/QP и feed actor. void audioLoop(); // Audio worker: 20мс stereo, compressor/Opus, пропуск устаревших blocks, feed actor. void captureAudio(const AudioCapture::Frame& frame); // Hardware callback: до 960 frames на элемент ограниченной очереди, без allocations/codec. diff --git a/tools/chatgui/src/stream_publish_window.cpp b/tools/chatgui/src/stream_publish_window.cpp index 1de5515e3..4f8ea3190 100644 --- a/tools/chatgui/src/stream_publish_window.cpp +++ b/tools/chatgui/src/stream_publish_window.cpp @@ -1,4 +1,5 @@ #include "stream_publish_window.h" +#include "videoplayer_window.h" #include "capture_controls.h" #include "sound_manager.h" #include "debug_config.h" @@ -130,12 +131,12 @@ StreamPublishWindow::StreamPublishWindow(StreamPublishEngine* engine, uint64_t g m_buffer = new StreamBufferIndicator(this); m_buffer->setFixedSize(std::max(80, m_start->sizeHint().width()), 20); controls->addWidget(m_buffer); controls->addWidget(m_start); layout->addLayout(controls); #ifdef HAVE_STREAM_CAPTURE - auto* preview = new QVideoWidget(this); m_preview = preview; - preview->setMinimumHeight(160); preview->setAspectRatioMode(Qt::KeepAspectRatio); - preview->videoSink()->setVideoFrame(engine->previewFrame().value()); + auto* preview = new VideoRenderWidget(this); m_preview = preview; + preview->setMinimumHeight(160); + preview->setFrame(engine->previewFrame().value()); connect(engine, &StreamPublishEngine::previewVideoChanged, this, [this, preview](const QVariant& frame) { - auto video = frame.value(); - if (!video.isValid() || isVisible()) preview->videoSink()->setVideoFrame(video); + auto image = frame.value(); + if (image.isNull() || isVisible()) preview->setFrame(image); }); #else m_preview = new QLabel(text("Video capture requires Qt Multimedia and FFmpeg"), this); @@ -316,7 +317,7 @@ void StreamPublishWindow::saveSettings() { void StreamPublishWindow::refreshSources() { QScopedValueRollback updating(m_updating, true); auto config = m_engine->active() ? m_engine->config() : selection(); - m_sources = StreamPublishEngine::sources(); clearOptions(m_source, "Video source"); + m_sources = m_engine->sources(); clearOptions(m_source, "Video source"); int selected = -1; for (int i = 0; i < m_sources.size(); i++) { auto& source = m_sources[i]; diff --git a/tools/chatgui/src/streamgrid_window.cpp b/tools/chatgui/src/streamgrid_window.cpp index ee5c82dfa..f6cc8ce1e 100644 --- a/tools/chatgui/src/streamgrid_window.cpp +++ b/tools/chatgui/src/streamgrid_window.cpp @@ -62,7 +62,7 @@ void StreamGridWindow::createTile(Tile& tile) { tile.title->setToolTip(tile.name); layout->addWidget(tile.title); tile.status = new QLabel(text("Connecting…"), tile.widget); layout->addWidget(tile.status); #ifdef HAVE_STREAM_CAPTURE - auto* preview = new QVideoWidget(tile.widget); preview->setAspectRatioMode(Qt::KeepAspectRatio); + auto* preview = new VideoRenderWidget(tile.widget); tile.video = preview; #else tile.video = new QLabel(text("Video capture requires Qt Multimedia and FFmpeg"), tile.widget); @@ -82,7 +82,7 @@ void StreamGridWindow::createTile(Tile& tile) { for (auto* child : tile.video->findChildren()) child->installEventFilter(this); } -/* QVariant хранит shared QVideoFrame; оба окна читают последний захваченный кадр без перекодирования. */ +/* QVariant хранит CPU QImage; очистка локального preview не затрагивает capture-процесс. */ void StreamGridWindow::setLocalPreview(uint64_t group, const QVariant& frame) { if (m_closed || group != m_group) return; m_preview = frame; @@ -91,13 +91,13 @@ void StreamGridWindow::setLocalPreview(uint64_t group, const QVariant& frame) { void StreamGridWindow::updatePreview(Tile& tile) { #ifdef HAVE_STREAM_CAPTURE - auto frame = m_preview.value(); - static_cast(tile.video)->videoSink()->setVideoFrame(frame); - tile.status->setText(text(frame.isValid() ? "Live" : "Connecting…")); - if (frame.isValid() && !tile.previewPresented) + auto frame = m_preview.value(); + static_cast(tile.video)->setFrame(frame); + tile.status->setText(text(!frame.isNull() ? "Live" : "Connecting…")); + if (!frame.isNull() && !tile.previewPresented) DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview first local frame source=%llx size=%dx%d decoder=0 audio=0", (unsigned long long)tile.source, frame.width(), frame.height()); - tile.previewPresented = frame.isValid(); + tile.previewPresented = !frame.isNull(); #else Q_UNUSED(tile); #endif diff --git a/tools/chatgui/tests/test_call_camera_capture.cpp b/tools/chatgui/tests/test_video_capture.cpp similarity index 100% rename from tools/chatgui/tests/test_call_camera_capture.cpp rename to tools/chatgui/tests/test_video_capture.cpp