diff --git a/tools/chatgui/src/stream_publish_engine.cpp b/tools/chatgui/src/stream_publish_engine.cpp index 5f04d5b92..51ab8b03d 100644 --- a/tools/chatgui/src/stream_publish_engine.cpp +++ b/tools/chatgui/src/stream_publish_engine.cpp @@ -98,7 +98,7 @@ 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 - m->capture = std::make_unique(nullptr, VideoCapture::Usage::Stream); + m->capture = std::make_unique(this, VideoCapture::Usage::Stream); connect(m->capture.get(), &VideoCapture::devicesChanged, this, [this](const VideoCaptureDevices& devices) { m->sources.clear(); for (const auto& device : devices) { diff --git a/tools/chatgui/tests/stream_capture_fixture.h b/tools/chatgui/tests/stream_capture_fixture.h new file mode 100644 index 000000000..b3881587e --- /dev/null +++ b/tools/chatgui/tests/stream_capture_fixture.h @@ -0,0 +1,59 @@ +/* Источник только для тестовых worker-процессов: неподвижный экран и зависания на границах native lifecycle. */ +#pragma once +#include "../src/video_capture_source.h" +#include "../../../src/call/call_video_playout.h" +#include +#include +#include +#include +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) +#include +class BlockedStreamFrame : public QAbstractVideoBuffer { +public: + MapData map(QVideoFrame::MapMode) override { QThread::sleep(60); return {}; } + QVideoFrameFormat format() const override { return {QSize(16, 16), QVideoFrameFormat::Format_BGRX8888}; } +}; +#endif + +class StreamCaptureFixture : public VideoCaptureSource { + QTimer timer; + QByteArray mode; + bool started = false; +public: + explicit StreamCaptureFixture(QByteArray testMode) : mode(std::move(testMode)) { + timer.setSingleShot(true); + connect(&timer, &QTimer::timeout, this, [this] { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + if (mode == "convert") { + QVideoFrame raw(std::make_unique()); raw.setStartTime(call_video_playout_now_us()); + emit frame(raw); return; + } +#endif + if (mode == "bad-frame") { + QVideoFrame raw(QVideoFrameFormat(QSize(16, 16), QVideoFrameFormat::Format_Jpeg)); + if (raw.map(QVideoFrame::WriteOnly)) { std::memset(raw.bits(0), 0, raw.mappedBytes(0)); raw.unmap(); } + emit frame(raw); return; + } + QImage image(640, 360, QImage::Format_RGB32); image.fill(Qt::red); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QVideoFrame raw(image); +#else + QVideoFrame raw(QVideoFrameFormat(image.size(), QVideoFrameFormat::Format_BGRA8888)); + if (!raw.map(QVideoFrame::WriteOnly)) return; + for (int row = 0; row < image.height(); ++row) + std::memcpy(raw.bits(0) + row * raw.bytesPerLine(0), image.constScanLine(row), size_t(image.width()) * 4); + raw.unmap(); +#endif + raw.setStartTime(call_video_playout_now_us()); emit frame(raw); + }); + } + ~StreamCaptureFixture() override { if (started && mode == "destroy") QThread::sleep(60); } + VideoCaptureDevices devices(bool) override { + return {{QStringLiteral("Isolated test screen"), "fixture", {}, VideoCaptureKind::Screen, QSize(640, 360)}}; + } + bool start(const VideoCaptureConfig&) override { started = true; timer.start(1); return true; } + void stop() override { + timer.stop(); emit stage(QStringLiteral("screen.stop")); + if (started && mode == "stop") QThread::sleep(60); + } +}; diff --git a/tools/chatgui/tests/test_stream_publish.cpp b/tools/chatgui/tests/test_stream_publish.cpp index 0ae28bc03..ab8e0e1cd 100644 --- a/tools/chatgui/tests/test_stream_publish.cpp +++ b/tools/chatgui/tests/test_stream_publish.cpp @@ -53,6 +53,7 @@ extern "C" { #include #include #include +#include "stream_capture_fixture.h" #endif #ifdef UTUN_STREAM_X11_CURSOR #include "../src/stream_screen_cursor.h" @@ -909,6 +910,94 @@ private slots: QCOMPARE(status.total_bytes, status.video_bytes + status.audio_bytes + status.stream_bytes + status.transport_bytes + status.inflight_bytes); gui_stream_publish_stop(id); QTRY_VERIFY_WITH_TIMEOUT(ended(id), 4000); } + void isolatedCaptureStop_data() { + QTest::addColumn("mode"); QTest::addColumn("destroyEngine"); + QTest::newRow("screen-stop") << QByteArray("stop") << false; + QTest::newRow("native-destructor") << QByteArray("destroy") << false; + QTest::newRow("engine-destruction") << QByteArray("stop") << true; +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QTest::newRow("conversion") << QByteArray("convert") << false; +#endif + } + void isolatedCaptureStop() { +#ifdef HAVE_STREAM_CAPTURE + QFETCH(QByteArray, mode); QFETCH(bool, destroyEngine); + qputenv("UTUN_STREAM_CAPTURE_FIXTURE", mode); + auto restore = qScopeGuard([] { qunsetenv("UTUN_STREAM_CAPTURE_FIXTURE"); }); + auto engine = std::make_unique(); + QTRY_VERIFY_WITH_TIMEOUT(!engine->sources().isEmpty(), 4000); + StreamPublishConfig config; config.audio = false; config.showCursor = false; + config.source = engine->sources().front(); config.video.width = 320; config.video.height = 180; config.video.fps = 15; + connect(engine.get(), &StreamPublishEngine::softwareEncodingRequested, engine.get(), + [&](uint64_t generation) { engine->answerSoftwareEncoding(generation, true); }); + uint64_t id = 0; + connect(engine.get(), &StreamPublishEngine::telemetryChanged, this, [&](const stream_publisher_status& status) { id = status.id; }); + QSignalSpy errors(engine.get(), &StreamPublishEngine::errorOccurred); + QVERIFY(engine->start(group, config)); QTRY_VERIFY_WITH_TIMEOUT(id, 3000); + if (mode != "convert") QTRY_VERIFY_WITH_TIMEOUT(engine->previewFrame().isValid(), 3000); + else QTest::qWait(200); // Worker уже вошёл в заведомо блокирующий map. + unsigned ticks = 0; qint64 previous = 0, gap = 0; QElapsedTimer elapsed; elapsed.start(); QTimer heartbeat; + connect(&heartbeat, &QTimer::timeout, this, [&] { + const qint64 now = elapsed.elapsed(); gap = std::max(gap, now - previous); previous = now; ++ticks; + }); heartbeat.start(10); + QPointer capture = engine->findChild(); QVERIFY(capture); + if (destroyEngine) { engine.reset(); QVERIFY(elapsed.elapsed() < 500); } + else { + QSignalSpy stopped(capture, &VideoCapture::stopped); + engine->stop(); QVERIFY(elapsed.elapsed() < 100); QVERIFY(capture->closing()); + QTRY_VERIFY_WITH_TIMEOUT(([&] { + stream_publisher_status status{}; + return gui_stream_publish_status(id, status) && status.state == STREAM_PUBLISHER_DRAINING; + })(), 1000); + QVERIFY(capture->closing()); // FIN уже отправлен, аппаратное закрытие ещё висит. + QTRY_COMPARE_WITH_TIMEOUT(stopped.count(), 1, 3000); + QVERIFY(!capture->closing()); QTRY_VERIFY_WITH_TIMEOUT(!engine->active(), 4000); + QVERIFY(errors.isEmpty()); + // Тот же engine открывает новую сессию после подтверждённого выхода прежнего worker. + qputenv("UTUN_STREAM_CAPTURE_FIXTURE", "idle"); + QVERIFY(engine->start(group, config)); QTRY_VERIFY_WITH_TIMEOUT(engine->previewFrame().isValid(), 3000); + engine->stop(); QTRY_VERIFY_WITH_TIMEOUT(!engine->active(), 4000); + } + QTRY_VERIFY_WITH_TIMEOUT(ended(id) || (engine && engine->active()), 4000); + QVERIFY(ticks > 50); QVERIFY2(gap < 200, qPrintable(QString::number(gap))); + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "[PASS] isolated publisher stop mode=%s destroy=%d GUI_gap_ms=%lld ticks=%u", + mode.constData(), destroyEngine, (long long)gap, ticks); +#else + QSKIP("Capture unavailable"); +#endif + } + void isolatedStaticScreen() { +#ifdef HAVE_STREAM_CAPTURE + qputenv("UTUN_STREAM_CAPTURE_FIXTURE", "idle"); + auto restore = qScopeGuard([] { qunsetenv("UTUN_STREAM_CAPTURE_FIXTURE"); }); + StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.sources().isEmpty(), 4000); + StreamPublishConfig config; config.audio = false; config.showCursor = false; + config.source = engine.sources().front(); config.video.width = 320; config.video.height = 180; config.video.fps = 15; + connect(&engine, &StreamPublishEngine::softwareEncodingRequested, &engine, + [&](uint64_t generation) { engine.answerSoftwareEncoding(generation, true); }); + QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); + QVERIFY(engine.start(group, config)); QTRY_VERIFY_WITH_TIMEOUT(engine.previewFrame().isValid(), 3000); + QTest::qWait(5600); QVERIFY(engine.running()); QVERIFY(errors.isEmpty()); + engine.stop(); QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); +#else + QSKIP("Capture unavailable"); +#endif + } + void captureConversionFailure() { +#ifdef HAVE_STREAM_CAPTURE + qputenv("UTUN_STREAM_CAPTURE_FIXTURE", "bad-frame"); + auto restore = qScopeGuard([] { qunsetenv("UTUN_STREAM_CAPTURE_FIXTURE"); }); + StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.sources().isEmpty(), 4000); + StreamPublishConfig config; config.audio = false; config.showCursor = false; config.source = engine.sources().front(); + config.video.width = 320; config.video.height = 180; + QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); + QVERIFY(engine.start(group, config)); QTRY_VERIFY_WITH_TIMEOUT(!errors.isEmpty(), 3000); + QVERIFY(errors.front().front().toString().contains("frame conversion failed")); + QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); +#else + QSKIP("Capture unavailable"); +#endif + } void stopWithGuiThreadCleanup_data() { QTest::addColumn("destroy"); QTest::newRow("stop") << false; @@ -1251,7 +1340,16 @@ private slots: } }; int main(int argc, char** argv) { - if (argc >= 2 && QByteArray(argv[1]) == "--video-capture-worker") return video_capture_worker_main(argc, argv); + if (argc >= 2 && QByteArray(argv[1]) == "--video-capture-worker") { +#ifdef HAVE_STREAM_CAPTURE + const auto fixture = qgetenv("UTUN_STREAM_CAPTURE_FIXTURE"); + if (!fixture.isEmpty()) { + QGuiApplication app(argc, argv); debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); + return video_capture_worker_run(QString::fromLocal8Bit(argv[2]), std::make_unique(fixture), true); + } +#endif + return video_capture_worker_main(argc, argv); + } QApplication app(argc, argv); StreamPublishTest test; return QTest::qExec(&test, argc, argv); } #include "test_stream_publish.moc"