/* Настоящее ядро/actor, подписанный медиаблок и H.264 decoder: каталог до подписки, эпоха, показ и teardown. */ #include "../src/streamplayer_window.h" #include "../src/streamgrid_window.h" #include "../src/videoplayer_window.h" #include "../src/sound_manager.h" #include "../transport/stream_bridge.h" #include #include #include #include #include #include #include #include #include #include #ifdef HAVE_STREAM_CAPTURE #include #include #include #include #endif #include #include #include #include #include extern "C" { #include "utun_instance.h" #include "chat/chat_core.h" #include "chat/chat_event.h" #include "stream/stream.h" #include "stream/stream_member.h" #include "stream/stream_viewer.h" #include "debug_config.h" #include "mem.h" #include "sqlite3.h" #include "opus_codec.h" #ifdef HAVE_FFMPEG #include #endif } 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 std::atomic videoPauseMs{0}, videoPauses{0}; static AVCodecContext* delayedContext = nullptr; static std::deque delayedFrames; static bool delayedDraining = false; extern "C" int __real_avcodec_open2(AVCodecContext*, const AVCodec*, AVDictionary**); extern "C" int __wrap_avcodec_open2(AVCodecContext* context, const AVCodec* codec, AVDictionary** options) { int result = __real_avcodec_open2(context, codec, options); if (!result && delayedDecoder && codec && av_codec_is_decoder(codec)) { delayedContext = context; delayedDraining = false; DEBUG_INFO(DEBUG_CATEGORY_STREAM, "fixture decoder name=%s holds four real decoded frames before output", codec->name); } return result; } extern "C" int __real_avcodec_receive_frame(AVCodecContext*, AVFrame*); extern "C" int __wrap_avcodec_receive_frame(AVCodecContext* context, AVFrame* frame) { if (unsigned pause = videoPauseMs.exchange(0)) { ++videoPauses; DEBUG_INFO(DEBUG_CATEGORY_STREAM, "fixture video decoder stall=%ums; audio worker must continue", pause); std::this_thread::sleep_for(std::chrono::milliseconds(pause)); } if (context != delayedContext) return __real_avcodec_receive_frame(context, frame); int result = __real_avcodec_receive_frame(context, frame); if (!result) { AVFrame* held = av_frame_clone(frame); if (!held) return AVERROR(ENOMEM); delayedFrames.push_back(held); av_frame_unref(frame); } else if (result == AVERROR_EOF) delayedDraining = true; else if (result != AVERROR(EAGAIN)) return result; if ((delayedDraining || delayedFrames.size() >= 4) && !delayedFrames.empty()) { AVFrame* held = delayedFrames.front(); delayedFrames.pop_front(); av_frame_move_ref(frame, held); av_frame_free(&held); return 0; } return delayedDraining ? AVERROR_EOF : AVERROR(EAGAIN); } extern "C" void __real_avcodec_free_context(AVCodecContext**); extern "C" void __wrap_avcodec_free_context(AVCodecContext** context) { if (context && *context == delayedContext) { for (auto* frame : delayedFrames) av_frame_free(&frame); delayedFrames.clear(); delayedContext = nullptr; delayedDraining = false; } __real_avcodec_free_context(context); } #endif #ifdef TEST_NULL_AUDIO static std::atomic originalAudioCallback{nullptr}; static std::atomic nonzeroSamples{0}; static std::atomic silenceGaps{0}; static unsigned silentRun = 0; // Только callback; счётчик промежутков >=1мс между тестовыми тонами. static std::atomic leftEnergy{0}, rightEnergy{0}; static std::atomic playbackDelayUs{0}; static std::atomic inspectMixedOutput{false}; static std::atomic mixedMean{0}, mixedPeak{0}; extern "C" int64_t __real_audio_stream_frame_time(ma_device*, unsigned int, int*); extern "C" int64_t __wrap_audio_stream_frame_time(ma_device* device, unsigned int frames, int* valid) { if (!playbackDelayUs) return __real_audio_stream_frame_time(device, frames, valid); *valid = 1; return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count() + playbackDelayUs.load(); } extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*); extern "C" ma_result __real_ma_device_init(ma_context*, const ma_device_config*, ma_device*); extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context* context) { ma_backend backend = ma_backend_null; return __real_ma_context_init(&backend, 1, nullptr, context); } static void observeAudio(ma_device* device, void* out, const void* in, ma_uint32 frames) { originalAudioCallback.load()(device, out, in, frames); const auto* pcm = static_cast(out); uint64_t left = 0, right = 0, nonzero = 0; for (unsigned i = 0; i < frames; i++) { int64_t l = std::lround(pcm[2 * i] * 32768), r = std::lround(pcm[2 * i + 1] * 32768); left += l * l; right += r * r; nonzero += pcm[2 * i] != 0; nonzero += pcm[2 * i + 1] != 0; if (pcm[2 * i] || pcm[2 * i + 1]) silentRun = 0; else if (++silentRun == 48) silenceGaps.fetch_add(1); } leftEnergy.fetch_add(left); rightEnergy.fetch_add(right); nonzeroSamples.fetch_add(nonzero); if (inspectMixedOutput) { float mean = 0, peak = 0; for (unsigned i = 0; i < frames * 2; ++i) { mean += pcm[i]; peak = std::max(peak, std::abs(pcm[i])); } mixedMean = mean / (frames * 2); mixedPeak = peak; } } extern "C" ma_result __wrap_ma_device_init(ma_context* context, const ma_device_config* config, ma_device* device) { ma_device_config copy = *config; if (copy.deviceType == ma_device_type_playback && copy.playback.format == ma_format_f32 && copy.playback.channels == 2) { originalAudioCallback = copy.dataCallback; copy.dataCallback = observeAudio; } return __real_ma_device_init(context, ©, device); } #endif static void sourceEvent(UTUN_INSTANCE*, int type, const uint8_t* data, int size) { if (type == CHAT_EVT_STREAM_SOURCES) snapshots.push_back(QByteArray(reinterpret_cast(data), size)); } class StreamViewTest : public QObject { Q_OBJECT UASYNC* ua = nullptr; UTUN_INSTANCE* inst = nullptr; STREAM_HANDLE* publisher = nullptr; uint64_t group = 0, epoch = 0, frameId = 0, mediaPts = 0; QTimer poll; size_t allocations = 0; uint32_t revision = 1; uint16_t rotation = 90; bool receive(uint64_t id, StreamRecord& record, int64_t position = -1) { QElapsedTimer elapsed; elapsed.start(); while (elapsed.elapsed() < 1000) { if (gui_stream_pull(id, record, position)) return true; QTest::qWait(1); } DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop test missing record id=%llu", (unsigned long long)id); return false; } void publish(const QByteArray& video, const QList& audio = {}, unsigned videoFrames = 1) { stream_media_block media{}; media.format = {160, 90, rotation, STREAM_MEDIA_H264, STREAM_MEDIA_OPUS_STEREO, revision}; mediaPts += 100000; media.first_frame_offset = 0; media.video_count = videoFrames; for (unsigned i = 0; i < videoFrames; i++) media.video[i] = {++frameId, mediaPts + i * 100000 / videoFrames, static_cast(video.size()), 0, static_cast(video.size()), STREAM_MEDIA_KEY, reinterpret_cast(video.constData())}; if (!audio.isEmpty()) { media.audio_count = audio.size(); for (unsigned i = 0; i < media.audio_count; i++) media.audio[i] = {mediaPts + i * 20000, static_cast(audio[i].size()), 960, reinterpret_cast(audio[i].constData())}; } uint8_t wire[7080]; size_t length = 0; QCOMPARE(stream_media_encode(&media, wire, sizeof(wire), &length), STREAM_OK); QCOMPARE(stream_publish_block(publisher, wire, length), STREAM_OK); } private slots: void initTestCase() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_INFO); if (qEnvironmentVariableIsSet("UTUN_TEST_DEBUG")) { debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_DEBUG); debug_set_category_level(DEBUG_CATEGORY_STREAM, 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); inst = utun_instance_create_from_str(ua, "[global]\nauto_sockets=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n"); QVERIFY(inst); QCOMPARE(utun_core_start(inst), 0); QCOMPARE(chat_service_start(inst), 0); chat_core_create_channel_auto(inst, "Stream test"); sqlite3_stmt* statement = nullptr; QCOMPARE(sqlite3_prepare_v2(chat_core_get_db(inst), "SELECT channel_id FROM channels LIMIT 1", -1, &statement, nullptr), SQLITE_OK); QCOMPARE(sqlite3_step(statement), SQLITE_ROW); group = QString::fromUtf8(reinterpret_cast(sqlite3_column_text(statement, 0))).toULongLong(); sqlite3_finalize(statement); QVERIFY(group); chat_event_set_handler(inst, sourceEvent); QVERIFY(gui_stream_watch(group)); QVERIFY(gui_stream_attach(inst)); uasync_mark_running(ua); 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)); QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch; QTRY_COMPARE(snapshots.size(), 2); auto sources = QJsonDocument::fromJson(snapshots.last().mid(9)).array(); QCOMPARE(sources.size(), 1); QCOMPARE(stream_read_be(reinterpret_cast(snapshots.last().constData()), 8), group); QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); } void staleSelectionAndCancellation() { QVERIFY(gui_stream_open(100, group, inst->node_id, epoch ^ 1)); StreamRecord event; QVERIFY(receive(100, event)); QCOMPARE(event.kind, StreamRecordKind::Closed); QCOMPARE(event.reason, STREAM_CLOSE_SOURCE_CHANGED); QVERIFY(gui_stream_open(101, group, inst->node_id, epoch)); gui_stream_stop(101); QTest::qWait(20); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); } void mediaAndPressure() { QVERIFY(gui_stream_open(102, group, inst->node_id, epoch)); StreamRecord event; QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Opened); const QByteArray frame(1500, 'k'); const QByteArray audio("opus"); publish(frame, {audio}); QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Video); QCOMPARE(event.data, frame); QCOMPARE(event.format.rotation, uint16_t(90)); QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Audio); QCOMPARE(event.data, audio); for (int i = 0; i < 270; i++) { publish(frame); uasync_poll(ua, 0); } unsigned records = 0; QElapsedTimer held; held.start(); while (held.elapsed() < 30) { if (gui_stream_pull(102, event, 100000, false, 0)) { QVERIFY(event.kind != StreamRecordKind::Gap); records++; } QTest::qWait(1); } QVERIFY(records <= 2); /* Накопленные 27 секунд остаются в RS, а не в очереди frontend. */ QVERIFY(receive(102, event, 200000)); QCOMPARE(event.kind, StreamRecordKind::Video); QCOMPARE(event.pts, uint64_t(200000)); QCOMPARE(event.data, frame); gui_stream_stop(102); QTest::qWait(20); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); } void sharedOutputMixesWithoutRenormalizing() { #ifdef TEST_NULL_AUDIO auto* sound = SoundManager::instance(); QVERIFY(sound->init()); std::atomic firstFrames{0}, secondFrames{0}; struct Cleanup { SoundManager* sound; ~Cleanup() { sound->detachStream(90001); sound->detachStream(90002); sound->detachStream(90003); inspectMixedOutput = false; } } cleanup{sound}; inspectMixedOutput = true; QVERIFY(sound->attachStream(90001, [&](float* out, unsigned frames, int64_t, uint64_t) { for (unsigned i = 0; i < frames * 2; ++i) out[i] += 0.125f; firstFrames += frames; })); QTRY_VERIFY(std::abs(mixedMean.load() - 0.125f) < 0.001f); QVERIFY(sound->attachStream(90002, [&](float* out, unsigned frames, int64_t, uint64_t) { for (unsigned i = 0; i < frames * 2; ++i) out[i] += 0.25f; secondFrames += frames; })); QTRY_VERIFY(std::abs(mixedMean.load() - 0.375f) < 0.001f); QVERIFY(sound->attachStream(90003, [](float*, unsigned, int64_t, uint64_t) {})); QTest::qWait(30); QVERIFY(std::abs(mixedMean.load() - 0.375f) < 0.001f); sound->detachStream(90001); unsigned stopped = firstFrames, continuing = secondFrames; QTRY_VERIFY(std::abs(mixedMean.load() - 0.25f) < 0.001f); QTRY_VERIFY(secondFrames.load() > continuing); QCOMPARE(firstFrames.load(), stopped); sound->detachStream(90003); QVERIFY(sound->attachStream(90003, [](float* out, unsigned frames, int64_t, uint64_t) { for (unsigned i = 0; i < frames * 2; ++i) out[i] += 0.9f; })); QTRY_VERIFY(mixedMean.load() > 0.95f); QVERIFY(mixedPeak.load() <= 1.0f); sound->detachStream(90003); QTRY_VERIFY(std::abs(mixedMean.load() - 0.25f) < 0.001f); #else QSKIP("Shared output fixture requires null audio"); #endif } void independentReaders() { QVERIFY(gui_stream_open(201, group, inst->node_id, epoch)); QVERIFY(gui_stream_open(202, group, inst->node_id, epoch)); StreamRecord a, b; QVERIFY(receive(201, a)); QCOMPARE(a.kind, StreamRecordKind::Opened); QVERIFY(receive(202, b)); QCOMPARE(b.kind, StreamRecordKind::Opened); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 3u); QVERIFY(receive(201, a)); QCOMPARE(a.kind, StreamRecordKind::Video); QTest::qWait(30); // Reader 202 ещё не запросил media и не тормозит 201. QVERIFY(receive(202, b)); QCOMPARE(b.kind, StreamRecordKind::Video); QCOMPARE(a.pts, b.pts); QCOMPARE(a.data, b.data); stream_media_status before{}, after{}; QVERIFY(gui_stream_status(202, before)); QVERIFY(gui_stream_resync(201)); QTest::qWait(20); QVERIFY(gui_stream_status(202, after)); QCOMPARE(after.generation, before.generation); QCOMPARE(after.start_us, before.start_us); gui_stream_stop(201); QTest::qWait(20); QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 2u); const QByteArray nextFrame(1500, 'm'); publish(nextFrame); QVERIFY(receive(202, b)); QCOMPARE(b.kind, StreamRecordKind::Video); QCOMPARE(b.data, nextFrame); gui_stream_stop(202); QTest::qWait(20); QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); } void independentCatalogueOwners() { int before = snapshots.size(); QVERIFY(gui_stream_watch(group, 77)); QTRY_VERIFY(snapshots.size() > before); QVERIFY(gui_stream_watch(0)); QTest::qWait(20); // Основной чат перестал наблюдать, окно осталось. before = snapshots.size(); QCOMPARE(stream_publish_finish(publisher), STREAM_OK); QTRY_VERIFY(snapshots.size() > before); QCOMPARE(snapshots.last().mid(9), QByteArray("[]")); QCOMPARE(snapshots.last().at(8), char(1)); stream_close(publisher); publisher = nullptr; QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch; QTRY_VERIFY(snapshots.last().mid(9) != QByteArray("[]")); QVERIFY(gui_stream_watch(0, 77)); QVERIFY(gui_stream_watch(group)); QTest::qWait(20); } void gridKeepsPlayersAndWaitsForSources() { #if defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG) QVERIFY(SoundManager::instance()->init()); StreamGridWindow grid(group); grid.show(); auto snapshot = [&](int count, bool valid = true) { QJsonArray sources; for (int i = 0; i < count; ++i) sources.append(QJsonObject{ {"id", QString::number(inst->node_id ^ static_cast(i))}, {"epoch", QString::number(epoch, 16)}, {"name", QStringLiteral("Source %1").arg(i)}, {"self", 0}}); QByteArray bytes(9, '\0'); stream_write_be(reinterpret_cast(bytes.data()), group, 8); bytes[8] = valid; bytes += QJsonDocument(sources).toJson(QJsonDocument::Compact); grid.onSources(reinterpret_cast(bytes.constData()), bytes.size()); }; snapshot(1); QPointer first = grid.findChild(QStringLiteral("streamPlayer_%1").arg(inst->node_id)); QVERIFY(first); for (int count : {2, 4, 5, 9, 10, 16}) { snapshot(count); QCOMPARE(grid.tileCount(), count); QCOMPARE(grid.gridSide(), static_cast(std::ceil(std::sqrt(count)))); QVERIFY(first); QCOMPARE(grid.findChild(first->objectName()), first.data()); } snapshot(0, false); QCOMPARE(grid.tileCount(), 16); // Недоступность каталога не снимает readers. snapshot(1); QTRY_COMPARE_WITH_TIMEOUT(grid.tileCount(), 1, 3000); QCOMPARE(grid.gridSide(), 1); QVERIFY(first); QCOMPARE(stream_publish_finish(publisher), STREAM_OK); stream_close(publisher); publisher = nullptr; QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch; QTest::mouseClick(first->findChild(), Qt::LeftButton); QTRY_COMPARE(grid.focusedSource(), inst->node_id); snapshot(1); QVERIFY(first.isNull()); QCOMPARE(grid.tileCount(), 1); QCOMPARE(grid.focusedSource(), uint64_t(0)); QVERIFY(!grid.isFullScreen()); snapshot(0); QTRY_COMPARE_WITH_TIMEOUT(grid.tileCount(), 0, 3000); QVERIFY(grid.isVisible()); QCOMPARE(grid.gridSide(), 0); snapshot(1); QCOMPARE(grid.tileCount(), 1); grid.close(); QTRY_VERIFY(stream_get_info(publisher, &info) == STREAM_OK && info.owners == 1); #else QSKIP("Grid lifetime fixture requires null audio and FFmpeg"); #endif } void gridLocalPreviewAndTileNavigation() { #if defined(TEST_NULL_AUDIO) && defined(HAVE_STREAM_CAPTURE) StreamGridWindow grid(group); grid.show(); grid.activateWindow(); const auto own = inst->node_id ^ uint64_t(1); auto snapshot = [&](bool remote) { QJsonArray sources{QJsonObject{{"id", QString::number(own)}, {"epoch", QString::number(epoch, 16)}, {"name", "Own preview"}, {"self", 1}}}; if (remote) sources.append(QJsonObject{{"id", QString::number(inst->node_id)}, {"epoch", QString::number(epoch, 16)}, {"name", "Remote fixture"}, {"self", 0}}); QByteArray bytes(9, '\0'); stream_write_be(reinterpret_cast(bytes.data()), group, 8); bytes[8] = 1; bytes += QJsonDocument(sources).toJson(QJsonDocument::Compact); grid.onSources(reinterpret_cast(bytes.constData()), bytes.size()); }; snapshot(false); auto* ownTile = grid.findChild(QStringLiteral("streamPreview_%1").arg(own)); QVERIFY(ownTile); auto* preview = ownTile->findChild(); QVERIFY(preview); QVERIFY(grid.findChildren().isEmpty()); QVERIFY(ownTile->findChildren().isEmpty()); stream_info info{}; QTest::qWait(30); QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); QVideoFrame frame(QVideoFrameFormat(QSize(16, 16), QVideoFrameFormat::Format_BGRA8888)); QVERIFY(frame.map(QVideoFrame::WriteOnly)); memset(frame.bits(0), 0xff, frame.mappedBytes(0)); frame.unmap(); frame.setStartTime(123000); grid.setLocalPreview(group, QVariant::fromValue(frame)); QCOMPARE(preview->videoSink()->videoFrame().startTime(), qint64(123000)); grid.setLocalPreview(group ^ 1, QVariant()); QVERIFY(preview->videoSink()->videoFrame().isValid()); grid.setLocalPreview(group, QVariant()); QVERIFY(!preview->videoSink()->videoFrame().isValid()); grid.setLocalPreview(group, QVariant::fromValue(frame)); QSignalSpy manage(&grid, &StreamGridWindow::publishRequested); QTest::mouseClick(preview, Qt::LeftButton); QTRY_COMPARE(manage.count(), 1); QCOMPARE(manage.at(0).at(0).toULongLong(), group); QCOMPARE(grid.focusedSource(), uint64_t(0)); QVERIFY(grid.windowHandle()); QTest::mouseClick(grid.windowHandle(), Qt::LeftButton, Qt::NoModifier, preview->mapTo(&grid, preview->rect().center())); QTRY_COMPARE(manage.count(), 2); // Доставка через нативное окно, включая внутренний QWidget поверхности Qt. snapshot(true); QPointer remote = grid.findChild(); QVERIFY(remote); auto* video = remote->findChild(); QVERIFY(video); QTRY_VERIFY(stream_get_info(publisher, &info) == STREAM_OK && info.owners == 2); QTest::mouseClick(video, Qt::LeftButton); QTRY_COMPARE(grid.focusedSource(), inst->node_id); QVERIFY(grid.isFullScreen()); QVERIFY(ownTile->isHidden()); auto* volume = remote->findChild(); QVERIFY(volume); volume->setFocus(); QTest::keyClick(volume, Qt::Key_Escape); QTRY_COMPARE(grid.focusedSource(), uint64_t(0)); QVERIFY(!grid.isFullScreen()); QVERIFY(remote->isVisible()); QVERIFY(ownTile->isVisible()); QCOMPARE(grid.findChild(), remote.data()); QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 2u); QTest::mouseClick(video, Qt::LeftButton); QTRY_COMPARE(grid.focusedSource(), inst->node_id); QTest::mouseClick(video, Qt::LeftButton); QTRY_COMPARE(grid.focusedSource(), uint64_t(0)); video->setFrame(coordinateFrame()); zoomVideo(*video, 12000, video->rect().center()); dragVideo(*video, video->rect().center(), video->rect().center() + QPoint(40, 20)); QTest::qWait(20); QCOMPARE(grid.focusedSource(), uint64_t(0)); // Pan не считается кликом. grid.close(); QVERIFY(remote.isNull()); QTRY_VERIFY(stream_get_info(publisher, &info) == STREAM_OK && info.owners == 1); DEBUG_INFO(DEBUG_CATEGORY_STREAM, "fixture multiview local preview: reader=0 audio=0; click/Esc/drag preserved remote owner"); #else QSKIP("Direct preview fixture requires Qt Multimedia and null audio"); #endif } void boundedBatchesAndNotifications() { QCOMPARE(stream_publish_finish(publisher), STREAM_OK); stream_close(publisher); publisher = nullptr; QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch; mediaPts = frameId = 0; revision = 1; unsigned notifications = 0; struct Readers { ~Readers() { gui_stream_stop(301); gui_stream_stop(302); } } readers; QVERIFY(gui_stream_open(301, group, inst->node_id, epoch, [](void* arg) { ++*static_cast(arg); }, ¬ifications)); QVERIFY(gui_stream_open(302, group, inst->node_id, epoch)); StreamRecord opened; QVERIFY(receive(301, opened)); QCOMPARE(opened.kind, StreamRecordKind::Opened); QVERIFY(receive(302, opened)); QCOMPARE(opened.kind, StreamRecordKind::Opened); const QByteArray frame(1500, 'k'); const QList audio(5, QByteArray("opus")); publish(frame, audio, 1); // Начальный KEY единственный: оба cursor фиксируют PTS=100000. stream_media_status status{}; QTRY_VERIFY(gui_stream_status(301, status) && status.initialized); QTRY_VERIFY(gui_stream_status(302, status) && status.initialized); for (unsigned i = 1; i < 10; i++) { publish(frame, audio, 3); uasync_poll(ua, 0); } auto batch = [&](uint64_t id, unsigned video, int64_t ahead, std::deque& out) { QElapsedTimer elapsed; elapsed.start(); while (elapsed.elapsed() < 1000) { if (gui_stream_pull_batch(id, out, 100000, video, ahead)) return true; QTest::qWait(1); } return false; }; std::deque records; QVERIFY(batch(301, 2, 1000000, records)); QVERIFY(records.size() > 2); QVERIFY(records.size() <= STREAM_VIEWER_BATCH_RECORDS); unsigned videos = 0, audios = 0, batches = 1; uint64_t lastVideo = 0, lastAudio = 0; auto consume = [&] { for (const auto& record : records) { if (record.kind == StreamRecordKind::Video) { uint64_t expected = videos ? 200000 + (videos - 1) / 3 * 100000 + (videos - 1) % 3 * 100000 / 3 : 100000; QCOMPARE(record.pts, expected); QCOMPARE(record.data, frame); lastVideo = record.pts; videos++; } else if (record.kind == StreamRecordKind::Audio) { QCOMPARE(record.pts, uint64_t(100000 + audios * 20000)); lastAudio = record.pts; audios++; } } records.clear(); }; consume(); QCOMPARE(videos, 2u); QVERIFY(audios > 0); unsigned before = notifications; QTest::qWait(20); QCOMPARE(notifications, before); // Видео приостановлено потребителем: звук продолжается, video cursor остаётся на третьем кадре. QVERIFY(batch(301, 0, 1000000, records)); batches++; for (const auto& record : records) QCOMPARE(record.kind, StreamRecordKind::Audio); consume(); QCOMPARE(videos, 2u); QVERIFY(batch(301, 2, -1, records)); batches++; for (const auto& record : records) QCOMPARE(record.kind, StreamRecordKind::Video); consume(); QCOMPARE(videos, 4u); QVERIFY(batch(302, 2, -1, records)); QCOMPARE(records.front().pts, uint64_t(100000)); records.clear(); QCOMPARE(stream_publish_finish(publisher), STREAM_OK); bool ended = false; for (unsigned i = 0; i < 50 && !ended; i++) { QVERIFY(batch(301, 2, 1000000, records)); batches++; QVERIFY(records.size() <= STREAM_VIEWER_BATCH_RECORDS); for (const auto& record : records) { QVERIFY(record.kind != StreamRecordKind::Closed && record.kind != StreamRecordKind::Gap); if (record.kind == StreamRecordKind::End) { QCOMPARE(&record, &records.back()); ended = true; } } consume(); } QVERIFY(ended); QCOMPARE(videos, 28u); QCOMPARE(audios, 50u); QVERIFY(batches < 28); gui_stream_stop(301); before = notifications; QTest::qWait(20); QCOMPARE(notifications, before); DEBUG_INFO(DEBUG_CATEGORY_STREAM, "fixture batches: video=%u audio=%u batches=%u last_video=%llu last_audio=%llu signals=%u independent_readers=1", videos, audios, batches, (unsigned long long)lastVideo, (unsigned long long)lastAudio, notifications); } void realH264RenderingAndClose() { #ifdef HAVE_FFMPEG /* Фикстура протокола выше содержит фиктивный H264; реальный decoder получает отдельную эпоху. */ QCOMPARE(stream_publish_finish(publisher), STREAM_OK); stream_close(publisher); publisher = nullptr; QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); stream_info fresh{}; QCOMPARE(stream_get_info(publisher, &fresh), STREAM_OK); epoch = fresh.descriptor.source_epoch; mediaPts = 0; frameId = 0; revision = 1; const AVCodec* encoder = avcodec_find_encoder(AV_CODEC_ID_H264); if (!encoder) QSKIP("H264 encoder is unavailable for the decoder fixture"); AVCodecContext* context = avcodec_alloc_context3(encoder); QVERIFY(context); context->width = 160; context->height = 90; context->pix_fmt = AV_PIX_FMT_YUV420P; context->time_base = {1, 10}; context->framerate = {10, 1}; context->max_b_frames = 0; AVDictionary* options = nullptr; av_dict_set(&options, "preset", "ultrafast", 0); av_dict_set(&options, "tune", "zerolatency", 0); QCOMPARE(avcodec_open2(context, encoder, &options), 0); av_dict_free(&options); AVFrame* frame = av_frame_alloc(); QVERIFY(frame); frame->width = 160; frame->height = 90; frame->format = context->pix_fmt; QCOMPARE(av_frame_get_buffer(frame, 32), 0); for (int plane = 0; plane < 3; plane++) for (int y = 0; y < (plane ? 45 : 90); y++) memset(frame->data[plane] + y * frame->linesize[plane], plane ? 128 : 80, plane ? 80 : 160); frame->pts = 0; QCOMPARE(avcodec_send_frame(context, frame), 0); AVPacket* packet = av_packet_alloc(); QVERIFY(packet); QCOMPARE(avcodec_receive_packet(context, packet), 0); QByteArray video(4, '\0'); video.append(reinterpret_cast(packet->data), packet->size); av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&context); std::unique_ptr opus(nullptr, opus_codec_encoder_destroy); uint64_t sampleFrame = 0; #ifdef TEST_NULL_AUDIO QVERIFY(SoundManager::instance()->init()); opus.reset(opus_codec_encoder_create(48000, 2)); QVERIFY(opus); QCOMPARE(opus_codec_encoder_application_set(opus.get(), OPUS_CODEC_APP_AUDIO), 0); QCOMPARE(opus_codec_encoder_bitrate_set(opus.get(), 128000), 0); nonzeroSamples = 0; leftEnergy = rightEnergy = 0; playbackDelayUs = 200000; #endif StreamPlayerWindow window(group, inst->node_id, epoch, "Source"); QSignalSpy shown(&window, &StreamPlayerWindow::framePresented); window.show(); auto audioPackets = [&opus, &sampleFrame] { QList packets; if (opus) for (unsigned frame = 0; frame < 5; frame++) { int16_t pcm[1920]; uint8_t encoded[1275]; for (unsigned i = 0; i < 960; i++) { pcm[2 * i] = static_cast(6000 * std::sin((sampleFrame + i) * 0.05)); pcm[2 * i + 1] = static_cast(1500 * std::sin((sampleFrame + i) * 0.13)); } sampleFrame += 960; int length = opus_codec_encode(opus.get(), pcm, 960, encoded, sizeof(encoded)); if (length <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop startup fixture Opus encode failed result=%d", length); return QList{}; } packets.append(QByteArray(reinterpret_cast(encoded), length)); } return packets; }; #ifdef TEST_NULL_AUDIO QTest::qWait(20); for (unsigned batch = 0; batch < 8; batch++) { publish({}, audioPackets(), 0); uasync_poll(ua, 0); } QTest::qWait(200); QCOMPARE(shown.count(), 0); QCOMPARE(nonzeroSamples.load(), uint64_t(0)); #endif QTimer sender; connect(&sender, &QTimer::timeout, this, [this, video, &audioPackets] { publish(video, audioPackets(), 3); }); sender.start(100); QTRY_VERIFY_WITH_TIMEOUT(shown.count() > 0, 5000); QTest::qWait(400); // Пройти старт декодера и физическую задержку тестового устройства. #ifdef TEST_NULL_AUDIO uint64_t gapsBefore = silenceGaps.load(); #endif int beforeSteady = shown.count(); QTest::qWait(1500); QVERIFY2(shown.count() >= beforeSteady + 30, "30fps video must keep advancing with 200ms physical audio latency"); #ifdef TEST_NULL_AUDIO QCOMPARE(silenceGaps.load(), gapsBefore); uint64_t beforePause = nonzeroSamples.load(); unsigned pausesBefore = videoPauses.load(); videoPauseMs = 500; QTest::qWait(650); QCOMPARE(videoPauses.load(), pausesBefore + 1); QCOMPARE(silenceGaps.load(), gapsBefore); QVERIFY(nonzeroSamples.load() > beforePause + 40000); DEBUG_INFO(DEBUG_CATEGORY_STREAM, "[PASS] video stalled 500ms: audio continued samples=%llu new_gaps=0", (unsigned long long)(nonzeroSamples.load() - beforePause)); #endif { StreamPlayerWindow second(group, inst->node_id, epoch, "Second reader"); second.show(); QSignalSpy secondShown(&second, &StreamPlayerWindow::framePresented); int beforeSecond = shown.count(); QTRY_VERIFY_WITH_TIMEOUT(secondShown.count() > 3 && shown.count() > beforeSecond + 3, 5000); stream_info together{}; QCOMPARE(stream_get_info(publisher, &together), STREAM_OK); QCOMPARE(together.owners, 3u); second.close(); QTRY_VERIFY(stream_get_info(publisher, &together) == STREAM_OK && together.owners == 2); beforeSecond = shown.count(); QTRY_VERIFY(shown.count() > beforeSecond + 3); } int beforeSwitch = shown.count(); revision++; rotation = 270; QTRY_VERIFY_WITH_TIMEOUT(shown.count() > beforeSwitch + 2, 5000); stream_info switched{}; QCOMPARE(stream_get_info(publisher, &switched), STREAM_OK); QCOMPARE(switched.descriptor.source_epoch, epoch); QCOMPARE(switched.owners, 2u); #ifdef TEST_NULL_AUDIO QTRY_VERIFY_WITH_TIMEOUT(nonzeroSamples.load() > 0, 5000); QVERIFY(rightEnergy.load() > 0); qInfo("Stereo playback energies: left=%llu right=%llu ratio=%.3f", (unsigned long long)leftEnergy.load(), (unsigned long long)rightEnergy.load(), static_cast(leftEnergy.load()) / rightEnergy.load()); QVERIFY2(leftEnergy.load() > rightEnergy.load() * 8 && leftEnergy.load() < rightEnergy.load() * 25, "Independent stereo channels must reach the playback device without downmix"); auto* volume = window.findChild(); QVERIFY(volume); volume->setValue(0); QTest::qWait(50); uint64_t played = nonzeroSamples.load(); QTest::qWait(100); QCOMPARE(nonzeroSamples.load(), played); #endif sender.stop(); window.close(); QTest::qWait(20); #ifdef TEST_NULL_AUDIO playbackDelayUs = 0; #endif stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); #else QSKIP("FFmpeg is disabled"); #endif } void modernCodecPlayback() { #ifdef HAVE_FFMPEG debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_DEBUG); struct Fixture { uint8_t type; const char* encoder; }; const Fixture fixtures[] = {{STREAM_MEDIA_H265, "libx265"}, {STREAM_MEDIA_VP9, "libvpx-vp9"}, {STREAM_MEDIA_AV1, "libaom-av1"}}; for (const auto& fixture : fixtures) { const AVCodec* encoder = avcodec_find_encoder_by_name(fixture.encoder); QVERIFY2(encoder, fixture.encoder); AVCodecContext* context = avcodec_alloc_context3(encoder); QVERIFY(context); context->width = 160; context->height = 90; context->pix_fmt = AV_PIX_FMT_YUV420P; context->bit_rate = 200000; context->time_base = {1, 30}; context->framerate = {30, 1}; context->max_b_frames = 0; context->thread_count = 1; context->gop_size = 1; context->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; AVDictionary* options = nullptr; if (fixture.type == STREAM_MEDIA_H265) { av_dict_set(&options, "preset", "ultrafast", 0); av_dict_set(&options, "tune", "zerolatency", 0); av_dict_set(&options, "x265-params", "pools=none:frame-threads=1:log-level=error:bframes=0:repeat-headers=1", 0); } else { av_dict_set(&options, "lag-in-frames", "0", 0); av_dict_set(&options, "cpu-used", "8", 0); av_dict_set(&options, fixture.type == STREAM_MEDIA_VP9 ? "deadline" : "usage", "realtime", 0); } QCOMPARE(avcodec_open2(context, encoder, &options), 0); av_dict_free(&options); AVFrame* frame = av_frame_alloc(); QVERIFY(frame); frame->width = 160; frame->height = 90; frame->format = context->pix_fmt; QCOMPARE(av_frame_get_buffer(frame, 32), 0); for (int plane = 0; plane < 3; plane++) for (int y = 0; y < (plane ? 45 : 90); y++) memset(frame->data[plane] + y * frame->linesize[plane], plane ? 128 : 80, plane ? 80 : 160); frame->pts = 0; QCOMPARE(avcodec_send_frame(context, frame), 0); AVPacket* packet = av_packet_alloc(); QVERIFY(packet); int received = avcodec_receive_packet(context, packet); if (received == AVERROR(EAGAIN)) { QCOMPARE(avcodec_send_frame(context, nullptr), 0); received = avcodec_receive_packet(context, packet); } QCOMPARE(received, 0); QVERIFY(packet->flags & AV_PKT_FLAG_KEY); QByteArray video(4, '\0'); stream_write_be(reinterpret_cast(video.data()), context->extradata_size, 4); if (context->extradata_size) video.append(reinterpret_cast(context->extradata), context->extradata_size); video.append(reinterpret_cast(packet->data), packet->size); av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&context); QCOMPARE(stream_publish_finish(publisher), STREAM_OK); stream_close(publisher); publisher = nullptr; QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch; StreamPlayerWindow window(group, inst->node_id, epoch, fixture.encoder); QSignalSpy shown(&window, &StreamPlayerWindow::framePresented); window.show(); stream_media_block media{}; media.format = {160, 90, 0, fixture.type, 0, 1}; media.video_count = 1; media.video[0] = {1, 0, (uint32_t)video.size(), 0, (uint32_t)video.size(), STREAM_MEDIA_KEY, reinterpret_cast(video.constData())}; uint8_t wire[7345]; size_t length = 0; QCOMPARE(stream_media_encode(&media, wire, sizeof(wire), &length), STREAM_OK); QCOMPARE(stream_publish_block(publisher, wire, length), STREAM_OK); QTRY_VERIFY_WITH_TIMEOUT(shown.count() > 0, 2000); QCOMPARE(stream_publish_finish(publisher), STREAM_OK); QTRY_VERIFY_WITH_TIMEOUT(stream_get_info(publisher, &info) == STREAM_OK && info.owners == 1, 2000); window.close(); QTest::qWait(20); } debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_INFO); #endif } void delayedDecoderAndShortStreamDrain() { #if defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG) const AVCodec* encoder = avcodec_find_encoder_by_name("libx264"); QVERIFY(encoder); AVCodecContext* context = avcodec_alloc_context3(encoder); QVERIFY(context); context->width = 160; context->height = 90; context->pix_fmt = AV_PIX_FMT_YUV420P; context->time_base = {1, 25}; context->max_b_frames = 0; AVDictionary* options = nullptr; av_dict_set(&options, "preset", "ultrafast", 0); av_dict_set(&options, "tune", "zerolatency", 0); QCOMPARE(avcodec_open2(context, encoder, &options), 0); av_dict_free(&options); AVFrame* frame = av_frame_alloc(); QVERIFY(frame); frame->width = 160; frame->height = 90; frame->format = context->pix_fmt; QCOMPARE(av_frame_get_buffer(frame, 32), 0); for (int plane = 0; plane < 3; plane++) for (int y = 0; y < (plane ? 45 : 90); y++) memset(frame->data[plane] + y * frame->linesize[plane], plane ? 128 : 80, plane ? 80 : 160); frame->pts = 0; QCOMPARE(avcodec_send_frame(context, frame), 0); AVPacket* packet = av_packet_alloc(); QVERIFY(packet); QCOMPARE(avcodec_receive_packet(context, packet), 0); QByteArray access(reinterpret_cast(packet->data), packet->size); av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&context); QVERIFY(SoundManager::instance()->init()); std::unique_ptr opus(opus_codec_encoder_create(48000, 2), opus_codec_encoder_destroy); QVERIFY(opus); int16_t pcm[1920] = {}; uint8_t encoded[1275]; int audioSize = opus_codec_encode(opus.get(), pcm, 960, encoded, sizeof(encoded)); QVERIFY(audioSize > 0); struct Delay { Delay() { delayedDecoder = true; } ~Delay() { delayedDecoder = false; } } delay; struct Scenario { unsigned video, audio; }; for (auto scenario : {Scenario{3, 0}, Scenario{8, 0}, Scenario{3, 6}, Scenario{8, 16}, Scenario{8, 4}, Scenario{3, 16}}) { unsigned count = scenario.video; bool audio = scenario.audio != 0; DEBUG_INFO(DEBUG_CATEGORY_STREAM, "fixture delayed decoder frames=%u audio_packets=%u", count, scenario.audio); QCOMPARE(stream_publish_finish(publisher), STREAM_OK); stream_close(publisher); publisher = nullptr; QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch; StreamPlayerWindow window(group, inst->node_id, epoch, "Delayed decoder"); window.show(); QSignalSpy shown(&window, &StreamPlayerWindow::framePresented); for (unsigned i = 0; i < std::max(count, scenario.audio / 2); i++) { QByteArray data = i ? access : QByteArray(4, '\0') + access; stream_media_block media{}; media.format = {160, 90, 0, STREAM_MEDIA_H264, (uint8_t)(audio ? STREAM_MEDIA_OPUS_STEREO : 0), 1}; media.video_count = i < count ? 1 : 0; media.video[0] = {i + 1, i * 40000u, (uint32_t)data.size(), 0, (uint32_t)data.size(), (uint8_t)(i ? 0 : STREAM_MEDIA_KEY), reinterpret_cast(data.constData())}; if (i * 2 < scenario.audio) { media.audio_count = 2; for (unsigned j = 0; j < 2; j++) media.audio[j] = {i * 40000u + j * 20000u, (uint32_t)audioSize, 960, encoded}; } uint8_t wire[7345]; size_t length = 0; QCOMPARE(stream_media_encode(&media, wire, sizeof(wire), &length), STREAM_OK); QCOMPARE(stream_publish_block(publisher, wire, length), STREAM_OK); QTest::qWait(i ? 1 : 30); } QTest::qWait(100); if (count == 3) QCOMPARE(shown.count(), 0); else QTRY_VERIFY_WITH_TIMEOUT(shown.count() > 0, 1500); QCOMPARE(stream_publish_finish(publisher), STREAM_OK); QTRY_COMPARE_WITH_TIMEOUT(shown.count(), (int)count, 2000); QTRY_VERIFY_WITH_TIMEOUT(window.finished(), 2000); QTRY_VERIFY_WITH_TIMEOUT(stream_get_info(publisher, &info) == STREAM_OK && info.owners == 1, 2000); window.close(); QTest::qWait(10); } #else QSKIP("Delayed FFmpeg decoder fixture requires Linux and FFmpeg"); #endif } void finishAndDetachPendingOwner() { QCOMPARE(stream_publish_finish(publisher), STREAM_OK); QTRY_COMPARE(snapshots.last().mid(9), QByteArray("[]")); stream_close(publisher); publisher = nullptr; QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QVERIFY(info.descriptor.source_epoch != epoch); QVERIFY(gui_stream_open(103, group, inst->node_id, epoch)); StreamRecord event; QVERIFY(receive(103, event)); QCOMPARE(event.reason, STREAM_CLOSE_SOURCE_CHANGED); QVERIFY(gui_stream_open(104, group, inst->node_id, info.descriptor.source_epoch)); gui_stream_detach(inst); QVERIFY(!gui_stream_ready()); QTest::qWait(20); QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); } void channelClosureNotifiesViewer() { QVERIFY(gui_stream_attach(inst)); QVERIFY(gui_stream_watch(group)); stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QVERIFY(gui_stream_open(105, group, inst->node_id, info.descriptor.source_epoch)); StreamRecord event; QVERIFY(receive(105, event)); QCOMPARE(event.kind, StreamRecordKind::Opened); #ifdef HAVE_FFMPEG StreamPlayerWindow window(group, inst->node_id, info.descriptor.source_epoch, "Closing channel"); QTRY_VERIFY(stream_get_info(publisher, &info) == STREAM_OK && info.owners == 3); #endif QByteArray channel = QByteArray::number(group); chat_core_delete_channel(inst, channel.constData()); QVERIFY(receive(105, event)); QCOMPARE(event.kind, StreamRecordKind::Closed); QCOMPARE(event.reason, STREAM_CLOSE_GROUP); #ifdef HAVE_FFMPEG QTRY_VERIFY(window.finished()); QVERIFY(!window.failed()); // Закрытый канал не запускает бесконечный retry. #endif QTRY_COMPARE(snapshots.last().mid(9), QByteArray("[]")); QVERIFY(!stream_handle_active(publisher)); QCOMPARE(stream_handle_reason(publisher), STREAM_CLOSE_GROUP); } void cleanupTestCase() { poll.stop(); gui_stream_detach(inst); stream_close(publisher); uasync_mark_stopped(ua); SoundManager::instance()->shutdown(); utun_instance_destroy(inst); uasync_destroy(ua, 0); QCOMPARE(u_get_allocated_count(), allocations); } }; QTEST_MAIN(StreamViewTest) #include "test_stream_view.moc"