984 lines
63 KiB
C++
984 lines
63 KiB
C++
/* Настоящие FFmpeg/Opus/uAV2/RS и ядро. X11 capture дополнительно запускается под Xvfb.
|
|
* Null miniaudio подаёт два разных тона вместо физического микрофона, не подменяя encoder/публикацию. */
|
|
#include <QtTest>
|
|
#include <QTimer>
|
|
#include <QLabel>
|
|
#include <QComboBox>
|
|
#include <QSpinBox>
|
|
#include <QCheckBox>
|
|
#include <QButtonGroup>
|
|
#include <QPushButton>
|
|
#include <QMessageBox>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QScreen>
|
|
#include <QLoggingCategory>
|
|
#include <QScopeGuard>
|
|
#include <QPainter>
|
|
#include <QPixmap>
|
|
#include <atomic>
|
|
#include <thread>
|
|
#include <cmath>
|
|
#ifdef TEST_NULL_AUDIO
|
|
#include <pthread.h>
|
|
#endif
|
|
#include "../src/live_video_encoder.h"
|
|
#include "../src/stream_qp_control.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"
|
|
#include "video/video.h"
|
|
#ifdef HAVE_FFMPEG
|
|
#include <libavcodec/avcodec.h>
|
|
#include <libavutil/mem.h>
|
|
#endif
|
|
}
|
|
#ifdef HAVE_STREAM_CAPTURE
|
|
#include <QWindowCapture>
|
|
#include <QVideoWidget>
|
|
#include <QVideoSink>
|
|
#include <QVideoFrame>
|
|
#endif
|
|
#ifdef UTUN_STREAM_X11_CURSOR
|
|
#include "../src/stream_screen_cursor.h"
|
|
#include <QtGui/qguiapplication_platform.h>
|
|
#include <X11/Xlib.h>
|
|
#undef None
|
|
#undef Status
|
|
#endif
|
|
#ifdef UTUN_STREAM_WIN_CURSOR
|
|
#include "../src/stream_screen_cursor.h"
|
|
#include <QWindow>
|
|
#include <QtGui/qscreen_platform.h>
|
|
#include <qt_windows.h>
|
|
#endif
|
|
|
|
#ifdef TEST_NULL_AUDIO
|
|
static ma_device_data_proc captureCallback;
|
|
static std::atomic<bool> failAudioEncoding{false};
|
|
static std::atomic<bool> suppressMicrophone{false}, silentMicrophone{false}, slowCaptureClose{false};
|
|
static std::atomic<unsigned> captureOpens{0}, audioEncodes{0};
|
|
#ifdef HAVE_FFMPEG
|
|
static std::atomic<unsigned> softwareOpens{0}, hardwareOpens{0};
|
|
static std::atomic<bool> failHardware{false};
|
|
static std::atomic<bool> requireGuiAtThreadExit{false};
|
|
static std::atomic<unsigned> threadExitArmed{0}, threadExitCompleted{0};
|
|
static pthread_key_t guiThreadExitKey;
|
|
static void guiThreadExit(void*) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
|
QMetaObject::invokeMethod(qApp, [] { threadExitCompleted++; }, Qt::BlockingQueuedConnection);
|
|
}
|
|
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)) {
|
|
if (requireGuiAtThreadExit && QThread::currentThread() != qApp->thread()) {
|
|
if (!pthread_getspecific(guiThreadExitKey)) {
|
|
pthread_setspecific(guiThreadExitKey, &guiThreadExitKey); threadExitArmed++;
|
|
}
|
|
}
|
|
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<int16_t> 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
|
|
|
|
#ifdef HAVE_FFMPEG
|
|
static QStringList ffmpegDiagnosticMessages;
|
|
static void captureFfmpegDiagnostic(int, const char*, const char* message) {
|
|
if (strstr(message, "UTUN_AMF_LOG_TEST") || strstr(message, "ffmpeg headers:"))
|
|
ffmpegDiagnosticMessages.append(QString::fromUtf8(message));
|
|
}
|
|
#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<LiveEncodedFrame> 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 amfDriverDiagnosticsRespectCategoryFilter() {
|
|
#ifdef HAVE_FFMPEG
|
|
video_ffmpeg_init();
|
|
auto previousDebug = g_debug_config.category_levels[DEBUG_CATEGORY_DEBUG];
|
|
auto previousVideo = g_debug_config.category_levels[DEBUG_CATEGORY_VIDEO];
|
|
AVCodecContext* context = avcodec_alloc_context3(nullptr); QVERIFY(context);
|
|
auto cleanup = qScopeGuard([&] {
|
|
context->codec = nullptr; avcodec_free_context(&context); debug_set_log_hook(nullptr);
|
|
debug_set_category_level(DEBUG_CATEGORY_DEBUG, previousDebug);
|
|
debug_set_category_level(DEBUG_CATEGORY_VIDEO, previousVideo); ffmpegDiagnosticMessages.clear();
|
|
});
|
|
AVCodec codec{}; codec.name = "hevc_amf"; context->codec = &codec;
|
|
debug_set_log_hook(captureFfmpegDiagnostic);
|
|
debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_TRACE);
|
|
debug_set_category_level(DEBUG_CATEGORY_VIDEO, DEBUG_LEVEL_WARN);
|
|
av_log(context, AV_LOG_DEBUG, "UTUN_AMF_LOG_TEST driver value=%d\n", 17);
|
|
QCOMPARE(ffmpegDiagnosticMessages.size(), 1);
|
|
QVERIFY(ffmpegDiagnosticMessages.front().contains("encoder=hevc_amf: UTUN_AMF_LOG_TEST driver value=17"));
|
|
av_log(context, AV_LOG_DEBUG, "UTUN_AMF_LOG_TEST Debug: AMFEncoderUVEHEVCImpl::QueryOutput()\n");
|
|
av_log(context, AV_LOG_DEBUG, "UTUN_AMF_LOG_TEST Trace: SubmitToEncoder() End: frame pts=17\n");
|
|
QCOMPARE(ffmpegDiagnosticMessages.size(), 1);
|
|
codec.name = "libx264";
|
|
av_log(context, AV_LOG_DEBUG, "UTUN_AMF_LOG_TEST unrelated encoder\n");
|
|
av_log(nullptr, AV_LOG_DEBUG, "UTUN_AMF_LOG_TEST unrelated context\n");
|
|
QCOMPARE(ffmpegDiagnosticMessages.size(), 1);
|
|
codec.name = "hevc_amf"; debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO);
|
|
av_log(context, AV_LOG_DEBUG, "UTUN_AMF_LOG_TEST driver disabled\n");
|
|
QCOMPARE(ffmpegDiagnosticMessages.size(), 1);
|
|
av_log(nullptr, AV_LOG_ERROR, "UTUN_AMF_LOG_TEST normal error\n");
|
|
QCOMPARE(ffmpegDiagnosticMessages.size(), 2);
|
|
QVERIFY(ffmpegDiagnosticMessages.back().contains("normal error"));
|
|
debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_TRACE);
|
|
av_log(context, AV_LOG_DEBUG, "UTUN_AMF_LOG_TEST Warning: AMFEncoderUVEHEVCImpl::QueryOutput() failed\n");
|
|
QCOMPARE(ffmpegDiagnosticMessages.size(), 3);
|
|
QVERIFY(ffmpegDiagnosticMessages.back().contains("Warning:"));
|
|
av_log_set_callback(av_log_default_callback); video_ffmpeg_init();
|
|
av_log(context, AV_LOG_DEBUG, "UTUN_AMF_LOG_TEST callback restored\n");
|
|
QCOMPARE(ffmpegDiagnosticMessages.size(), 4);
|
|
QVERIFY(ffmpegDiagnosticMessages.back().contains("callback restored"));
|
|
#else
|
|
QSKIP("FFmpeg unavailable");
|
|
#endif
|
|
}
|
|
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<QComboBox*>(); 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<QCheckBox*>("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<QSpinBox*>("streamMaxQp")->setValue(25);
|
|
controls.findChild<QCheckBox*>("streamAdaptiveQp")->setChecked(true);
|
|
controls.findChild<QButtonGroup*>()->button(270)->click();
|
|
controls.findChildren<QCheckBox*>().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));
|
|
QCOMPARE(stored.value("max_qp").toInt(), 25); QVERIFY(stored.value("adaptive_qp").toBool());
|
|
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<QComboBox*>();
|
|
QCOMPARE(boxes[1]->currentData().toList(), selectedMode);
|
|
QCOMPARE(boxes[3]->currentData().toInt(), 4000000);
|
|
QCOMPARE(reopened.findChild<QSpinBox*>("streamMaxQp")->value(), 25);
|
|
QVERIFY(reopened.findChild<QCheckBox*>("streamAdaptiveQp")->isChecked());
|
|
if (StreamPublishEngine::supported()) QTRY_COMPARE(boxes[2]->currentData().toInt(), stored.value("codec").toInt());
|
|
QCOMPARE(reopened.findChild<QButtonGroup*>()->checkedId(), 270);
|
|
QVERIFY(!reopened.findChildren<QCheckBox*>().first()->isChecked());
|
|
QVERIFY(!reopened.findChild<QCheckBox*>("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<QComboBox*>()[3]->currentIndex(), 0);
|
|
}
|
|
void qpAdaptationFollowsBitrateWithoutSpikes() {
|
|
StreamQpControl qp; qp.reset(28, 1000000, true, 0);
|
|
QCOMPARE(qp.update(1000000, 1000000), 28); // До двух секунд одиночный KEY не меняет потолок.
|
|
QCOMPARE(qp.update(0, 2000000), 29);
|
|
QCOMPARE(qp.update(1000000, 4000000), 30); // Не более одного пункта за интервал.
|
|
for (int64_t now = 6000000; now <= 12000000; now += 2000000) qp.update(250000, now);
|
|
QCOMPARE(qp.limit(), 31); // Сглаживание не дёргает QP около целевого битрейта.
|
|
for (int64_t now = 14000000; now <= 30000000; now += 2000000) qp.update(10000, now);
|
|
QCOMPARE(qp.limit(), 28); // Возвращаем выбранный потолок, не снижаем его дальше.
|
|
qp.reset(28, 1000000, false, 0); QCOMPARE(qp.update(10000000, 2000000), 28);
|
|
qp.reset(50, 1000000, true, 0);
|
|
QCOMPARE(qp.update(1000000, 2000000), 51); QCOMPARE(qp.update(1000000, 4000000), 51);
|
|
qp.reset(24, 4000000, true, 10000000);
|
|
QCOMPARE(qp.update(1000, 11000000), 24); QCOMPARE(qp.bitrate(), uint64_t(0));
|
|
}
|
|
void proportionalScreenModes_data() {
|
|
QTest::addColumn<QSize>("source"); QTest::addColumn<QSize>("maximum"); QTest::addColumn<QSize>("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<StreamVideoMode> 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<StreamVideoMode> 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<QComboBox*>("streamVideoSource");
|
|
auto* modeBox = controls.findChild<QComboBox*>("streamVideoMode");
|
|
auto sources = StreamPublishEngine::sources();
|
|
QSignalSpy changes(&controls, &StreamPublishWindow::settingsChanged);
|
|
QList<QByteArray> 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<int>("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 native 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;
|
|
// При дробном DPI size*dpr округляется; сверяемся с физическими пикселями снимка.
|
|
QSize capturedSize = source.screen->grabWindow(0).size(); QVERIFY(!capturedSize.isEmpty());
|
|
QSize expected = stream_video_screen_size(capturedSize);
|
|
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, capturedSize);
|
|
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<QNativeInterface::QX11Application>(); 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<QVideoFrame>().toImage();
|
|
return !preview.isNull() && preview.pixelColor(position) == QColor(Qt::magenta);
|
|
};
|
|
QTRY_VERIFY_WITH_TIMEOUT(cursorShown(), 5000);
|
|
StreamPublishWindow controls(&engine, group);
|
|
auto* toggle = controls.findChild<QCheckBox*>("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 windowsScreenCursorOverlay() {
|
|
#ifdef UTUN_STREAM_WIN_CURSOR
|
|
if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run on an isolated Windows desktop");
|
|
if (QGuiApplication::platformName() != "windows") QSKIP("Requires the Windows platform plugin");
|
|
POINT saved{}; QVERIFY(GetCursorPos(&saved));
|
|
auto restorePosition = qScopeGuard([&] { SetCursorPos(saved.x, saved.y); });
|
|
for (auto* screen : QGuiApplication::screens()) {
|
|
auto* native = screen->nativeInterface<QNativeInterface::QWindowsScreen>(); QVERIFY(native);
|
|
MONITORINFO monitor{}; monitor.cbSize = sizeof(monitor);
|
|
QVERIFY(GetMonitorInfoW(native->handle(), &monitor));
|
|
QPoint origin(monitor.rcMonitor.left, monitor.rcMonitor.top);
|
|
QWindow target(screen); target.setFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
|
|
target.setGeometry(QRect(screen->geometry().topLeft(), QSize(200, 160))); target.show();
|
|
QVERIFY(QTest::qWaitForWindowExposed(&target));
|
|
BYTE mask[32]; memset(mask, 0xff, sizeof(mask));
|
|
// AND=1, XOR=1 инвертирует фон; прозрачная картинка курсора этот случай не воспроизводит.
|
|
HCURSOR cursor = CreateCursor(nullptr, 3, 4, 16, 16, mask, mask); QVERIFY(cursor);
|
|
auto releaseCursor = qScopeGuard([&] { DestroyCursor(cursor); });
|
|
HCURSOR previous = GetCursor();
|
|
auto restoreCursor = qScopeGuard([&] { SetCursor(previous); });
|
|
StreamScreenCursor overlay(screen);
|
|
const QColor background(0x12, 0x34, 0x56), inverted(0xed, 0xcb, 0xa9);
|
|
for (QPoint position : {QPoint(60, 70), QPoint(0, 0)}) {
|
|
QVERIFY(SetCursorPos(origin.x() + position.x(), origin.y() + position.y()));
|
|
SetCursor(cursor);
|
|
CURSORINFO info{}; info.cbSize = sizeof(info); QVERIFY(GetCursorInfo(&info));
|
|
QVERIFY(info.flags & CURSOR_SHOWING); QVERIFY(info.hCursor == cursor);
|
|
QImage frame(monitor.rcMonitor.right - monitor.rcMonitor.left,
|
|
monitor.rcMonitor.bottom - monitor.rcMonitor.top, QImage::Format_RGB32);
|
|
frame.setDevicePixelRatio(screen->devicePixelRatio()); frame.fill(background);
|
|
QImage blank = frame;
|
|
QVERIFY(overlay.draw(frame));
|
|
QImage expected(frame.size(), QImage::Format_RGB32); expected.fill(background);
|
|
{ QPainter painter(&expected); painter.fillRect(QRect(position - QPoint(3, 4), QSize(16, 16)), inverted); }
|
|
QCOMPARE(frame, expected); // Hotspot, физический размер HiDPI и обрезание у края монитора.
|
|
QCOMPARE(frame.devicePixelRatio(), qreal(1));
|
|
SetCursor(nullptr); frame = blank;
|
|
QVERIFY(overlay.draw(frame)); QCOMPARE(frame, blank);
|
|
}
|
|
QVERIFY(SetCursorPos(origin.x() + 60, origin.y() + 70)); SetCursor(cursor);
|
|
for (auto* other : QGuiApplication::screens()) {
|
|
if (other == screen) continue;
|
|
StreamScreenCursor otherOverlay(other);
|
|
QImage frame(other->size() * other->devicePixelRatio(), QImage::Format_RGB32); frame.fill(background);
|
|
QImage blank = frame; QVERIFY(otherOverlay.draw(frame)); QCOMPARE(frame, blank);
|
|
}
|
|
StreamPublishEngine engine;
|
|
for (const auto& source : engine.sources()) {
|
|
if (source.kind != StreamVideoSource::Screen || source.screen != screen) continue;
|
|
QVERIFY(StreamPublishEngine::cursorSupported(source));
|
|
StreamPublishWindow controls(&engine, group);
|
|
auto* sources = controls.findChild<QComboBox*>("streamVideoSource"); QVERIFY(sources);
|
|
auto all = engine.sources();
|
|
for (int i = 0; i < all.size(); i++) if (all[i].sameSource(source)) sources->setCurrentIndex(i);
|
|
auto* toggle = controls.findChild<QCheckBox*>("streamShowCursor"); QVERIFY(toggle);
|
|
QVERIFY(!toggle->isHidden()); QVERIFY(toggle->isChecked());
|
|
}
|
|
}
|
|
#else
|
|
QSKIP("Windows 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<int>("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 previousDebug = g_debug_config.category_levels[DEBUG_CATEGORY_DEBUG];
|
|
auto cleanup = qScopeGuard([&] {
|
|
if (codec != STREAM_MEDIA_H265) return;
|
|
debug_set_log_hook(nullptr); debug_set_category_level(DEBUG_CATEGORY_DEBUG, previousDebug);
|
|
ffmpegDiagnosticMessages.clear();
|
|
});
|
|
if (codec == STREAM_MEDIA_H265) {
|
|
ffmpegDiagnosticMessages.clear(); debug_set_log_hook(captureFfmpegDiagnostic);
|
|
debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_TRACE);
|
|
}
|
|
auto encoded = keyframe(codec); QVERIFY(!encoded.isEmpty());
|
|
if (codec == STREAM_MEDIA_H265) {
|
|
QString headers = ffmpegDiagnosticMessages.join('\n');
|
|
QVERIFY2(headers.contains("Packet:"), qPrintable(headers));
|
|
QVERIFY2(headers.contains("init_qp_minus26"), qPrintable(headers));
|
|
QVERIFY2(headers.contains("slice_qp_delta"), qPrintable(headers));
|
|
QVERIFY(!headers.contains("profile_compatibility_flag"));
|
|
}
|
|
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<const char*>(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<LiveEncodedFrame> 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 stopWithGuiThreadCleanup_data() {
|
|
QTest::addColumn<bool>("destroy");
|
|
QTest::newRow("stop") << false;
|
|
QTest::newRow("destructor") << true;
|
|
}
|
|
void stopWithGuiThreadCleanup() {
|
|
#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, destroy);
|
|
requireGuiAtThreadExit = true; failHardware = true; threadExitArmed = threadExitCompleted = 0;
|
|
QCOMPARE(pthread_key_create(&guiThreadExitKey, guiThreadExit), 0);
|
|
auto reset = qScopeGuard([] { requireGuiAtThreadExit = false; failHardware = false; pthread_key_delete(guiThreadExitKey); });
|
|
auto engine = std::make_unique<StreamPublishEngine>();
|
|
uint64_t id = 0;
|
|
connect(engine.get(), &StreamPublishEngine::telemetryChanged, this, [&](const stream_publisher_status& status) { id = status.id; });
|
|
connect(engine.get(), &StreamPublishEngine::softwareEncodingRequested, engine.get(),
|
|
[&](uint64_t generation, const QString&, const QString&) { engine->answerSoftwareEncoding(generation, true); });
|
|
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()); QVERIFY(engine->start(group, config));
|
|
QTRY_COMPARE_WITH_TIMEOUT(threadExitArmed.load(), 1u, 4000);
|
|
QTRY_VERIFY(id != 0);
|
|
unsigned heartbeats = 0; QTimer heartbeat;
|
|
connect(&heartbeat, &QTimer::timeout, this, [&] { heartbeats++; }); heartbeat.start(10);
|
|
QElapsedTimer elapsed; elapsed.start();
|
|
if (destroy) {
|
|
engine.reset(); QVERIFY(elapsed.elapsed() < 3000);
|
|
} else {
|
|
engine->stop(); QVERIFY(elapsed.elapsed() < 300); QVERIFY(engine->active());
|
|
QCOMPARE(threadExitCompleted.load(), 0u);
|
|
QTRY_VERIFY_WITH_TIMEOUT(!engine->active(), 4000);
|
|
}
|
|
QCOMPARE(threadExitCompleted.load(), 1u); QVERIFY(heartbeats >= 5);
|
|
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "[PASS] publisher stop destroy=%d GUI-dependent TLS cleanup completed heartbeat=%u elapsed_ms=%lld",
|
|
destroy, heartbeats, (long long)elapsed.elapsed());
|
|
QTRY_VERIFY_WITH_TIMEOUT(ended(id), 4000); // Core продолжает FIN drain после удаления desktop engine.
|
|
#else
|
|
QSKIP("Capture test requires FFmpeg and null audio wrappers");
|
|
#endif
|
|
}
|
|
void cpuConsent_data() {
|
|
QTest::addColumn<bool>("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<QMessageBox*>("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<QMessageBox*>("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<bool>("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<QLabel*>()) 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 captureConversionFailure() {
|
|
#if defined(HAVE_STREAM_CAPTURE) && defined(TEST_NULL_AUDIO)
|
|
if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run under isolated Xvfb");
|
|
StreamPublishEngine engine;
|
|
StreamPublishConfig config; config.audio = false; config.showCursor = 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());
|
|
QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred);
|
|
QVERIFY(engine.start(group, config));
|
|
auto* sink = engine.findChild<QVideoSink*>(); QVERIFY(sink);
|
|
// Валидная оболочка кадра, но повреждённый JPEG: ошибка image worker обязана остановить текущий source.
|
|
QVideoFrame frame(QVideoFrameFormat(QSize(16, 16), QVideoFrameFormat::Format_Jpeg));
|
|
QVERIFY(frame.isValid()); QVERIFY(frame.map(QVideoFrame::WriteOnly));
|
|
memset(frame.bits(0), 0, frame.mappedBytes(0)); frame.unmap();
|
|
sink->setVideoFrame(frame);
|
|
QTRY_VERIFY_WITH_TIMEOUT(!errors.isEmpty(), 3000);
|
|
QVERIFY(errors.front().front().toString().contains("Video frame conversion failed"));
|
|
QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 5000);
|
|
#else
|
|
QSKIP("Capture fixture 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<QVideoSink*>(); QVERIFY(sink);
|
|
// Неподвижный экран на backend, выдающем кадры только при изменениях: новых raw callbacks нет.
|
|
QObject::disconnect(sink, nullptr, &engine, nullptr);
|
|
StreamPublishWindow controls(&engine, group); controls.show();
|
|
auto* preview = controls.findChild<QVideoWidget*>(); 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);
|
|
QTRY_VERIFY_WITH_TIMEOUT(engine.videoBitrate() > 0, 3000);
|
|
auto* stats = controls.findChild<QLabel*>("streamPublishStats"); QVERIFY(stats);
|
|
QVERIFY(stats->text().contains(QStringLiteral("Current video bitrate: ")));
|
|
QVERIFY(stats->text().contains(QStringLiteral("%1 Mbit/s").arg(engine.videoBitrate() / 1000000.0, 0, 'f', 2)));
|
|
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "[PASS] publisher displays measured video bitrate bps=%llu",
|
|
(unsigned long long)engine.videoBitrate());
|
|
AudioCapture radio;
|
|
std::atomic<uint64_t> 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<bool> 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"
|