/* Настоящие FFmpeg/Opus/uAV2/RS и ядро. X11 capture дополнительно запускается под Xvfb. * Null miniaudio подаёт два разных тона вместо физического микрофона, не подменяя encoder/публикацию. */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../src/live_video_encoder.h" #include "../src/stream_publish_engine.h" #include "../src/stream_publish_window.h" #include "../src/sound_manager.h" #include "../transport/stream_bridge.h" extern "C" { #include "utun_instance.h" #include "u_async.h" #include "chat/chat_core.h" #include "stream/stream.h" #include "stream/stream_member.h" #include "debug_config.h" #include "mem.h" #include "sqlite3.h" #include "opus_codec.h" #ifdef HAVE_FFMPEG #include #include #endif } #ifdef HAVE_STREAM_CAPTURE #include #include #include #include #endif #ifdef UTUN_STREAM_X11_CURSOR #include "../src/stream_screen_cursor.h" #include #include #undef None #undef Status #endif #ifdef TEST_NULL_AUDIO static ma_device_data_proc captureCallback; static std::atomic failAudioEncoding{false}; static std::atomic suppressMicrophone{false}, silentMicrophone{false}, slowCaptureClose{false}; static std::atomic captureOpens{0}, audioEncodes{0}; #ifdef HAVE_FFMPEG static std::atomic softwareOpens{0}, hardwareOpens{0}; static std::atomic failHardware{false}; extern "C" int __real_avcodec_open2(AVCodecContext*, const AVCodec*, AVDictionary**); extern "C" int __wrap_avcodec_open2(AVCodecContext* context, const AVCodec* codec, AVDictionary** options) { if (av_codec_is_encoder(codec)) { QByteArray name(codec->name); bool hardware = name.endsWith("_nvenc") || name.endsWith("_qsv") || name.endsWith("_amf") || name.endsWith("_vaapi"); if (hardware) { hardwareOpens++; if (failHardware) return AVERROR(ENODEV); } else softwareOpens++; } return __real_avcodec_open2(context, codec, options); } #endif extern "C" int __real_opus_codec_encode(opus_codec_encoder_t*, const int16_t*, int, uint8_t*, int); extern "C" int __wrap_opus_codec_encode(opus_codec_encoder_t* encoder, const int16_t* pcm, int frames, uint8_t* data, int capacity) { audioEncodes++; return failAudioEncoding.exchange(false) ? -1 : __real_opus_codec_encode(encoder, pcm, frames, data, capacity); } 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 injectMicrophone(ma_device* device, void* out, const void*, ma_uint32 frames) { if (suppressMicrophone) return; static uint64_t position = 0; std::vector pcm(frames * device->capture.channels); for (unsigned i = 0; i < frames; i++, position++) { pcm[i * device->capture.channels] = (int16_t)(1200 * std::sin(2 * 3.141592653589793 * 440 * position / 48000)); if (device->capture.channels == 2) pcm[i * 2 + 1] = (int16_t)(3600 * std::sin(2 * 3.141592653589793 * 880 * position / 48000)); } if (silentMicrophone) std::fill(pcm.begin(), pcm.end(), 0); captureCallback(device, out, pcm.data(), frames); } extern "C" ma_result __wrap_ma_device_init(ma_context* context, const ma_device_config* config, ma_device* device) { auto copy = *config; if (copy.deviceType == ma_device_type_capture && copy.capture.format == ma_format_s16) { captureOpens++; captureCallback = copy.dataCallback; copy.dataCallback = injectMicrophone; } return __real_ma_device_init(context, ©, device); } extern "C" void __real_ma_device_uninit(ma_device*); extern "C" void __wrap_ma_device_uninit(ma_device* device) { if (device->type == ma_device_type_capture && slowCaptureClose) std::this_thread::sleep_for(std::chrono::milliseconds(700)); __real_ma_device_uninit(device); } #endif class StreamPublishTest : public QObject { Q_OBJECT UASYNC* ua = nullptr; UTUN_INSTANCE* inst = nullptr; QTimer poll; uint64_t group = 0; size_t allocations = 0; static QByteArray keyframe(int codec, int width = 160, int height = 90, uint64_t pts = 100000) { #ifndef HAVE_FFMPEG Q_UNUSED(codec); Q_UNUSED(width); Q_UNUSED(height); Q_UNUSED(pts); return QByteArray(4, '\0') + "opaque access unit"; #else LiveVideoEncoder encoder; LiveVideoConfig config; config.width = width; config.height = height; config.codec = codec; if (!encoder.open(config)) return {}; QImage image(width, height, QImage::Format_RGB32); image.fill(QColor(45, 130, 220)); QList output; if (!encoder.encode(image, pts, true, output) || output.isEmpty() || !output.front().key) return {}; return output.front().data; #endif } bool record(uint64_t id, StreamRecordKind kind, StreamRecord& out) { QElapsedTimer elapsed; elapsed.start(); while (elapsed.elapsed() < 2500) { if (gui_stream_pull(id, out, 0, true, 60000000) && out.kind == kind) return true; QTest::qWait(1); } return false; } bool live(uint64_t id) { stream_publisher_status status; return gui_stream_publish_status(id, status) && status.state == STREAM_PUBLISHER_LIVE && status.epoch; } bool ended(uint64_t id) { stream_publisher_status status; return gui_stream_publish_status(id, status) && status.state == STREAM_PUBLISHER_ENDED; } private slots: void initTestCase() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_DEBUG); debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_VIDEO, DEBUG_LEVEL_DEBUG); if (qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) { qunsetenv("WAYLAND_DISPLAY"); qputenv("XDG_SESSION_TYPE", "x11"); QLoggingCategory::setFilterRules("qt.multimedia.*=true"); } 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, "Desktop publisher 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((const char*)sqlite3_column_text(statement, 0)).toULongLong(); sqlite3_finalize(statement); QVERIFY(group); QVERIFY(gui_stream_attach(inst)); uasync_mark_running(ua); connect(&poll, &QTimer::timeout, this, [this] { uasync_poll(ua, 0); }); poll.start(1); } void captureSettingsSurviveReopening() { QString saved; QVariantList selectedMode; { StreamPublishEngine engine; StreamPublishWindow controls(&engine, group); connect(&controls, &StreamPublishWindow::settingsChanged, this, [&saved](const QString& value) { saved = value; }); auto boxes = controls.findChildren(); QCOMPARE(boxes.size(), 5); if (StreamPublishEngine::supported()) QTRY_VERIFY_WITH_TIMEOUT(boxes[2]->count() > 0, 15000); auto sources = StreamPublishEngine::sources(); int screen = -1; for (int i = 0; i < sources.size(); i++) if (sources[i].kind == StreamVideoSource::Screen && !sources[i].size.isEmpty()) { screen = i; break; } if (screen < 0) QSKIP("No enumerated screen for capture settings"); boxes[0]->setCurrentIndex(screen); auto* cursor = controls.findChild("streamShowCursor"); QVERIFY(cursor); QVERIFY(cursor->isChecked()); cursor->setChecked(false); QSize maximum = stream_video_screen_size(sources[screen].size); QCOMPARE(boxes[1]->currentData().toList(), (QVariantList{maximum.width(), maximum.height(), 30})); QVERIFY(boxes[1]->count() >= 6); boxes[1]->setCurrentIndex(boxes[1]->count() - 1); // Меньший размер сохраняется при повторном открытии. selectedMode = boxes[1]->currentData().toList(); boxes[3]->setCurrentIndex(boxes[3]->findData(4000000)); controls.findChild()->button(270)->click(); controls.findChildren().first()->setChecked(false); if (boxes[2]->count() > 1) boxes[2]->setCurrentIndex(boxes[2]->count() - 1); QVERIFY(!saved.isEmpty()); } const auto stored = QJsonDocument::fromJson(saved.toUtf8()).object(); QCOMPARE(stored.value("width").toInt(), selectedMode[0].toInt()); QCOMPARE(stored.value("fps").toInt(), 60); QVERIFY(!stored.value("native_size").toBool()); QCOMPARE(stored.value("bitrate").toInt(), 4000000); QCOMPARE(stored.value("rotation").toInt(), 270); QVERIFY(!stored.value("audio").toBool()); QVERIFY(!stored.value("show_cursor").toBool(true)); StreamPublishEngine reopenedEngine; StreamPublishWindow reopened(&reopenedEngine, group, nullptr, saved); QSignalSpy changes(&reopened, &StreamPublishWindow::settingsChanged); if (StreamPublishEngine::supported()) QTRY_VERIFY_WITH_TIMEOUT(!reopenedEngine.codecs().isEmpty(), 15000); auto boxes = reopened.findChildren(); QCOMPARE(boxes[1]->currentData().toList(), selectedMode); QCOMPARE(boxes[3]->currentData().toInt(), 4000000); if (StreamPublishEngine::supported()) QTRY_COMPARE(boxes[2]->currentData().toInt(), stored.value("codec").toInt()); QCOMPARE(reopened.findChild()->checkedId(), 270); QVERIFY(!reopened.findChildren().first()->isChecked()); QVERIFY(!reopened.findChild("streamShowCursor")->isChecked()); QVERIFY(changes.isEmpty()); // Асинхронный список кодеков не перезаписывает сохранённый выбор. boxes[3]->setCurrentIndex(0); QCOMPARE(changes.size(), 1); auto automatic = changes.takeFirst().front().toString(); QCOMPARE(QJsonDocument::fromJson(automatic.toUtf8()).object().value("bitrate").toInt(-1), 0); StreamPublishWindow restoredAuto(&reopenedEngine, group, nullptr, automatic); QCOMPARE(restoredAuto.findChildren()[3]->currentIndex(), 0); } void proportionalScreenModes_data() { QTest::addColumn("source"); QTest::addColumn("maximum"); QTest::addColumn("reduced"); QTest::newRow("full-hd") << QSize(1920, 1080) << QSize(1920, 1080) << QSize(1280, 720); QTest::newRow("16-10") << QSize(1920, 1200) << QSize(1920, 1200) << QSize(1152, 720); QTest::newRow("ultrawide") << QSize(3440, 1440) << QSize(3440, 1440) << QSize(1720, 720); QTest::newRow("limited") << QSize(5120, 1440) << QSize(4096, 1152) << QSize(2560, 720); QTest::newRow("retina") << QSize(5120, 2880) << QSize(4096, 2304) << QSize(1280, 720); QTest::newRow("portrait") << QSize(1440, 5120) << QSize(1152, 4096) << QSize(720, 2560); QTest::newRow("odd") << QSize(1365, 767) << QSize(1364, 766) << QSize(1280, 720); } void proportionalScreenModes() { QFETCH(QSize, source); QFETCH(QSize, maximum); QFETCH(QSize, reduced); auto modes = stream_video_screen_modes(source); QVERIFY(!modes.isEmpty()); QCOMPARE(modes.front().size, maximum); QVERIFY(modes.contains({reduced, 30})); QList seen; for (const auto& mode : modes) { QVERIFY(mode.size.width() <= 4096 && mode.size.height() <= 4096); QVERIFY(mode.size.width() <= source.width() && mode.size.height() <= source.height()); QVERIFY(!(mode.size.width() % 2) && !(mode.size.height() % 2)); double scale = std::min(double(mode.size.width()) / source.width(), double(mode.size.height()) / source.height()); QVERIFY(std::abs(mode.size.width() - source.width() * scale) < 8); QVERIFY(std::abs(mode.size.height() - source.height() * scale) < 8); QVERIFY(!seen.contains(mode)); seen.append(mode); } int selected = stream_video_select_mode(modes, {reduced, 60}, true); QCOMPARE(modes[selected].size, maximum); QCOMPARE(modes[selected].fps, 60); selected = stream_video_select_mode(modes, {reduced, 60}, false); QCOMPARE(modes[selected].size, reduced); QCOMPARE(modes[selected].fps, 60); } void cameraAdvertisedModes() { QList modes; stream_video_add_camera_modes(modes, {1920, 1080}, 29.97, 29.97); QCOMPARE(modes.size(), 1); QCOMPARE(modes.front().fps, 30); stream_video_add_camera_modes(modes, {1920, 1080}, 29.97, 29.97); QCOMPARE(modes.size(), 1); stream_video_add_camera_modes(modes, {1280, 720}, 15, 60); QVERIFY(modes.contains({{1280, 720}, 15})); QVERIFY(modes.contains({{1280, 720}, 60})); QVERIFY(!modes.contains({{1920, 1080}, 60})); QVERIFY(!modes.contains({{1920, 1080}, 15})); stream_video_add_camera_modes(modes, {640, 480}, 7, 7); QVERIFY(modes.contains({{640, 480}, 7})); int count = modes.size(); stream_video_add_camera_modes(modes, {5120, 2880}, 30, 30); stream_video_add_camera_modes(modes, {1281, 720}, 30, 30); stream_video_add_camera_modes(modes, {1280, 720}, 120, 120); stream_video_add_camera_modes(modes, {1280, 720}, 60, 30); QCOMPARE(modes.size(), count); QVERIFY(!stream_video_camera_supports({640, 480}, 30, 30, {{1280, 720}, 30})); } void screenSelectionAndRefresh() { StreamPublishEngine engine; StreamPublishWindow controls(&engine, group); auto* sourceBox = controls.findChild("streamVideoSource"); auto* modeBox = controls.findChild("streamVideoMode"); auto sources = StreamPublishEngine::sources(); QSignalSpy changes(&controls, &StreamPublishWindow::settingsChanged); QList screenIds; unsigned screens = 0; for (int i = 0; i < sources.size(); i++) { if (sources[i].kind != StreamVideoSource::Screen || sources[i].size.isEmpty()) continue; QVERIFY(!screenIds.contains(sources[i].id)); screenIds.append(sources[i].id); screens++; sourceBox->setCurrentIndex(-1); changes.clear(); sourceBox->setCurrentIndex(i); QSize size = stream_video_screen_size(sources[i].size); auto mode = modeBox->currentData().toList(); QCOMPARE(mode[0].toInt(), size.width()); QCOMPARE(mode[1].toInt(), size.height()); QVERIFY(modeBox->currentData(Qt::UserRole + 1).toBool()); QCOMPARE(changes.size(), 1); QVERIFY(sourceBox->currentText().contains(sources[i].name)); modeBox->setCurrentIndex(modeBox->count() - 1); mode = modeBox->currentData().toList(); changes.clear(); emit engine.sourcesChanged(); QCOMPARE(modeBox->currentData().toList(), mode); QVERIFY(changes.isEmpty()); } if (!screens) QSKIP("No enumerated screens"); } void nativeScreenCapture_data() { QTest::addColumn("codec"); QTest::newRow("h264") << int(STREAM_MEDIA_H264); QTest::newRow("h265") << int(STREAM_MEDIA_H265); } void nativeScreenCapture() { #ifdef HAVE_STREAM_CAPTURE if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run with X11 screen capture enabled"); QFETCH(int, codec); StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.codecs().isEmpty(), 15000); if (!engine.codecs().contains(codec)) QSKIP("Requested encoder unavailable"); connect(&engine, &StreamPublishEngine::softwareEncodingRequested, &engine, [&engine](uint64_t generation) { engine.answerSoftwareEncoding(generation, true); }); QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); StreamPublishConfig config; config.audio = false; config.nativeSize = true; config.automaticBitrate = true; config.video.width = 640; config.video.height = 360; config.video.fps = 15; config.video.codec = codec; uint64_t epoch = 0; unsigned captured = 0; for (const auto& source : engine.sources()) { if (source.kind != StreamVideoSource::Screen || source.size.isEmpty()) continue; QSize expected = stream_video_screen_size(source.size); config.source = source; config.source.size = {}; // Как portal: размер станет известен только из первого кадра. if (!engine.active()) { QVERIFY(engine.start(group, config)); stream_member_state member{}; QTRY_VERIFY_WITH_TIMEOUT(stream_member_load(inst, group, inst->node_id, &member, nullptr) == STREAM_OK && member.published, 5000); epoch = member.source.source_epoch; QVERIFY(gui_stream_open(204, group, inst->node_id, epoch)); StreamRecord opened; QVERIFY(record(204, StreamRecordKind::Opened, opened)); } else QVERIFY(engine.reconfigure(config)); bool matched = false; StreamRecord frame; QElapsedTimer elapsed; elapsed.start(); while (!matched && elapsed.elapsed() < 8000) { if (gui_stream_pull(204, frame, 0, true, 60000000) && frame.kind == StreamRecordKind::Video) matched = frame.format.width == expected.width() && frame.format.height == expected.height(); QTest::qWait(1); } QVERIFY(matched); QCOMPARE(engine.config().source.id, source.id); QCOMPARE(engine.config().source.size, source.size); QCOMPARE(engine.config().video.width, expected.width()); QCOMPARE(engine.config().video.height, expected.height()); QVERIFY(errors.isEmpty()); captured++; uint64_t previous = frame.pts; unsigned following = 0; elapsed.restart(); while (following < 8 && elapsed.elapsed() < 12000) { if (gui_stream_pull(204, frame, 0, true, 60000000) && frame.kind == StreamRecordKind::Video) { QVERIFY(frame.pts > previous); previous = frame.pts; following++; } QTest::qWait(1); } QCOMPARE(following, 8u); // Проверяем продолжение видео, а не только появление первого KEY. } if (!captured) QSKIP("No enumerated screens"); engine.stop(); QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); gui_stream_stop(204); #else QSKIP("Screen capture unavailable"); #endif } void screenCursorOverlay() { #ifdef UTUN_STREAM_X11_CURSOR if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run under isolated Xvfb"); auto* native = qGuiApp->nativeInterface(); QVERIFY(native); Display* display = native->display(); for (auto* screen : QGuiApplication::screens()) { QWindow target(screen); target.setFlags(Qt::FramelessWindowHint | Qt::BypassWindowManagerHint); target.setGeometry(QRect(screen->geometry().topLeft() + QPoint(20, 20), QSize(200, 160))); target.show(); QVERIFY(QTest::qWaitForWindowExposed(&target)); char bits[32]; memset(bits, 0xff, sizeof(bits)); Pixmap bitmap = XCreateBitmapFromData(display, target.winId(), bits, 16, 16); QVERIFY(bitmap); auto releaseBitmap = qScopeGuard([&] { XFreePixmap(display, bitmap); }); XColor foreground{}, background{}; foreground.red = foreground.blue = 65535; Cursor cursor = XCreatePixmapCursor(display, bitmap, bitmap, &foreground, &background, 3, 4); QVERIFY(cursor); auto releaseCursor = qScopeGuard([&] { XFreeCursor(display, cursor); }); XDefineCursor(display, target.winId(), cursor); XWarpPointer(display, 0, target.winId(), 0, 0, 0, 0, 40, 50); XSync(display, false); QPoint position(qRound(20 * screen->devicePixelRatio()) + 40, qRound(20 * screen->devicePixelRatio()) + 50); StreamScreenCursor overlay(screen); QImage frame(screen->size() * screen->devicePixelRatio(), QImage::Format_RGB32); frame.setDevicePixelRatio(screen->devicePixelRatio()); frame.fill(Qt::black); QVERIFY(overlay.draw(frame)); QImage expected(frame.size(), QImage::Format_RGB32); expected.fill(Qt::black); { QPainter painter(&expected); painter.fillRect(QRect(position - QPoint(3, 4), QSize(16, 16)), Qt::magenta); } QCOMPARE(frame, expected); // Включая hotspot, 64-битный Xlib и физические пиксели HiDPI. for (auto* other : QGuiApplication::screens()) { if (other == screen) continue; StreamScreenCursor otherOverlay(other); QImage otherFrame(other->size() * other->devicePixelRatio(), QImage::Format_RGB32); otherFrame.fill(Qt::black); QImage blank = otherFrame; QVERIFY(otherOverlay.draw(otherFrame)); QCOMPARE(otherFrame, blank); } StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.codecs().isEmpty(), 15000); StreamPublishConfig config; config.audio = false; for (const auto& source : engine.sources()) if (source.kind == StreamVideoSource::Screen && source.screen == screen) config.source = source; QVERIFY(config.source.screen); QVERIFY(config.showCursor); config.video.width = 320; config.video.height = 180; QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); QVERIFY(engine.start(group, config)); auto cursorShown = [&] { QImage preview = engine.previewFrame().value().toImage(); return !preview.isNull() && preview.pixelColor(position) == QColor(Qt::magenta); }; QTRY_VERIFY_WITH_TIMEOUT(cursorShown(), 5000); StreamPublishWindow controls(&engine, group); auto* toggle = controls.findChild("streamShowCursor"); QVERIFY(toggle); QVERIFY(toggle->isChecked()); toggle->setChecked(false); QVERIFY(!engine.config().showCursor); QTRY_VERIFY_WITH_TIMEOUT(!cursorShown(), 5000); toggle->setChecked(true); QVERIFY(engine.config().showCursor); QTRY_VERIFY_WITH_TIMEOUT(cursorShown(), 5000); QVERIFY(errors.isEmpty()); engine.stop(); QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); } #else QSKIP("X11 cursor unavailable"); #endif } void broadcastAllowsRadioMicrophone() { AudioCaptureLease call, radio(AudioCaptureLease::Mode::Radio), recorder, publisher(AudioCaptureLease::Mode::Exclusive); QVERIFY(call.acquire()); QVERIFY(radio.acquire()); QVERIFY(recorder.acquire()); QVERIFY(!publisher.acquire()); call.release(); radio.release(); QVERIFY(!publisher.acquire()); recorder.release(); QVERIFY(publisher.acquire()); QVERIFY(!call.acquire()); QVERIFY(radio.acquire()); QVERIFY(!recorder.acquire()); QVERIFY(publisher.acquire()); publisher.release(); QVERIFY(call.acquire()); } void encoderRoundTrip_data() { QTest::addColumn("codec"); auto codecs = LiveVideoEncoder::available(); if (codecs.isEmpty()) QTest::newRow("unavailable") << 0; for (int codec : codecs) QTest::newRow(qPrintable(LiveVideoEncoder::codecName(codec))) << codec; } void encoderRoundTrip() { #ifdef HAVE_FFMPEG QFETCH(int, codec); if (!codec) QSKIP("No supported encoder"); auto encoded = keyframe(codec); QVERIFY(!encoded.isEmpty()); const uint8_t *config = nullptr, *frame = nullptr; size_t configLength = 0, frameLength = 0; QCOMPARE(stream_media_key_parse((const uint8_t*)encoded.constData(), encoded.size(), &config, &configLength, &frame, &frameLength), STREAM_OK); const char* names[] = {"", "h264", "hevc", "vp9", "av1", "vvc"}; const AVCodecDescriptor* descriptor = avcodec_descriptor_get_by_name(names[codec]); QVERIFY(descriptor); const AVCodec* decoder = avcodec_find_decoder(descriptor->id); QVERIFY(decoder); AVCodecContext* context = avcodec_alloc_context3(decoder); QVERIFY(context); if (configLength) { context->extradata = (uint8_t*)av_mallocz(configLength + AV_INPUT_BUFFER_PADDING_SIZE); QVERIFY(context->extradata); memcpy(context->extradata, config, configLength); context->extradata_size = (int)configLength; } QCOMPARE(avcodec_open2(context, decoder, nullptr), 0); AVPacket* packet = av_packet_alloc(); AVFrame* decoded = av_frame_alloc(); QVERIFY(packet && decoded); QCOMPARE(av_new_packet(packet, (int)frameLength), 0); memcpy(packet->data, frame, frameLength); QCOMPARE(avcodec_send_packet(context, packet), 0); QCOMPARE(avcodec_receive_frame(context, decoded), 0); QCOMPARE(decoded->width, 160); QCOMPARE(decoded->height, 90); av_frame_free(&decoded); av_packet_free(&packet); avcodec_free_context(&context); #else QSKIP("FFmpeg unavailable"); #endif } void publishRevisionAndFinish() { uint64_t id = 101, viewer = 201; stream_media_format format{160, 90, 0, STREAM_MEDIA_H264, 0, 1}; QVERIFY(gui_stream_publish_open(id, group, format)); QTRY_VERIFY(live(id)); stream_publisher_status status; QVERIFY(gui_stream_publish_status(id, status)); uint64_t epoch = status.epoch; QVERIFY(gui_stream_open(viewer, group, inst->node_id, epoch)); StreamRecord out; QVERIFY(record(viewer, StreamRecordKind::Opened, out)); auto first = keyframe(STREAM_MEDIA_H264); QVERIFY(!first.isEmpty()); QCOMPARE(gui_stream_publish_feed(id, true, 100000, true, first, &format), 1); QVERIFY(record(viewer, StreamRecordKind::Video, out)); QCOMPARE(out.format.revision, 1u); QCOMPARE(out.data, first); format.width = 128; format.height = 80; format.rotation = 270; format.revision = 2; QCOMPARE(gui_stream_publish_feed(id, true, 200000, false, QByteArray("P"), &format), 0); auto second = keyframe(STREAM_MEDIA_H264, 128, 80, 200000); QVERIFY(!second.isEmpty()); QCOMPARE(gui_stream_publish_feed(id, true, 200000, true, second, &format), 1); QVERIFY(record(viewer, StreamRecordKind::Video, out)); QCOMPARE(out.format.revision, 2u); QCOMPARE(out.format.rotation, (uint16_t)270); QVERIFY(gui_stream_publish_status(id, status)); QCOMPARE(status.epoch, epoch); gui_stream_publish_stop(id); QVERIFY(record(viewer, StreamRecordKind::End, out)); QTRY_VERIFY_WITH_TIMEOUT(ended(id), 4000); QCOMPARE(gui_stream_publish_feed(id, true, 300000, true, second, &format), -1); gui_stream_stop(viewer); } void audioToggleKeepsEpochAndTrack() { uint64_t id = 104; stream_media_format format{160, 90, 0, STREAM_MEDIA_H264, STREAM_MEDIA_OPUS_STEREO, 1}; QVERIFY(gui_stream_publish_open(id, group, format)); QTRY_VERIFY(live(id)); stream_publisher_status status{}; QVERIFY(gui_stream_publish_status(id, status)); QVERIFY(status.audio_enabled); uint64_t epoch = status.epoch; stream_member_state member{}; QCOMPARE(stream_member_load(inst, group, inst->node_id, &member, nullptr), STREAM_OK); QVERIFY(member.audio_enabled); QVERIFY(gui_stream_publish_audio(id, false)); QTRY_VERIFY(gui_stream_publish_status(id, status) && !status.audio_enabled); QCOMPARE(status.epoch, epoch); QCOMPARE(stream_member_load(inst, group, inst->node_id, &member, nullptr), STREAM_OK); QVERIFY(!member.audio_enabled); QVERIFY(!gui_stream_publish_audio(id + 1, true)); QVERIFY(gui_stream_publish_audio(id, true)); QTRY_VERIFY(gui_stream_publish_status(id, status) && status.audio_enabled); QCOMPARE(status.epoch, epoch); auto* encoder = opus_codec_encoder_create(48000, 2); QVERIFY(encoder); int16_t silence[1920]{}; uint8_t packet[STREAM_MEDIA_MAX_OPUS]; int size = opus_codec_encode(encoder, silence, 960, packet, sizeof(packet)); QVERIFY(size > 0); opus_codec_encoder_destroy(encoder); QCOMPARE(gui_stream_publish_feed(id, false, 100000, false, QByteArray(reinterpret_cast(packet), size)), 1); gui_stream_publish_stop(id); QVERIFY(!gui_stream_publish_audio(id, false)); QTRY_VERIFY_WITH_TIMEOUT(ended(id), 4000); format.audio_codec = 0; QVERIFY(gui_stream_publish_open(++id, group, format)); QTRY_VERIFY(live(id)); QVERIFY(!gui_stream_publish_audio(id, true)); gui_stream_publish_stop(id); QTRY_VERIFY_WITH_TIMEOUT(ended(id), 4000); } void liveSoftwareBitrateKeepsDecoder() { #ifdef HAVE_FFMPEG LiveVideoEncoder encoder; LiveVideoConfig config; config.width = config.height = 16; QVERIFY(encoder.open(config)); if (encoder.name() != "libx264") QSKIP("The available hardware encoder uses video-only replacement for bitrate changes"); QImage image(16, 16, QImage::Format_RGB32); image.fill(Qt::red); QList output; QVERIFY(encoder.encode(image, 100000, true, output)); QVERIFY(!output.isEmpty() && output.front().key); const uint8_t *configuration, *frame; size_t configLength, frameLength; auto first = output.front().data; QCOMPARE(stream_media_key_parse((const uint8_t*)first.constData(), first.size(), &configuration, &configLength, &frame, &frameLength), STREAM_OK); auto* decoder = avcodec_alloc_context3(avcodec_find_decoder(AV_CODEC_ID_H264)); QVERIFY(decoder); decoder->extradata = (uint8_t*)av_mallocz(configLength + AV_INPUT_BUFFER_PADDING_SIZE); QVERIFY(decoder->extradata); memcpy(decoder->extradata, configuration, configLength); decoder->extradata_size = (int)configLength; QCOMPARE(avcodec_open2(decoder, decoder->codec, nullptr), 0); AVPacket* packet = av_packet_alloc(); AVFrame* decoded = av_frame_alloc(); QVERIFY(packet && decoded); QCOMPARE(av_new_packet(packet, (int)frameLength), 0); memcpy(packet->data, frame, frameLength); QCOMPARE(avcodec_send_packet(decoder, packet), 0); QCOMPARE(avcodec_receive_frame(decoder, decoded), 0); av_packet_unref(packet); av_frame_unref(decoded); QVERIFY(encoder.setBitrate(500000)); image.fill(Qt::blue); output.clear(); QVERIFY(encoder.encode(image, 133333, false, output)); QVERIFY(!output.isEmpty() && !output.front().key); QCOMPARE(av_new_packet(packet, output.front().data.size()), 0); memcpy(packet->data, output.front().data.constData(), packet->size); QCOMPARE(avcodec_send_packet(decoder, packet), 0); QCOMPARE(avcodec_receive_frame(decoder, decoded), 0); QCOMPARE(decoded->width, 16); QCOMPARE(decoded->height, 16); av_packet_free(&packet); av_frame_free(&decoded); avcodec_free_context(&decoder); #else QSKIP("FFmpeg unavailable"); #endif } void overloadRequiresKey() { uint64_t id = 102; stream_media_format format{160, 90, 0, STREAM_MEDIA_H264, 0, 1}; QVERIFY(gui_stream_publish_open(id, group, format)); QTRY_VERIFY(live(id)); QByteArray large(1200000, '\0'); large[4] = '\1'; QCOMPARE(gui_stream_publish_feed(id, true, 100000, true, large, &format), 1); QCOMPARE(gui_stream_publish_feed(id, true, 200000, false, large, &format), 0); stream_publisher_status status; QVERIFY(gui_stream_publish_status(id, status)); QVERIFY(status.need_key); QCOMPARE(gui_stream_publish_feed(id, true, 300000, false, QByteArray("P"), &format), 0); auto key = keyframe(STREAM_MEDIA_H264); QVERIFY(!key.isEmpty()); QCOMPARE(gui_stream_publish_feed(id, true, 400000, true, key, &format), 1); QVERIFY(gui_stream_publish_status(id, status)); QVERIFY(!status.need_key); QTest::qWait(150); QVERIFY(gui_stream_publish_status(id, status)); QVERIFY(status.sample_us); 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 cpuConsent_data() { QTest::addColumn("allow"); QTest::newRow("decline") << false; QTest::newRow("allow") << true; } void cpuConsent() { #if defined(HAVE_STREAM_CAPTURE) && defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG) if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run under isolated Xvfb"); QFETCH(bool, allow); failHardware = true; softwareOpens = 0; audioEncodes = 0; auto reset = qScopeGuard([] { failHardware = false; }); StreamPublishEngine engine; StreamPublishWindow controls(&engine, group); controls.show(); QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); QSignalSpy requests(&engine, &StreamPublishEngine::softwareEncodingRequested); StreamPublishConfig config; config.audio = false; config.video.width = 320; config.video.height = 180; config.video.fps = 15; for (const auto& source : engine.sources()) if (source.kind == StreamVideoSource::Screen) { config.source = source; break; } QVERIFY(!config.source.id.isEmpty()); QCOMPARE(softwareOpens.load(), 0u); QVERIFY(engine.start(group, config)); QTRY_COMPARE(requests.size(), 1); uint64_t generation = requests.front().front().toULongLong(); auto* question = controls.findChild("streamCpuConfirmation"); QVERIFY(question); QCOMPARE(question->defaultButton(), question->button(QMessageBox::No)); QTest::qWait(allow ? 8200 : 150); // Время ответа человека не является timeout первого кадра. QCOMPARE(softwareOpens.load(), 0u); QCOMPARE(audioEncodes.load(), 0u); QVERIFY(errors.isEmpty()); engine.answerSoftwareEncoding(generation + 1, true); QCOMPARE(engine.softwareEncodingRequest(), generation); question->done(allow ? QMessageBox::Yes : QMessageBox::No); if (allow) { QTRY_VERIFY(softwareOpens.load() > 0); QTRY_VERIFY(engine.previewFrame().isValid()); QVERIFY(errors.isEmpty()); engine.stop(); } else { QTRY_VERIFY(!engine.running()); QCOMPARE(errors.size(), 1); QCOMPARE(softwareOpens.load(), 0u); } QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); // Согласие не сохраняется между трансляциями. unsigned before = softwareOpens.load(); QVERIFY(engine.start(group, config)); QTRY_COMPARE(requests.size(), 2); QCOMPARE(softwareOpens.load(), before); engine.stop(); engine.answerSoftwareEncoding(generation, true); QCOMPARE(softwareOpens.load(), before); QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); // Уничтожение окна с открытым вопросом отменяет запуск после удаления его дочерних виджетов. disconnect(&engine, &StreamPublishEngine::softwareEncodingRequested, &controls, nullptr); auto* temporary = new StreamPublishWindow(&engine, group); QVERIFY(engine.start(group, config)); QTRY_COMPARE(requests.size(), 3); QVERIFY(temporary->findChild("streamCpuConfirmation")); delete temporary; QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); QCOMPARE(softwareOpens.load(), before); #else QSKIP("Capture and encoder instrumentation unavailable"); #endif } void microphoneFailure_data() { QTest::addColumn("initialFailure"); QTest::newRow("no-first-frame") << true; QTest::newRow("stops-after-silence") << false; } void microphoneFailure() { #if defined(HAVE_STREAM_CAPTURE) && defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG) if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run under isolated Xvfb"); QFETCH(bool, initialFailure); auto* sound = SoundManager::instance(); QVERIFY(sound->init()); failHardware = true; suppressMicrophone = initialFailure; silentMicrophone = true; slowCaptureClose = true; softwareOpens = 0; hardwareOpens = 0; audioEncodes = 0; captureOpens = 0; auto reset = qScopeGuard([sound] { failHardware = suppressMicrophone = silentMicrophone = slowCaptureClose = false; sound->shutdown(); }); StreamPublishEngine engine; StreamPublishWindow controls(&engine, group); QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); QSignalSpy requests(&engine, &StreamPublishEngine::softwareEncodingRequested); StreamPublishConfig config; config.video.width = 320; config.video.height = 180; config.video.fps = 15; for (const auto& source : engine.sources()) if (source.kind == StreamVideoSource::Screen) { config.source = source; break; } QVERIFY(engine.start(group, config)); if (!initialFailure) { QTRY_COMPARE(requests.size(), 1); QCOMPARE(softwareOpens.load(), 0u); QCOMPARE(audioEncodes.load(), 0u); engine.answerSoftwareEncoding(requests.front().front().toULongLong(), true); QTRY_VERIFY(audioEncodes.load() > 0); QVERIFY(errors.isEmpty()); suppressMicrophone = true; } QElapsedTimer clock; clock.start(); qint64 previous = clock.elapsed(), maxGap = 0; QTimer heartbeat; heartbeat.setInterval(10); connect(&heartbeat, &QTimer::timeout, this, [&] { auto now = clock.elapsed(); maxGap = std::max(maxGap, now - previous); previous = now; }); heartbeat.start(); QTRY_VERIFY_WITH_TIMEOUT(!engine.running(), 2500); QCOMPARE(errors.size(), 1); QVERIFY(errors.front().front().toString().contains("microphone")); QVERIFY(engine.active()); // Фоновое закрытие ещё идёт, GUI продолжает обрабатывать события. QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 2500); heartbeat.stop(); QVERIFY2(maxGap < 400, qPrintable(QString::number(maxGap))); QCOMPARE(captureOpens.load(), 1u); bool shown = false; for (auto* label : controls.findChildren()) shown |= label->text().contains("microphone is not delivering audio"); QVERIFY(shown); if (initialFailure) { QVERIFY(requests.isEmpty()); QCOMPARE(hardwareOpens.load(), 0u); QCOMPARE(softwareOpens.load(), 0u); QCOMPARE(audioEncodes.load(), 0u); } #else QSKIP("Capture and encoder instrumentation unavailable"); #endif } void screenAndWindowCapture() { #if defined(HAVE_STREAM_CAPTURE) && defined(TEST_NULL_AUDIO) if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Real screen/window capture runs separately under Xvfb"); auto* sound = SoundManager::instance(); QVERIFY(sound->init()); QLabel pattern("Desktop capture test"); pattern.setWindowTitle("UTUN capture fixture"); pattern.resize(320, 180); pattern.setStyleSheet("background: #c04020; color: white; font-size: 20px;"); pattern.show(); QTest::qWait(100); StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.codecs().isEmpty(), 15000); connect(&engine, &StreamPublishEngine::softwareEncodingRequested, &engine, [&engine](uint64_t generation) { engine.answerSoftwareEncoding(generation, true); }); StreamPublishConfig config; config.video.width = 640; config.video.height = 360; config.video.fps = 15; for (const auto& source : engine.sources()) if (source.kind == StreamVideoSource::Screen) { config.source = source; break; } QVERIFY(!config.source.id.isEmpty()); QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); QVERIFY(engine.start(group, config)); QTRY_VERIFY_WITH_TIMEOUT(engine.config().video.codec == STREAM_MEDIA_H264, 10000); uint64_t epoch = 0; QElapsedTimer wait; wait.start(); while (!epoch && wait.elapsed() < 5000) { stream_member_state member{}; if (stream_member_load(inst, group, inst->node_id, &member, nullptr) == STREAM_OK && member.published) epoch = member.source.source_epoch; QTest::qWait(1); } QVERIFY(epoch); QVERIFY(gui_stream_open(202, group, inst->node_id, epoch)); StreamRecord out; QVERIFY(record(202, StreamRecordKind::Opened, out)); QVERIFY(record(202, StreamRecordKind::Video, out)); auto* sink = engine.findChild(); QVERIFY(sink); // Неподвижный экран на backend, выдающем кадры только при изменениях: новых raw callbacks нет. QObject::disconnect(sink, nullptr, &engine, nullptr); StreamPublishWindow controls(&engine, group); controls.show(); auto* preview = controls.findChild(); QVERIFY(preview); QTRY_VERIFY(preview->videoSink()->videoFrame().isValid()); controls.close(); QVERIFY(engine.running()); uint64_t first = out.pts; unsigned audio = 0, video = 0; uint64_t latest = first, left = 0, right = 0; auto* decoder = opus_codec_decoder_create(48000, 2); QVERIFY(decoder); QElapsedTimer elapsed; elapsed.start(); while (elapsed.elapsed() < 1800) { if (gui_stream_pull(202, out, 0, true, 60000000)) { if (out.kind == StreamRecordKind::Video) { video++; latest = out.pts; } if (out.kind == StreamRecordKind::Audio) { audio++; int16_t pcm[1920]; QCOMPARE(opus_codec_decode(decoder, (const uint8_t*)out.data.constData(), out.data.size(), pcm, 960), 960); for (int i = 0; i < 960; i++) { left += (int64_t)pcm[2 * i] * pcm[2 * i]; right += (int64_t)pcm[2 * i + 1] * pcm[2 * i + 1]; } } } QTest::qWait(1); } QVERIFY(video >= 10); QVERIFY(audio >= 40); QVERIFY(latest > first + 1000000); QVERIFY(left && right > left * 3); AudioCapture radio; std::atomic radioFrames{0}; QVERIFY(radio.open({}, AudioCapture::Role::Radio, 1, 303, [](int, int) { return true; }, [] {}, [&radioFrames](const AudioCapture::Frame& frame) { for (unsigned i = 0; i < frame.frames * frame.channels; i++) if (frame.pcm[i]) { radioFrames++; break; } })); for (bool muted : {true, false}) { auto before = radioFrames.load(); engine.setMuted(muted); QCOMPARE(engine.muted(), muted); stream_member_state member{}; QTRY_VERIFY(stream_member_load(inst, group, inst->node_id, &member, nullptr) == STREAM_OK && member.audio_enabled == !muted); QCOMPARE(member.source.source_epoch, epoch); int peak = 0; unsigned checked = 0; elapsed.restart(); while (elapsed.elapsed() < 1200) { if (gui_stream_pull(202, out, 0, true, 60000000) && out.kind == StreamRecordKind::Audio) { int16_t pcm[1920]; QCOMPARE(opus_codec_decode(decoder, (const uint8_t*)out.data.constData(), out.data.size(), pcm, 960), 960); if (elapsed.elapsed() > 400) { checked++; for (int16_t sample : pcm) peak = std::max(peak, std::abs((int)sample)); } } QTest::qWait(1); } QVERIFY(checked >= 20); QVERIFY(muted ? peak < 5 : peak > 100); QVERIFY(radioFrames.load() > before + 10); QVERIFY(radio.running()); } radio.close(); opus_codec_decoder_destroy(decoder); auto next = config; next.video.bitrate = 1000000; next.video.rotation = 90; if (engine.codecs().contains(STREAM_MEDIA_VP9)) next.video.codec = STREAM_MEDIA_VP9; QVERIFY(engine.reconfigure(next)); bool revised = false; elapsed.restart(); while (!revised && elapsed.elapsed() < 5000) { if (gui_stream_pull(202, out, 0, true, 60000000) && out.kind == StreamRecordKind::Video && out.format.revision >= 2) revised = true; QTest::qWait(1); } QVERIFY(revised); QCOMPARE(out.format.rotation, (uint16_t)90); QCOMPARE(out.format.video_codec, (uint8_t)next.video.codec); QVERIFY(record(202, StreamRecordKind::Audio, out)); QVERIFY(out.pts > first + 1000000); stream_member_state member{}; QCOMPARE(stream_member_load(inst, group, inst->node_id, &member, nullptr), STREAM_OK); QCOMPARE(member.source.source_epoch, epoch); auto invalid = next; invalid.video.fps = 61; QVERIFY(engine.reconfigure(invalid)); QTRY_COMPARE_WITH_TIMEOUT(engine.config().video.fps, 15, 5000); QTRY_COMPARE(errors.size(), 1); errors.clear(); bool restored = false; elapsed.restart(); while (!restored && elapsed.elapsed() < 5000) { if (gui_stream_pull(202, out, 0, true, 60000000) && out.kind == StreamRecordKind::Video && out.format.revision >= 3) restored = true; QTest::qWait(1); } QVERIFY(restored); #if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) next.source = {StreamVideoSource::Window, pattern.windowTitle(), {}, QVariant::fromValue(QCapturableWindow(pattern.windowHandle()))}; QVERIFY(engine.reconfigure(next)); bool window = false; elapsed.restart(); while (!window && elapsed.elapsed() < 5000) { if (gui_stream_pull(202, out, 0, true, 60000000) && out.kind == StreamRecordKind::Video && out.format.revision >= 4) window = true; QTest::qWait(1); } QVERIFY(window); #endif QVERIFY(errors.isEmpty()); failAudioEncoding = true; QTRY_VERIFY(!engine.running()); QTRY_COMPARE(errors.size(), 1); QVERIFY(!errors.front().front().toString().contains("restored")); AudioCaptureLease microphone; QTRY_VERIFY_WITH_TIMEOUT(microphone.acquire(), 2000); microphone.release(); QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); gui_stream_stop(202); sound->shutdown(); #else QSKIP("Qt screen capture or null audio unavailable"); #endif } void concurrentDetach() { uint64_t id = 103; stream_media_format format{160, 90, 0, STREAM_MEDIA_H264, 0, 1}; QVERIFY(gui_stream_publish_open(id, group, format)); QTRY_VERIFY(live(id)); auto key = keyframe(STREAM_MEDIA_H264); QVERIFY(!key.isEmpty()); std::atomic ready{false}; std::thread producer([&] { ready = true; for (uint64_t pts = 1; gui_stream_publish_feed(id, true, pts, true, key, &format) >= 0; pts++) {} }); while (!ready) std::this_thread::yield(); gui_stream_detach(inst); producer.join(); QVERIFY(!gui_stream_ready()); QVERIFY(gui_stream_attach(inst)); } void cleanupTestCase() { poll.stop(); gui_stream_detach(inst); uasync_mark_stopped(ua); utun_instance_destroy(inst); uasync_destroy(ua, 1); QCOMPARE(u_get_allocated_count(), allocations); } }; QTEST_MAIN(StreamPublishTest) #include "test_stream_publish.moc"