You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

315 lines
20 KiB

/* Настоящие FFmpeg/Opus/uAV2/RS и ядро. X11 capture дополнительно запускается под Xvfb.
* Null miniaudio подаёт два разных тона вместо физического микрофона, не подменяя encoder/публикацию. */
#include <QtTest>
#include <QTimer>
#include <QLabel>
#include <QScreen>
#include <QLoggingCategory>
#include <atomic>
#include <thread>
#include <cmath>
#include "../src/stream_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 <libavcodec/avcodec.h>
#include <libavutil/mem.h>
#endif
}
#ifdef HAVE_STREAM_CAPTURE
#include <QWindowCapture>
#include <QVideoWidget>
#include <QVideoSink>
#include <QVideoFrame>
#endif
#ifdef TEST_NULL_AUDIO
static ma_device_data_proc captureCallback;
static std::atomic<bool> failAudioEncoding{false};
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) {
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) {
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));
}
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) {
captureCallback = copy.dataCallback; copy.dataCallback = injectMicrophone;
}
return __real_ma_device_init(context, &copy, 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
StreamVideoEncoder encoder; StreamVideoConfig 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<StreamEncodedFrame> 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")) 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 exclusiveBroadcastMicrophone() {
AudioCaptureLease call, 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 = StreamVideoEncoder::available();
if (codecs.isEmpty()) QTest::newRow("unavailable") << 0;
for (int codec : codecs) QTest::newRow(qPrintable(StreamVideoEncoder::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 liveSoftwareBitrateKeepsDecoder() {
#ifdef HAVE_FFMPEG
StreamVideoEncoder encoder; StreamVideoConfig 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<StreamEncodedFrame> 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 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);
StreamPublishConfig config; config.video.width = 640; config.video.height = 360; config.video.fps = 15;
config.source = {StreamVideoSource::Screen, "Virtual screen", QGuiApplication::primaryScreen()->name().toUtf8(), {}};
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);
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; QVERIFY(microphone.acquire()); 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"