From 1dc26bf71580670b0716dd41b7f6efceacd0b415 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 17 Oct 2026 01:49:35 +0200 Subject: [PATCH] Add bounded mouse zoom and panning to live stream viewer --- doc/tasks.md | 7 ++ tools/chatgui/src/streamplayer_window.cpp | 2 +- tools/chatgui/src/videoplayer_window.cpp | 145 +++++++++++++++++++++- tools/chatgui/src/videoplayer_window.h | 19 ++- tools/chatgui/tests/test_stream_view.cpp | 137 ++++++++++++++++++++ 5 files changed, 306 insertions(+), 4 deletions(-) diff --git a/doc/tasks.md b/doc/tasks.md index 9624bfb1..1633c4d3 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -4,6 +4,13 @@ ## Текущие задачи +[ ] **Разобрать нестабильную проверку аудиопауз desktop-трансляции** — 17.10.2026: + При проверке зума test_stream_view::realH264RenderingAndClose получил silenceGaps=3 вместо gapsBefore=2. + В логе есть desktop audio gap cause=pcm_short, got=96/480; видео продолжает выводиться (50 кадров к stop). + Первый полный Qt-прогон прошёл; сбой повторился с общим check.sh и после его завершения. + Причина не установлена; нужны диагностика PCM/часов и воспроизведение под нагрузкой. + Логи: /tmp/stream-zoom-full-view-audio-gap.log, /tmp/stream-zoom-full-view.log. + [ ] **Устранить коллизию порта при перезапуске узла в 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; отдельный повтор теста проходит. diff --git a/tools/chatgui/src/streamplayer_window.cpp b/tools/chatgui/src/streamplayer_window.cpp index ebc546b1..448eaf69 100644 --- a/tools/chatgui/src/streamplayer_window.cpp +++ b/tools/chatgui/src/streamplayer_window.cpp @@ -90,7 +90,7 @@ StreamPlayerWindow::StreamPlayerWindow(uint64_t group, uint64_t source, uint64_t setWindowTitle(text("Live stream: %1").arg(name)); resize(800, 500); auto* layout = new QVBoxLayout(this); layout->addWidget(new QLabel(name, this)); m_status = new QLabel(text("Connecting…"), this); layout->addWidget(m_status); - m_render = new VideoRenderWidget(this); layout->addWidget(m_render, 1); + m_render = new VideoRenderWidget(this, true); layout->addWidget(m_render, 1); auto* controls = new QHBoxLayout; auto* volume = new QSlider(Qt::Horizontal, this); volume->setRange(0, 100); volume->setValue(100); volume->setAccessibleName(text("Stream volume")); controls->addWidget(new QLabel(text("Volume"), this)); controls->addWidget(volume); diff --git a/tools/chatgui/src/videoplayer_window.cpp b/tools/chatgui/src/videoplayer_window.cpp index 25e94085..8f6c9857 100644 --- a/tools/chatgui/src/videoplayer_window.cpp +++ b/tools/chatgui/src/videoplayer_window.cpp @@ -11,35 +11,178 @@ #include #include #include +#include #include #include #include #include #include #include +#include +#include /* ─── VideoRenderWidget ─── */ -VideoRenderWidget::VideoRenderWidget(QWidget* parent) : QWidget(parent) { +VideoRenderWidget::VideoRenderWidget(QWidget* parent, bool zoomEnabled) : QWidget(parent), m_zoomEnabled(zoomEnabled) { setMouseTracking(true); setAttribute(Qt::WA_OpaquePaintEvent); } void VideoRenderWidget::setFrame(const QImage& img) { + bool changed = m_frame.size() != img.size(); m_frame = img; + if (m_zoomEnabled && changed) { + finishDrag(); m_scale = 0; m_offset = {}; constrainView("frame"); + DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoView %p frame=%dx%d reset to fit scale=%.4f DPR=%.3f", + static_cast(this), img.width(), img.height(), frameScale(), devicePixelRatioF()); + } update(); } void VideoRenderWidget::clearFrame() { + finishDrag(); m_scale = 0; m_offset = {}; unsetCursor(); m_frame = QImage(); update(); } +/* Масштаб в координатах Qt; предел 1:1 относится к физическим пикселям экрана. */ +qreal VideoRenderWidget::frameScale() const { + if (m_frame.isNull() || width() <= 0 || height() <= 0) return 0; + qreal dpr = devicePixelRatioF(); + qreal fit = std::min({qreal(1), width() * dpr / m_frame.width(), height() * dpr / m_frame.height()}); + return (m_scale > 0 ? std::clamp(m_scale, fit, qreal(1)) : fit) / dpr; +} + +/* Как в Android VideoZoomState: сдвиг ограничен половиной выступающей части каждой оси. */ +void VideoRenderWidget::constrainView(const char* reason) { + qreal scale = frameScale(); + if (scale <= 0) { finishDrag(); unsetCursor(); return; } + if (m_scale > 0) m_scale = scale * devicePixelRatioF(); + qreal x = std::max(qreal(0), (m_frame.width() - width() / scale) / 2); + qreal y = std::max(qreal(0), (m_frame.height() - height() / scale) / 2); + m_offset.setX(std::clamp(m_offset.x(), -x, x)); + m_offset.setY(std::clamp(m_offset.y(), -y, y)); + if (x > 0 || y > 0) setCursor(m_panning ? Qt::ClosedHandCursor : Qt::OpenHandCursor); + else { finishDrag(); unsetCursor(); } + DEBUG_TRACE(DEBUG_CATEGORY_VIDEO, "VideoView %p %s viewport=%dx%d frame=%dx%d DPR=%.3f scale=%.4f offset=(%.2f,%.2f) limit=(%.2f,%.2f)", + static_cast(this), reason, width(), height(), m_frame.width(), m_frame.height(), devicePixelRatioF(), scale, + m_offset.x(), m_offset.y(), x, y); +} + +void VideoRenderWidget::finishDrag() { + if (!m_panning) return; + m_panning = false; setCursor(Qt::OpenHandCursor); + DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoView %p drag end offset=(%.2f,%.2f)", + static_cast(this), m_offset.x(), m_offset.y()); +} + +/* Захват мыши принадлежит QWidget; отмена жеста нужна и при потере окна/захвата. */ +bool VideoRenderWidget::event(QEvent* event) { + bool handled = QWidget::event(event); + if (!m_zoomEnabled) return handled; + if (event->type() == QEvent::UngrabMouse || event->type() == QEvent::WindowDeactivate || event->type() == QEvent::Hide) + finishDrag(); + if (event->type() == QEvent::ScreenChangeInternal +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + || event->type() == QEvent::DevicePixelRatioChange +#endif + ) { + constrainView("screen"); update(); + DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoView %p screen changed DPR=%.3f scale=%.4f", + static_cast(this), devicePixelRatioF(), frameScale()); + } + return handled; +} + +void VideoRenderWidget::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + if (!m_zoomEnabled) return; + constrainView("resize"); + DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoView %p resize=%dx%d scale=%.4f offset=(%.2f,%.2f)", + static_cast(this), width(), height(), frameScale(), m_offset.x(), m_offset.y()); +} + +/* Сохраняем пиксель под курсором; clamp может сдвинуть якорь только возле границ кадра. */ +void VideoRenderWidget::wheelEvent(QWheelEvent* event) { + if (!m_zoomEnabled) { QWidget::wheelEvent(event); return; } + qreal previous = frameScale(); + if (previous <= 0) { event->accept(); return; } + int delta = event->angleDelta().y(); + if (!delta) delta = event->pixelDelta().y(); + if (!delta) { event->ignore(); return; } +#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) + QPointF position = event->position(); +#else + QPointF position = event->posF(); +#endif + qreal steps = std::clamp(delta / qreal(120), qreal(-100), qreal(100)); + m_scale = previous * devicePixelRatioF() * std::pow(1.15, steps); + qreal next = frameScale(); + if (m_frame.width() * next <= width() + 1e-6 && m_frame.height() * next <= height() + 1e-6) m_scale = 0; + QPointF relative = position - QPointF(width() / 2.0, height() / 2.0); + m_offset += relative * (1 / next - 1 / previous); + constrainView("zoom"); update(); event->accept(); + DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoView %p zoom delta=%d scale=%.4f->%.4f physical=%.4f anchor=(%.1f,%.1f) offset=(%.2f,%.2f)", + static_cast(this), delta, previous, next, next * devicePixelRatioF(), position.x(), position.y(), + m_offset.x(), m_offset.y()); +} + +void VideoRenderWidget::mousePressEvent(QMouseEvent* event) { + qreal scale = frameScale(); + bool canPan = scale > 0 && (m_frame.width() * scale > width() + 1e-6 || m_frame.height() * scale > height() + 1e-6); + if (!m_zoomEnabled || event->button() != Qt::LeftButton || !canPan) { + QWidget::mousePressEvent(event); return; + } +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + m_lastMouse = event->position(); +#else + m_lastMouse = event->localPos(); +#endif + m_panning = true; setCursor(Qt::ClosedHandCursor); event->accept(); + DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoView %p drag begin scale=%.4f offset=(%.2f,%.2f)", + static_cast(this), frameScale(), m_offset.x(), m_offset.y()); +} + +void VideoRenderWidget::mouseMoveEvent(QMouseEvent* event) { + if (!m_panning) { QWidget::mouseMoveEvent(event); return; } + if (!(event->buttons() & Qt::LeftButton)) { finishDrag(); event->accept(); return; } +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + QPointF position = event->position(); +#else + QPointF position = event->localPos(); +#endif + m_offset += (position - m_lastMouse) / frameScale(); m_lastMouse = position; + constrainView("drag"); update(); event->accept(); +} + +void VideoRenderWidget::mouseReleaseEvent(QMouseEvent* event) { + if (!m_zoomEnabled || event->button() != Qt::LeftButton || !m_panning) { QWidget::mouseReleaseEvent(event); return; } + finishDrag(); event->accept(); +} + void VideoRenderWidget::paintEvent(QPaintEvent*) { QPainter p(this); p.fillRect(rect(), Qt::black); if (m_frame.isNull()) return; + if (m_zoomEnabled) { + qreal scale = frameScale(); + QSizeF scaled = QSizeF(m_frame.size()) * scale; + QPointF origin = QPointF(width() / 2.0, height() / 2.0) + m_offset * scale - QPointF(scaled.width(), scaled.height()) / 2; + // При 1:1 начало кадра совпадает с физической сеткой пикселей, без размытия текста. + qreal dpr = devicePixelRatioF(); + if (qFuzzyCompare(scale * dpr, qreal(1))) { + auto align = [dpr](qreal offset, int pixels, int viewport) { + qreal rounded = std::round(offset * dpr); + if (pixels > viewport * dpr) rounded = std::clamp(rounded, std::ceil(viewport * dpr - pixels), qreal(0)); + return rounded / dpr; + }; + origin = QPointF(align(origin.x(), m_frame.width(), width()), align(origin.y(), m_frame.height(), height())); + } + p.setRenderHint(QPainter::SmoothPixmapTransform, scale * dpr < 1); + p.drawImage(QRectF(origin, scaled), m_frame, QRectF(m_frame.rect())); + return; + } QSize sz = m_frame.size(); sz.scale(size(), Qt::KeepAspectRatio); QRect dst((width() - sz.width()) / 2, (height() - sz.height()) / 2, sz.width(), sz.height()); diff --git a/tools/chatgui/src/videoplayer_window.h b/tools/chatgui/src/videoplayer_window.h index 6d1e4cea..645e2373 100644 --- a/tools/chatgui/src/videoplayer_window.h +++ b/tools/chatgui/src/videoplayer_window.h @@ -11,19 +11,34 @@ class QPushButton; class QLabel; class VideoPlayerEngine; -/* Виджет отрисовки кадра (aspect-fit на чёрном фоне). */ +/* Отрисовка кадра на чёрном фоне. zoomEnabled включает колесо до физического 1:1 и drag с ограничением краёв. */ class VideoRenderWidget : public QWidget { Q_OBJECT public: - explicit VideoRenderWidget(QWidget* parent = nullptr); + explicit VideoRenderWidget(QWidget* parent = nullptr, bool zoomEnabled = false); void setFrame(const QImage& img); void clearFrame(); protected: + bool event(QEvent* event) override; void paintEvent(QPaintEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void wheelEvent(QWheelEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; private: + qreal frameScale() const; + void constrainView(const char* reason); + void finishDrag(); + QImage m_frame; + const bool m_zoomEnabled; + qreal m_scale = 0; // Физические пиксели на пиксель кадра; 0 — автоматическое вписывание. + QPointF m_offset; // Смещение от центра в пикселях исходного кадра. + QPointF m_lastMouse; + bool m_panning = false; }; /* Отдельное окно плеера без рамки: drag-перенос, dblclick/Alt+Enter — fullscreen, diff --git a/tools/chatgui/tests/test_stream_view.cpp b/tools/chatgui/tests/test_stream_view.cpp index 18f98126..21a371ec 100644 --- a/tools/chatgui/tests/test_stream_view.cpp +++ b/tools/chatgui/tests/test_stream_view.cpp @@ -1,5 +1,6 @@ /* Настоящее ядро/actor, подписанный медиаблок и H.264 decoder: каталог до подписки, эпоха, показ и teardown. */ #include "../src/streamplayer_window.h" +#include "../src/videoplayer_window.h" #include "../src/sound_manager.h" #include "../transport/stream_bridge.h" #include @@ -7,6 +8,8 @@ #include #include #include +#include +#include #include #include #include @@ -28,6 +31,44 @@ extern "C" { } static QList snapshots; + +/* Проверяем изображение на выходе QWidget, включая физические пиксели при QT_SCALE_FACTOR=2. */ +static QImage renderedVideo(VideoRenderWidget& view) { + QImage image(QSize(qRound(view.width() * view.devicePixelRatioF()), qRound(view.height() * view.devicePixelRatioF())), + QImage::Format_RGB32); + image.setDevicePixelRatio(view.devicePixelRatioF()); view.render(&image); return image; +} + +static QImage coordinateFrame() { + QImage image(1200, 900, QImage::Format_RGB32); + for (int y = 0; y < image.height(); y++) { + auto* row = reinterpret_cast(image.scanLine(y)); + for (int x = 0; x < image.width(); x++) row[x] = qRgb(1 + x % 251, 1 + y % 251, 1 + x / 251 + 16 * (y / 251)); + } + return image; +} + +static void zoomVideo(VideoRenderWidget& view, int delta, QPoint position, bool pixels = false) { + QWheelEvent event(position, view.mapToGlobal(position), pixels ? QPoint(0, delta) : QPoint(), + pixels ? QPoint() : QPoint(0, delta), Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); + QApplication::sendEvent(&view, &event); +} + +static void moveVideo(VideoRenderWidget& view, QPoint position, Qt::MouseButtons buttons = Qt::LeftButton) { + QMouseEvent event(QEvent::MouseMove, position, view.mapToGlobal(position), Qt::NoButton, buttons, Qt::NoModifier); + QApplication::sendEvent(&view, &event); +} + +static void dragVideo(VideoRenderWidget& view, QPoint from, QPoint to) { + QTest::mousePress(&view, Qt::LeftButton, Qt::NoModifier, from); moveVideo(view, to); + QTest::mouseRelease(&view, Qt::LeftButton, Qt::NoModifier, to); +} + +static QImage nativeCrop(const QImage& frame, const VideoRenderWidget& view, QPoint origin) { + QImage image = frame.copy(QRect(origin, QSize(qRound(view.width() * view.devicePixelRatioF()), + qRound(view.height() * view.devicePixelRatioF())))); + image.setDevicePixelRatio(view.devicePixelRatioF()); return image; +} #if defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG) static std::atomic delayedDecoder{false}; static AVCodecContext* delayedContext = nullptr; @@ -152,6 +193,7 @@ private slots: if (qEnvironmentVariableIsSet("UTUN_TEST_DEBUG")) { debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_TRACE); debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_TRACE); + debug_set_category_level(DEBUG_CATEGORY_VIDEO, DEBUG_LEVEL_TRACE); } debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_ERROR); allocations = u_get_allocated_count(); ua = uasync_create(); QVERIFY(ua); @@ -169,6 +211,101 @@ private slots: connect(&poll, &QTimer::timeout, this, [this] { uasync_poll(ua, 0); }); poll.start(1); } + void zoomNativeAndFrameUpdates() { + VideoRenderWidget view(nullptr, true); view.resize(200, 150); view.show(); + QImage frame = coordinateFrame(); view.setFrame(frame); + QPoint center(100, 75); + zoomVideo(view, 12000, center); + QSize physical = renderedVideo(view).size(); + QPoint origin((frame.width() - physical.width()) / 2, (frame.height() - physical.height()) / 2); + QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin)); + zoomVideo(view, 12000, center); // Верхняя граница достигается точно и не превышается. + QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin)); + dragVideo(view, center, QPoint(5000, 5000)); + QCOMPARE(renderedVideo(view), nativeCrop(frame, view, QPoint())); + frame.invertPixels(); view.setFrame(frame); // Следующий кадр сохраняет масштаб и рассматриваемый участок. + QCOMPARE(renderedVideo(view), nativeCrop(frame, view, QPoint())); + dragVideo(view, center, QPoint(-5000, -5000)); + origin = QPoint(frame.width() - physical.width(), frame.height() - physical.height()); + QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin)); + view.resize(240, 180); QApplication::processEvents(); + physical = renderedVideo(view).size(); origin = QPoint(frame.width() - physical.width(), frame.height() - physical.height()); + QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin)); + view.resize(1400, 1000); QApplication::processEvents(); // При 1:1 весь кадр временно помещается в большое окно. + view.resize(200, 150); QApplication::processEvents(); + physical = renderedVideo(view).size(); origin = QPoint((frame.width() - physical.width()) / 2, (frame.height() - physical.height()) / 2); + QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin)); + view.clearFrame(); + QImage black = renderedVideo(view); black.fill(Qt::black); QCOMPARE(renderedVideo(view), black); + } + + void zoomAnchorAndFineWheel() { + VideoRenderWidget view(nullptr, true); view.resize(200, 150); view.show(); + QImage frame(1200, 900, QImage::Format_RGB32); frame.fill(Qt::red); + // Маркер находится под курсором (120, 80) в исходном вписанном кадре. + { QPainter painter(&frame); painter.fillRect(QRect(700, 460, 40, 40), Qt::green); } + view.setFrame(frame); QPoint anchor(120, 80); + zoomVideo(view, 60, anchor); zoomVideo(view, 60, anchor); + zoomVideo(view, 30, anchor, true); // Устройство, присылающее только pixelDelta. + zoomVideo(view, 12000, anchor); + QImage actual = renderedVideo(view); + QPoint pixel(qRound(anchor.x() * view.devicePixelRatioF()), qRound(anchor.y() * view.devicePixelRatioF())); + QCOMPARE(actual.pixelColor(pixel), QColor(Qt::green)); + QCOMPARE(actual.pixelColor(pixel + QPoint(25, 0)), QColor(Qt::red)); // Маркер при 1:1 имеет исходный размер. + QCOMPARE(actual.pixelColor(pixel - QPoint(25, 0)), QColor(Qt::red)); + } + + void zoomLetterboxAndReset_data() { + QTest::addColumn("frameSize"); + QTest::newRow("landscape") << QSize(1200, 300); + QTest::newRow("portrait") << QSize(300, 1200); + } + + void zoomLetterboxAndReset() { + QFETCH(QSize, frameSize); + VideoRenderWidget view(nullptr, true); view.resize(200, 150); view.show(); + QImage frame(frameSize, QImage::Format_RGB32); frame.fill(Qt::red); view.setFrame(frame); + QPoint center(100, 75); zoomVideo(view, 120, center); + QImage before = renderedVideo(view); + dragVideo(view, center, QPoint(5000, 5000)); + QCOMPARE(renderedVideo(view), before); // Помещающаяся ось остаётся по центру, вторая полностью покрыта однотонным кадром. + QCOMPARE(before.pixelColor(0, 0), QColor(Qt::black)); + QCOMPARE(before.pixelColor(before.width() / 2, before.height() / 2), QColor(Qt::red)); + zoomVideo(view, -12000, center); QImage fitted = renderedVideo(view); + zoomVideo(view, -12000, center); QCOMPARE(renderedVideo(view), fitted); + dragVideo(view, center, QPoint(-5000, -5000)); QCOMPARE(renderedVideo(view), fitted); + zoomVideo(view, 12000, center); + QImage small(100, 80, QImage::Format_RGB32); small.fill(Qt::green); view.setFrame(small); + QImage expected = renderedVideo(view); expected.fill(Qt::black); + qreal dpr = view.devicePixelRatioF(); + { QPainter painter(&expected); painter.drawImage(QRectF((view.width() - 100 / dpr) / 2, + (view.height() - 80 / dpr) / 2, 100 / dpr, 80 / dpr), small, QRectF(small.rect())); } + QCOMPARE(renderedVideo(view), expected); // Смена размера сбрасывает zoom; маленький кадр остаётся в 1:1. + zoomVideo(view, 12000, center); QCOMPARE(renderedVideo(view), expected); + } + + void zoomDragCancellationAndFullscreen() { + VideoRenderWidget view(nullptr, true); view.resize(200, 150); view.show(); + QImage frame = coordinateFrame(); view.setFrame(frame); QPoint center(100, 75); + zoomVideo(view, 12000, center); + for (QEvent::Type type : {QEvent::UngrabMouse, QEvent::WindowDeactivate, QEvent::Hide}) { + QTest::mousePress(&view, Qt::LeftButton, Qt::NoModifier, center); + QEvent cancelled(type); QApplication::sendEvent(&view, &cancelled); + QImage before = renderedVideo(view); moveVideo(view, center + QPoint(20, 10)); + QCOMPARE(renderedVideo(view), before); + QTest::mouseRelease(&view, Qt::LeftButton, Qt::NoModifier, center); + } + QImage before = renderedVideo(view); + QTest::mousePress(&view, Qt::LeftButton, Qt::NoModifier, center); moveVideo(view, center, Qt::NoButton); + moveVideo(view, center + QPoint(20, 10)); QCOMPARE(renderedVideo(view), before); + QTest::mouseRelease(&view, Qt::LeftButton, Qt::NoModifier, center); + view.showFullScreen(); QApplication::processEvents(); + view.showNormal(); QApplication::processEvents(); QCOMPARE(renderedVideo(view), before); + VideoRenderWidget ordinary; ordinary.resize(200, 150); ordinary.show(); ordinary.setFrame(frame); + before = renderedVideo(ordinary); zoomVideo(ordinary, 12000, center); + dragVideo(ordinary, center, QPoint(5000, 5000)); QCOMPARE(renderedVideo(ordinary), before); + } + void catalogueWithoutViewer() { QTRY_COMPARE(snapshots.size(), 1); QCOMPARE(snapshots.last().mid(9), QByteArray("[]")); QVERIFY(!stream_service_needs_activity(inst));