Feed publisher CPU frames and decouple FIN from capture teardown

This commit is contained in:
evgeny committed 2026-11-01 20:06:56 +01:00
1 parent 5256dfa8c5
commit eed07cb358
5 files changed
+106 -447

No files matched your search

+80 -421
View File
@@ -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 <QGuiApplication>
@@ -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 <QCamera>
#include <QMediaDevices>
#include <QMediaCaptureSession>
#include <QVideoSink>
#include <QScreenCapture>
#include <QWindowCapture>
#include <QVideoFrame>
#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::microseconds>(
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<QThread> imageThread;
std::atomic<bool> workersJoined{false}, videoExited{false}, audioExited{false}, imageJoined{false};
std::atomic<bool> workersJoined{false}, videoExited{false}, audioExited{false};
bool stopping = false, finSent = false, destroying = false;
int64_t stopStarted = 0, stopReport = 0;
std::atomic<bool> running{false}, muted{false};
@@ -102,9 +70,6 @@ struct StreamPublishEngine::Impl {
uint64_t captureReportRaw = 0, captureReportConverted = 0, captureReportEncoded = 0, captureReportDelivered = 0;
std::atomic<const char*> videoStage{"stopped"};
std::atomic<int64_t> videoStageAt{0};
std::atomic<const char*> imageStage{"starting"};
std::atomic<int64_t> imageStageAt{monotonicUs()};
std::atomic<uint64_t> imageFrames{0};
std::atomic<uint64_t> convertedFrames{0}, encodedFrames{0}, deliveredFrames{0}, repeatedFrames{0};
std::atomic<uint64_t> encodedBytes{0}, lastKeyBytes{0}; // Видео до сетевой отправки, без повторов транспорта.
std::atomic<int> 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<int> 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<QMediaCaptureSession> session;
std::unique_ptr<QVideoSink> sink;
std::unique_ptr<QCamera> camera;
std::unique_ptr<QScreenCapture> screen;
#ifdef UTUN_STREAM_PORTAL
std::unique_ptr<StreamPortalCapture> portal;
#endif
std::unique_ptr<QTimer> screenGrabTimer;
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
std::shared_ptr<StreamScreenCursor> cursor;
#endif
std::unique_ptr<QWindowCapture> window;
QImage frame;
QImage latestImage;
QVideoFrame previewFrame;
QVideoFrame presentedPreview;
std::unique_ptr<VideoCapture> capture;
QList<StreamVideoSource> 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<VideoCapture>(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<StreamVideoSource::Kind>(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<std::mutex> 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<int64_t>(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<std::mutex> 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<std::mutex> 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<std::mutex> guard(m->lock); return QVariant::fromValue(m->previewFrame);
std::lock_guard<std::mutex> 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<StreamVideoSource> StreamPublishEngine::sources() {
QList<StreamVideoSource> sources;
QList<StreamVideoSource> 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<std::mutex> guard(m->lock); generation = ++m->sourceGeneration;
m->backendOrigin = m->backendAnchor = -1; m->capturedFrames = m->convertedCaptureFrames = 0; m->lastImageCaptureAt = 0; }
m->session = std::make_unique<QMediaCaptureSession>(); m->sink = std::make_unique<QVideoSink>(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<std::mutex> 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<int64_t>(1, callbackAt - m->origin);
}
{
std::lock_guard<std::mutex> 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<QCamera>(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<StreamPortalCapture>();
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<std::mutex> guard(m->lock); m->cursor = std::make_shared<StreamScreenCursor>(selected); }
#endif
m->screenGrabTimer = std::make_unique<QTimer>(); m->screenGrabTimer->setTimerType(Qt::PreciseTimer);
connect(m->screenGrabTimer.get(), &QTimer::timeout, this, [this, generation, screen = QPointer<QScreen>(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<std::mutex> guard(m->lock); m->cursor = std::make_shared<StreamScreenCursor>(selected); }
#endif
m->screen = std::make_unique<QScreenCapture>();
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<QCapturableWindow>();
if (!window.isValid()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop window is unavailable"); return false; }
m->window = std::make_unique<QWindowCapture>(); 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<std::mutex> 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<VideoCaptureKind>(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<std::mutex> 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<StreamScreenCursor> cursor;
#endif
m->imageProgress("wait-frame");
{
std::unique_lock<std::mutex> 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<int>(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<std::mutex> 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<std::mutex> 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<std::mutex> guard(m->lock); return m->softwareRequest;
}
+12 -13
View File
@@ -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 <QObject>
#include <QTimer>
@@ -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<StreamVideoMode> modes; // Режимы камеры из объявленных возможностей; screen-режимы строятся отдельно.
QPointer<QScreen> 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<int> codecs() const; // Кодеки LiveVideoEncoder, найденные при создании; пусто без поддержки capture.
static QList<StreamVideoSource> sources(); // Текущие камеры и screen: Wayland даёт один portal, остальные платформы — QScreen handles.
QList<StreamVideoSource> 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.
+7 -6
View File
@@ -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<QVideoFrame>());
auto* preview = new VideoRenderWidget(this); m_preview = preview;
preview->setMinimumHeight(160);
preview->setFrame(engine->previewFrame().value<QImage>());
connect(engine, &StreamPublishEngine::previewVideoChanged, this, [this, preview](const QVariant& frame) {
auto video = frame.value<QVideoFrame>();
if (!video.isValid() || isVisible()) preview->videoSink()->setVideoFrame(video);
auto image = frame.value<QImage>();
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<bool> 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];
+7 -7
View File
@@ -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<QWidget*>()) 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<QVideoFrame>();
static_cast<QVideoWidget*>(tile.video)->videoSink()->setVideoFrame(frame);
tile.status->setText(text(frame.isValid() ? "Live" : "Connecting…"));
if (frame.isValid() && !tile.previewPresented)
auto frame = m_preview.value<QImage>();
static_cast<VideoRenderWidget*>(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