579 lines
36 KiB
C++
579 lines
36 KiB
C++
/* Настоящее ядро/actor, подписанный медиаблок и H.264 decoder: каталог до подписки, эпоха, показ и teardown. */
|
|
#include "../src/streamplayer_window.h"
|
|
#include "../src/videoplayer_window.h"
|
|
#include "../src/sound_manager.h"
|
|
#include "../transport/stream_bridge.h"
|
|
#include <QtTest>
|
|
#include <QJsonDocument>
|
|
#include <QJsonArray>
|
|
#include <QLabel>
|
|
#include <QSlider>
|
|
#include <QPainter>
|
|
#include <QWheelEvent>
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <chrono>
|
|
#include <deque>
|
|
|
|
extern "C" {
|
|
#include "utun_instance.h"
|
|
#include "chat/chat_core.h"
|
|
#include "chat/chat_event.h"
|
|
#include "stream/stream.h"
|
|
#include "stream/stream_member.h"
|
|
#include "debug_config.h"
|
|
#include "mem.h"
|
|
#include "sqlite3.h"
|
|
#include "opus_codec.h"
|
|
#ifdef HAVE_FFMPEG
|
|
#include <libavcodec/avcodec.h>
|
|
#endif
|
|
}
|
|
|
|
static QList<QByteArray> snapshots;
|
|
|
|
/* Проверяем изображение на выходе QWidget, включая физические пиксели при QT_SCALE_FACTOR=2. */
|
|
static QImage renderedVideo(VideoRenderWidget& view) {
|
|
QImage image(QSize(qRound(view.width() * view.devicePixelRatioF()), qRound(view.height() * view.devicePixelRatioF())),
|
|
QImage::Format_RGB32);
|
|
image.setDevicePixelRatio(view.devicePixelRatioF()); view.render(&image); return image;
|
|
}
|
|
|
|
static QImage coordinateFrame() {
|
|
QImage image(1200, 900, QImage::Format_RGB32);
|
|
for (int y = 0; y < image.height(); y++) {
|
|
auto* row = reinterpret_cast<QRgb*>(image.scanLine(y));
|
|
for (int x = 0; x < image.width(); x++) row[x] = qRgb(1 + x % 251, 1 + y % 251, 1 + x / 251 + 16 * (y / 251));
|
|
}
|
|
return image;
|
|
}
|
|
|
|
static void zoomVideo(VideoRenderWidget& view, int delta, QPoint position, bool pixels = false) {
|
|
QWheelEvent event(position, view.mapToGlobal(position), pixels ? QPoint(0, delta) : QPoint(),
|
|
pixels ? QPoint() : QPoint(0, delta), Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false);
|
|
QApplication::sendEvent(&view, &event);
|
|
}
|
|
|
|
static void moveVideo(VideoRenderWidget& view, QPoint position, Qt::MouseButtons buttons = Qt::LeftButton) {
|
|
QMouseEvent event(QEvent::MouseMove, position, view.mapToGlobal(position), Qt::NoButton, buttons, Qt::NoModifier);
|
|
QApplication::sendEvent(&view, &event);
|
|
}
|
|
|
|
static void dragVideo(VideoRenderWidget& view, QPoint from, QPoint to) {
|
|
QTest::mousePress(&view, Qt::LeftButton, Qt::NoModifier, from); moveVideo(view, to);
|
|
QTest::mouseRelease(&view, Qt::LeftButton, Qt::NoModifier, to);
|
|
}
|
|
|
|
static QImage nativeCrop(const QImage& frame, const VideoRenderWidget& view, QPoint origin) {
|
|
QImage image = frame.copy(QRect(origin, QSize(qRound(view.width() * view.devicePixelRatioF()),
|
|
qRound(view.height() * view.devicePixelRatioF()))));
|
|
image.setDevicePixelRatio(view.devicePixelRatioF()); return image;
|
|
}
|
|
#if defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG)
|
|
static std::atomic<bool> delayedDecoder{false};
|
|
static AVCodecContext* delayedContext = nullptr;
|
|
static std::deque<AVFrame*> delayedFrames;
|
|
static bool delayedDraining = false;
|
|
extern "C" int __real_avcodec_open2(AVCodecContext*, const AVCodec*, AVDictionary**);
|
|
extern "C" int __wrap_avcodec_open2(AVCodecContext* context, const AVCodec* codec, AVDictionary** options) {
|
|
int result = __real_avcodec_open2(context, codec, options);
|
|
if (!result && delayedDecoder && codec && av_codec_is_decoder(codec)) {
|
|
delayedContext = context; delayedDraining = false;
|
|
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "fixture decoder name=%s holds four real decoded frames before output", codec->name);
|
|
}
|
|
return result;
|
|
}
|
|
extern "C" int __real_avcodec_receive_frame(AVCodecContext*, AVFrame*);
|
|
extern "C" int __wrap_avcodec_receive_frame(AVCodecContext* context, AVFrame* frame) {
|
|
if (context != delayedContext) return __real_avcodec_receive_frame(context, frame);
|
|
int result = __real_avcodec_receive_frame(context, frame);
|
|
if (!result) {
|
|
AVFrame* held = av_frame_clone(frame); if (!held) return AVERROR(ENOMEM);
|
|
delayedFrames.push_back(held); av_frame_unref(frame);
|
|
} else if (result == AVERROR_EOF) delayedDraining = true;
|
|
else if (result != AVERROR(EAGAIN)) return result;
|
|
if ((delayedDraining || delayedFrames.size() >= 4) && !delayedFrames.empty()) {
|
|
AVFrame* held = delayedFrames.front(); delayedFrames.pop_front(); av_frame_move_ref(frame, held); av_frame_free(&held); return 0;
|
|
}
|
|
return delayedDraining ? AVERROR_EOF : AVERROR(EAGAIN);
|
|
}
|
|
extern "C" void __real_avcodec_free_context(AVCodecContext**);
|
|
extern "C" void __wrap_avcodec_free_context(AVCodecContext** context) {
|
|
if (context && *context == delayedContext) {
|
|
for (auto* frame : delayedFrames) av_frame_free(&frame);
|
|
delayedFrames.clear(); delayedContext = nullptr; delayedDraining = false;
|
|
}
|
|
__real_avcodec_free_context(context);
|
|
}
|
|
#endif
|
|
#ifdef TEST_NULL_AUDIO
|
|
static std::atomic<ma_device_data_proc> originalAudioCallback{nullptr};
|
|
static std::atomic<uint64_t> nonzeroSamples{0};
|
|
static std::atomic<uint64_t> silenceGaps{0};
|
|
static unsigned silentRun = 0; // Только callback; счётчик промежутков >=1мс между тестовыми тонами.
|
|
static std::atomic<uint64_t> leftEnergy{0}, rightEnergy{0};
|
|
static std::atomic<int64_t> playbackDelayUs{0};
|
|
extern "C" int64_t __real_audio_stream_frame_time(ma_device*, unsigned int, int*);
|
|
extern "C" int64_t __wrap_audio_stream_frame_time(ma_device* device, unsigned int frames, int* valid) {
|
|
if (!playbackDelayUs) return __real_audio_stream_frame_time(device, frames, valid);
|
|
*valid = 1;
|
|
return std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()).count()
|
|
+ playbackDelayUs.load();
|
|
}
|
|
extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*);
|
|
extern "C" ma_result __real_ma_device_init(ma_context*, const ma_device_config*, ma_device*);
|
|
extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context* context) {
|
|
ma_backend backend = ma_backend_null; return __real_ma_context_init(&backend, 1, nullptr, context);
|
|
}
|
|
static void observeAudio(ma_device* device, void* out, const void* in, ma_uint32 frames) {
|
|
originalAudioCallback.load()(device, out, in, frames);
|
|
const auto* pcm = static_cast<const int16_t*>(out);
|
|
uint64_t left = 0, right = 0, nonzero = 0;
|
|
for (unsigned i = 0; i < frames; i++) {
|
|
left += static_cast<int64_t>(pcm[2 * i]) * pcm[2 * i];
|
|
right += static_cast<int64_t>(pcm[2 * i + 1]) * pcm[2 * i + 1];
|
|
nonzero += pcm[2 * i] != 0; nonzero += pcm[2 * i + 1] != 0;
|
|
if (pcm[2 * i] || pcm[2 * i + 1]) silentRun = 0;
|
|
else if (++silentRun == 48) silenceGaps.fetch_add(1);
|
|
}
|
|
leftEnergy.fetch_add(left); rightEnergy.fetch_add(right); nonzeroSamples.fetch_add(nonzero);
|
|
}
|
|
extern "C" ma_result __wrap_ma_device_init(ma_context* context, const ma_device_config* config, ma_device* device) {
|
|
ma_device_config copy = *config;
|
|
if (copy.deviceType == ma_device_type_playback && copy.playback.format == ma_format_s16 && copy.playback.channels == 2) {
|
|
originalAudioCallback = copy.dataCallback; copy.dataCallback = observeAudio;
|
|
}
|
|
return __real_ma_device_init(context, ©, device);
|
|
}
|
|
#endif
|
|
static void sourceEvent(UTUN_INSTANCE*, int type, const uint8_t* data, int size) {
|
|
if (type == CHAT_EVT_STREAM_SOURCES) snapshots.push_back(QByteArray(reinterpret_cast<const char*>(data), size));
|
|
}
|
|
|
|
class StreamViewTest : public QObject {
|
|
Q_OBJECT
|
|
UASYNC* ua = nullptr;
|
|
UTUN_INSTANCE* inst = nullptr;
|
|
STREAM_HANDLE* publisher = nullptr;
|
|
uint64_t group = 0, epoch = 0, frameId = 0, mediaPts = 0;
|
|
QTimer poll;
|
|
size_t allocations = 0;
|
|
uint32_t revision = 1;
|
|
uint16_t rotation = 90;
|
|
|
|
bool receive(uint64_t id, StreamRecord& record, int64_t position = -1) {
|
|
QElapsedTimer elapsed; elapsed.start();
|
|
while (elapsed.elapsed() < 1000) {
|
|
if (gui_stream_pull(id, record, position)) return true;
|
|
QTest::qWait(1);
|
|
}
|
|
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop test missing record id=%llu", (unsigned long long)id); return false;
|
|
}
|
|
|
|
void publish(const QByteArray& video, const QList<QByteArray>& audio = {}, unsigned videoFrames = 1) {
|
|
stream_media_block media{}; media.format = {160, 90, rotation, STREAM_MEDIA_H264, STREAM_MEDIA_OPUS_STEREO, revision};
|
|
mediaPts += 100000;
|
|
media.first_frame_offset = 0; media.video_count = videoFrames;
|
|
for (unsigned i = 0; i < videoFrames; i++) media.video[i] = {++frameId, mediaPts + i * 100000 / videoFrames,
|
|
static_cast<uint32_t>(video.size()), 0, static_cast<uint32_t>(video.size()), STREAM_MEDIA_KEY,
|
|
reinterpret_cast<const uint8_t*>(video.constData())};
|
|
if (!audio.isEmpty()) {
|
|
media.audio_count = audio.size();
|
|
for (unsigned i = 0; i < media.audio_count; i++) media.audio[i] = {mediaPts + i * 20000,
|
|
static_cast<uint32_t>(audio[i].size()), 960, reinterpret_cast<const uint8_t*>(audio[i].constData())};
|
|
}
|
|
uint8_t wire[7080]; size_t length = 0;
|
|
QCOMPARE(stream_media_encode(&media, wire, sizeof(wire), &length), STREAM_OK);
|
|
QCOMPARE(stream_publish_block(publisher, wire, length), STREAM_OK);
|
|
}
|
|
|
|
private slots:
|
|
void initTestCase() {
|
|
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_INFO);
|
|
if (qEnvironmentVariableIsSet("UTUN_TEST_DEBUG")) {
|
|
debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_TRACE);
|
|
debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_TRACE);
|
|
debug_set_category_level(DEBUG_CATEGORY_VIDEO, DEBUG_LEVEL_TRACE);
|
|
}
|
|
debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_ERROR);
|
|
allocations = u_get_allocated_count(); ua = uasync_create(); QVERIFY(ua);
|
|
inst = utun_instance_create_from_str(ua,
|
|
"[global]\nauto_sockets=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n");
|
|
QVERIFY(inst); QCOMPARE(utun_core_start(inst), 0); QCOMPARE(chat_service_start(inst), 0);
|
|
chat_core_create_channel_auto(inst, "Stream test");
|
|
sqlite3_stmt* statement = nullptr;
|
|
QCOMPARE(sqlite3_prepare_v2(chat_core_get_db(inst), "SELECT channel_id FROM channels LIMIT 1", -1, &statement, nullptr), SQLITE_OK);
|
|
QCOMPARE(sqlite3_step(statement), SQLITE_ROW);
|
|
group = QString::fromUtf8(reinterpret_cast<const char*>(sqlite3_column_text(statement, 0))).toULongLong();
|
|
sqlite3_finalize(statement); QVERIFY(group);
|
|
chat_event_set_handler(inst, sourceEvent); QVERIFY(gui_stream_watch(group));
|
|
QVERIFY(gui_stream_attach(inst)); uasync_mark_running(ua);
|
|
connect(&poll, &QTimer::timeout, this, [this] { uasync_poll(ua, 0); }); poll.start(1);
|
|
}
|
|
|
|
void zoomNativeAndFrameUpdates() {
|
|
VideoRenderWidget view(nullptr, true); view.resize(200, 150); view.show();
|
|
QImage frame = coordinateFrame(); view.setFrame(frame);
|
|
QPoint center(100, 75);
|
|
zoomVideo(view, 12000, center);
|
|
QSize physical = renderedVideo(view).size();
|
|
QPoint origin((frame.width() - physical.width()) / 2, (frame.height() - physical.height()) / 2);
|
|
QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin));
|
|
zoomVideo(view, 12000, center); // Верхняя граница достигается точно и не превышается.
|
|
QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin));
|
|
dragVideo(view, center, QPoint(5000, 5000));
|
|
QCOMPARE(renderedVideo(view), nativeCrop(frame, view, QPoint()));
|
|
frame.invertPixels(); view.setFrame(frame); // Следующий кадр сохраняет масштаб и рассматриваемый участок.
|
|
QCOMPARE(renderedVideo(view), nativeCrop(frame, view, QPoint()));
|
|
dragVideo(view, center, QPoint(-5000, -5000));
|
|
origin = QPoint(frame.width() - physical.width(), frame.height() - physical.height());
|
|
QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin));
|
|
view.resize(240, 180); QApplication::processEvents();
|
|
physical = renderedVideo(view).size(); origin = QPoint(frame.width() - physical.width(), frame.height() - physical.height());
|
|
QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin));
|
|
view.resize(1400, 1000); QApplication::processEvents(); // При 1:1 весь кадр временно помещается в большое окно.
|
|
view.resize(200, 150); QApplication::processEvents();
|
|
physical = renderedVideo(view).size(); origin = QPoint((frame.width() - physical.width()) / 2, (frame.height() - physical.height()) / 2);
|
|
QCOMPARE(renderedVideo(view), nativeCrop(frame, view, origin));
|
|
view.clearFrame();
|
|
QImage black = renderedVideo(view); black.fill(Qt::black); QCOMPARE(renderedVideo(view), black);
|
|
}
|
|
|
|
void zoomAnchorAndFineWheel() {
|
|
VideoRenderWidget view(nullptr, true); view.resize(200, 150); view.show();
|
|
QImage frame(1200, 900, QImage::Format_RGB32); frame.fill(Qt::red);
|
|
// Маркер находится под курсором (120, 80) в исходном вписанном кадре.
|
|
{ QPainter painter(&frame); painter.fillRect(QRect(700, 460, 40, 40), Qt::green); }
|
|
view.setFrame(frame); QPoint anchor(120, 80);
|
|
zoomVideo(view, 60, anchor); zoomVideo(view, 60, anchor);
|
|
zoomVideo(view, 30, anchor, true); // Устройство, присылающее только pixelDelta.
|
|
zoomVideo(view, 12000, anchor);
|
|
QImage actual = renderedVideo(view);
|
|
QPoint pixel(qRound(anchor.x() * view.devicePixelRatioF()), qRound(anchor.y() * view.devicePixelRatioF()));
|
|
QCOMPARE(actual.pixelColor(pixel), QColor(Qt::green));
|
|
QCOMPARE(actual.pixelColor(pixel + QPoint(25, 0)), QColor(Qt::red)); // Маркер при 1:1 имеет исходный размер.
|
|
QCOMPARE(actual.pixelColor(pixel - QPoint(25, 0)), QColor(Qt::red));
|
|
}
|
|
|
|
void zoomLetterboxAndReset_data() {
|
|
QTest::addColumn<QSize>("frameSize");
|
|
QTest::newRow("landscape") << QSize(1200, 300);
|
|
QTest::newRow("portrait") << QSize(300, 1200);
|
|
}
|
|
|
|
void zoomLetterboxAndReset() {
|
|
QFETCH(QSize, frameSize);
|
|
VideoRenderWidget view(nullptr, true); view.resize(200, 150); view.show();
|
|
QImage frame(frameSize, QImage::Format_RGB32); frame.fill(Qt::red); view.setFrame(frame);
|
|
QPoint center(100, 75); zoomVideo(view, 120, center);
|
|
QImage before = renderedVideo(view);
|
|
dragVideo(view, center, QPoint(5000, 5000));
|
|
QCOMPARE(renderedVideo(view), before); // Помещающаяся ось остаётся по центру, вторая полностью покрыта однотонным кадром.
|
|
QCOMPARE(before.pixelColor(0, 0), QColor(Qt::black));
|
|
QCOMPARE(before.pixelColor(before.width() / 2, before.height() / 2), QColor(Qt::red));
|
|
zoomVideo(view, -12000, center); QImage fitted = renderedVideo(view);
|
|
zoomVideo(view, -12000, center); QCOMPARE(renderedVideo(view), fitted);
|
|
dragVideo(view, center, QPoint(-5000, -5000)); QCOMPARE(renderedVideo(view), fitted);
|
|
zoomVideo(view, 12000, center);
|
|
QImage small(100, 80, QImage::Format_RGB32); small.fill(Qt::green); view.setFrame(small);
|
|
QImage expected = renderedVideo(view); expected.fill(Qt::black);
|
|
qreal dpr = view.devicePixelRatioF();
|
|
{ QPainter painter(&expected); painter.drawImage(QRectF((view.width() - 100 / dpr) / 2,
|
|
(view.height() - 80 / dpr) / 2, 100 / dpr, 80 / dpr), small, QRectF(small.rect())); }
|
|
QCOMPARE(renderedVideo(view), expected); // Смена размера сбрасывает zoom; маленький кадр остаётся в 1:1.
|
|
zoomVideo(view, 12000, center); QCOMPARE(renderedVideo(view), expected);
|
|
}
|
|
|
|
void zoomDragCancellationAndFullscreen() {
|
|
VideoRenderWidget view(nullptr, true); view.resize(200, 150); view.show();
|
|
QImage frame = coordinateFrame(); view.setFrame(frame); QPoint center(100, 75);
|
|
zoomVideo(view, 12000, center);
|
|
for (QEvent::Type type : {QEvent::UngrabMouse, QEvent::WindowDeactivate, QEvent::Hide}) {
|
|
QTest::mousePress(&view, Qt::LeftButton, Qt::NoModifier, center);
|
|
QEvent cancelled(type); QApplication::sendEvent(&view, &cancelled);
|
|
QImage before = renderedVideo(view); moveVideo(view, center + QPoint(20, 10));
|
|
QCOMPARE(renderedVideo(view), before);
|
|
QTest::mouseRelease(&view, Qt::LeftButton, Qt::NoModifier, center);
|
|
}
|
|
QImage before = renderedVideo(view);
|
|
QTest::mousePress(&view, Qt::LeftButton, Qt::NoModifier, center); moveVideo(view, center, Qt::NoButton);
|
|
moveVideo(view, center + QPoint(20, 10)); QCOMPARE(renderedVideo(view), before);
|
|
QTest::mouseRelease(&view, Qt::LeftButton, Qt::NoModifier, center);
|
|
view.showFullScreen(); QApplication::processEvents();
|
|
view.showNormal(); QApplication::processEvents(); QCOMPARE(renderedVideo(view), before);
|
|
VideoRenderWidget ordinary; ordinary.resize(200, 150); ordinary.show(); ordinary.setFrame(frame);
|
|
before = renderedVideo(ordinary); zoomVideo(ordinary, 12000, center);
|
|
dragVideo(ordinary, center, QPoint(5000, 5000)); QCOMPARE(renderedVideo(ordinary), before);
|
|
}
|
|
|
|
void catalogueWithoutViewer() {
|
|
QTRY_COMPARE(snapshots.size(), 1);
|
|
QCOMPARE(snapshots.last().mid(9), QByteArray("[]")); QVERIFY(!stream_service_needs_activity(inst));
|
|
QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK);
|
|
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch;
|
|
QTRY_COMPARE(snapshots.size(), 2);
|
|
auto sources = QJsonDocument::fromJson(snapshots.last().mid(9)).array(); QCOMPARE(sources.size(), 1);
|
|
QCOMPARE(stream_read_be(reinterpret_cast<const uint8_t*>(snapshots.last().constData()), 8), group);
|
|
QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u);
|
|
}
|
|
|
|
void staleSelectionAndCancellation() {
|
|
QVERIFY(gui_stream_open(100, group, inst->node_id, epoch ^ 1));
|
|
StreamRecord event;
|
|
QVERIFY(receive(100, event)); QCOMPARE(event.kind, StreamRecordKind::Closed);
|
|
QCOMPARE(event.reason, STREAM_CLOSE_SOURCE_CHANGED);
|
|
QVERIFY(gui_stream_open(101, group, inst->node_id, epoch)); gui_stream_stop(101); QTest::qWait(20);
|
|
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u);
|
|
}
|
|
|
|
void mediaAndPressure() {
|
|
QVERIFY(gui_stream_open(102, group, inst->node_id, epoch)); StreamRecord event;
|
|
QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Opened);
|
|
const QByteArray frame(1500, 'k'); const QByteArray audio("opus"); publish(frame, {audio});
|
|
QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Video); QCOMPARE(event.data, frame);
|
|
QCOMPARE(event.format.rotation, uint16_t(90));
|
|
QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Audio); QCOMPARE(event.data, audio);
|
|
for (int i = 0; i < 270; i++) { publish(frame); uasync_poll(ua, 0); }
|
|
unsigned records = 0;
|
|
QElapsedTimer held; held.start();
|
|
while (held.elapsed() < 30) {
|
|
if (gui_stream_pull(102, event, 100000, false, 0)) { QVERIFY(event.kind != StreamRecordKind::Gap); records++; }
|
|
QTest::qWait(1);
|
|
}
|
|
QVERIFY(records <= 2); /* Накопленные 27 секунд остаются в RS, а не в очереди frontend. */
|
|
QVERIFY(receive(102, event, 200000)); QCOMPARE(event.kind, StreamRecordKind::Video);
|
|
QCOMPARE(event.pts, uint64_t(200000)); QCOMPARE(event.data, frame);
|
|
gui_stream_stop(102); QTest::qWait(20);
|
|
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u);
|
|
}
|
|
|
|
void realH264RenderingAndClose() {
|
|
#ifdef HAVE_FFMPEG
|
|
/* Фикстура протокола выше содержит фиктивный H264; реальный decoder получает отдельную эпоху. */
|
|
QCOMPARE(stream_publish_finish(publisher), STREAM_OK); stream_close(publisher); publisher = nullptr;
|
|
QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK);
|
|
stream_info fresh{}; QCOMPARE(stream_get_info(publisher, &fresh), STREAM_OK); epoch = fresh.descriptor.source_epoch;
|
|
mediaPts = 0; frameId = 0; revision = 1;
|
|
const AVCodec* encoder = avcodec_find_encoder(AV_CODEC_ID_H264);
|
|
if (!encoder) QSKIP("H264 encoder is unavailable for the decoder fixture");
|
|
AVCodecContext* context = avcodec_alloc_context3(encoder); QVERIFY(context);
|
|
context->width = 160; context->height = 90; context->pix_fmt = AV_PIX_FMT_YUV420P;
|
|
context->time_base = {1, 10}; context->framerate = {10, 1}; context->max_b_frames = 0;
|
|
AVDictionary* options = nullptr; av_dict_set(&options, "preset", "ultrafast", 0); av_dict_set(&options, "tune", "zerolatency", 0);
|
|
QCOMPARE(avcodec_open2(context, encoder, &options), 0); av_dict_free(&options);
|
|
AVFrame* frame = av_frame_alloc(); QVERIFY(frame); frame->width = 160; frame->height = 90; frame->format = context->pix_fmt;
|
|
QCOMPARE(av_frame_get_buffer(frame, 32), 0);
|
|
for (int plane = 0; plane < 3; plane++) for (int y = 0; y < (plane ? 45 : 90); y++)
|
|
memset(frame->data[plane] + y * frame->linesize[plane], plane ? 128 : 80, plane ? 80 : 160);
|
|
frame->pts = 0; QCOMPARE(avcodec_send_frame(context, frame), 0);
|
|
AVPacket* packet = av_packet_alloc(); QVERIFY(packet); QCOMPARE(avcodec_receive_packet(context, packet), 0);
|
|
QByteArray video(4, '\0'); video.append(reinterpret_cast<const char*>(packet->data), packet->size);
|
|
av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&context);
|
|
std::unique_ptr<opus_codec_encoder_t, decltype(&opus_codec_encoder_destroy)> opus(nullptr, opus_codec_encoder_destroy);
|
|
uint64_t sampleFrame = 0;
|
|
#ifdef TEST_NULL_AUDIO
|
|
QVERIFY(SoundManager::instance()->init());
|
|
opus.reset(opus_codec_encoder_create(48000, 2)); QVERIFY(opus);
|
|
QCOMPARE(opus_codec_encoder_application_set(opus.get(), OPUS_CODEC_APP_AUDIO), 0);
|
|
QCOMPARE(opus_codec_encoder_bitrate_set(opus.get(), 128000), 0);
|
|
nonzeroSamples = 0; leftEnergy = rightEnergy = 0; playbackDelayUs = 200000;
|
|
#endif
|
|
StreamPlayerWindow window(group, inst->node_id, epoch, "Source");
|
|
QSignalSpy shown(&window, &StreamPlayerWindow::framePresented); window.show();
|
|
auto audioPackets = [&opus, &sampleFrame] {
|
|
QList<QByteArray> packets;
|
|
if (opus) for (unsigned frame = 0; frame < 5; frame++) {
|
|
int16_t pcm[1920]; uint8_t encoded[1275];
|
|
for (unsigned i = 0; i < 960; i++) {
|
|
pcm[2 * i] = static_cast<int16_t>(6000 * std::sin((sampleFrame + i) * 0.05));
|
|
pcm[2 * i + 1] = static_cast<int16_t>(1500 * std::sin((sampleFrame + i) * 0.13));
|
|
}
|
|
sampleFrame += 960;
|
|
int length = opus_codec_encode(opus.get(), pcm, 960, encoded, sizeof(encoded));
|
|
if (length <= 0) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop startup fixture Opus encode failed result=%d", length);
|
|
return QList<QByteArray>{};
|
|
}
|
|
packets.append(QByteArray(reinterpret_cast<const char*>(encoded), length));
|
|
}
|
|
return packets;
|
|
};
|
|
#ifdef TEST_NULL_AUDIO
|
|
QTest::qWait(20);
|
|
for (unsigned batch = 0; batch < 8; batch++) { publish({}, audioPackets(), 0); uasync_poll(ua, 0); }
|
|
QTest::qWait(200);
|
|
QCOMPARE(shown.count(), 0);
|
|
QCOMPARE(nonzeroSamples.load(), uint64_t(0));
|
|
#endif
|
|
QTimer sender;
|
|
connect(&sender, &QTimer::timeout, this, [this, video, &audioPackets] {
|
|
publish(video, audioPackets(), 3);
|
|
});
|
|
sender.start(100);
|
|
QTRY_VERIFY_WITH_TIMEOUT(shown.count() > 0, 5000);
|
|
QTest::qWait(400); // Пройти старт декодера и физическую задержку тестового устройства.
|
|
#ifdef TEST_NULL_AUDIO
|
|
uint64_t gapsBefore = silenceGaps.load();
|
|
#endif
|
|
int beforeSteady = shown.count(); QTest::qWait(1500);
|
|
QVERIFY2(shown.count() >= beforeSteady + 30, "30fps video must keep advancing with 200ms physical audio latency");
|
|
#ifdef TEST_NULL_AUDIO
|
|
QCOMPARE(silenceGaps.load(), gapsBefore);
|
|
#endif
|
|
int beforeSwitch = shown.count(); revision++; rotation = 270;
|
|
QTRY_VERIFY_WITH_TIMEOUT(shown.count() > beforeSwitch + 2, 5000);
|
|
stream_info switched{}; QCOMPARE(stream_get_info(publisher, &switched), STREAM_OK);
|
|
QCOMPARE(switched.descriptor.source_epoch, epoch); QCOMPARE(switched.owners, 2u);
|
|
#ifdef TEST_NULL_AUDIO
|
|
QTRY_VERIFY_WITH_TIMEOUT(nonzeroSamples.load() > 0, 5000);
|
|
QVERIFY(rightEnergy.load() > 0);
|
|
qInfo("Stereo playback energies: left=%llu right=%llu ratio=%.3f",
|
|
(unsigned long long)leftEnergy.load(), (unsigned long long)rightEnergy.load(),
|
|
static_cast<double>(leftEnergy.load()) / rightEnergy.load());
|
|
QVERIFY2(leftEnergy.load() > rightEnergy.load() * 8 && leftEnergy.load() < rightEnergy.load() * 25,
|
|
"Independent stereo channels must reach the playback device without downmix");
|
|
auto* volume = window.findChild<QSlider*>(); QVERIFY(volume); volume->setValue(0);
|
|
QTest::qWait(50); uint64_t played = nonzeroSamples.load(); QTest::qWait(100); QCOMPARE(nonzeroSamples.load(), played);
|
|
#endif
|
|
sender.stop(); window.close(); QTest::qWait(20);
|
|
#ifdef TEST_NULL_AUDIO
|
|
playbackDelayUs = 0;
|
|
#endif
|
|
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u);
|
|
#else
|
|
QSKIP("FFmpeg is disabled");
|
|
#endif
|
|
}
|
|
|
|
void modernCodecPlayback() {
|
|
#ifdef HAVE_FFMPEG
|
|
debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_DEBUG);
|
|
struct Fixture { uint8_t type; const char* encoder; };
|
|
const Fixture fixtures[] = {{STREAM_MEDIA_H265, "libx265"}, {STREAM_MEDIA_VP9, "libvpx-vp9"}, {STREAM_MEDIA_AV1, "libaom-av1"}};
|
|
for (const auto& fixture : fixtures) {
|
|
const AVCodec* encoder = avcodec_find_encoder_by_name(fixture.encoder); QVERIFY2(encoder, fixture.encoder);
|
|
AVCodecContext* context = avcodec_alloc_context3(encoder); QVERIFY(context);
|
|
context->width = 160; context->height = 90; context->pix_fmt = AV_PIX_FMT_YUV420P; context->bit_rate = 200000;
|
|
context->time_base = {1, 30}; context->framerate = {30, 1}; context->max_b_frames = 0; context->thread_count = 1; context->gop_size = 1;
|
|
context->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
|
|
AVDictionary* options = nullptr;
|
|
if (fixture.type == STREAM_MEDIA_H265) {
|
|
av_dict_set(&options, "preset", "ultrafast", 0); av_dict_set(&options, "tune", "zerolatency", 0);
|
|
av_dict_set(&options, "x265-params", "pools=none:frame-threads=1:log-level=error:bframes=0:repeat-headers=1", 0);
|
|
} else {
|
|
av_dict_set(&options, "lag-in-frames", "0", 0); av_dict_set(&options, "cpu-used", "8", 0);
|
|
av_dict_set(&options, fixture.type == STREAM_MEDIA_VP9 ? "deadline" : "usage", "realtime", 0);
|
|
}
|
|
QCOMPARE(avcodec_open2(context, encoder, &options), 0); av_dict_free(&options);
|
|
AVFrame* frame = av_frame_alloc(); QVERIFY(frame); frame->width = 160; frame->height = 90; frame->format = context->pix_fmt;
|
|
QCOMPARE(av_frame_get_buffer(frame, 32), 0);
|
|
for (int plane = 0; plane < 3; plane++) for (int y = 0; y < (plane ? 45 : 90); y++)
|
|
memset(frame->data[plane] + y * frame->linesize[plane], plane ? 128 : 80, plane ? 80 : 160);
|
|
frame->pts = 0; QCOMPARE(avcodec_send_frame(context, frame), 0);
|
|
AVPacket* packet = av_packet_alloc(); QVERIFY(packet);
|
|
int received = avcodec_receive_packet(context, packet);
|
|
if (received == AVERROR(EAGAIN)) { QCOMPARE(avcodec_send_frame(context, nullptr), 0); received = avcodec_receive_packet(context, packet); }
|
|
QCOMPARE(received, 0); QVERIFY(packet->flags & AV_PKT_FLAG_KEY);
|
|
QByteArray video(4, '\0');
|
|
stream_write_be(reinterpret_cast<uint8_t*>(video.data()), context->extradata_size, 4);
|
|
if (context->extradata_size) video.append(reinterpret_cast<const char*>(context->extradata), context->extradata_size);
|
|
video.append(reinterpret_cast<const char*>(packet->data), packet->size);
|
|
av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&context);
|
|
QCOMPARE(stream_publish_finish(publisher), STREAM_OK); stream_close(publisher); publisher = nullptr;
|
|
QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK);
|
|
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch;
|
|
StreamPlayerWindow window(group, inst->node_id, epoch, fixture.encoder); QSignalSpy shown(&window, &StreamPlayerWindow::framePresented);
|
|
window.show();
|
|
stream_media_block media{}; media.format = {160, 90, 0, fixture.type, 0, 1}; media.video_count = 1;
|
|
media.video[0] = {1, 0, (uint32_t)video.size(), 0, (uint32_t)video.size(), STREAM_MEDIA_KEY,
|
|
reinterpret_cast<const uint8_t*>(video.constData())};
|
|
uint8_t wire[7345]; size_t length = 0;
|
|
QCOMPARE(stream_media_encode(&media, wire, sizeof(wire), &length), STREAM_OK);
|
|
QCOMPARE(stream_publish_block(publisher, wire, length), STREAM_OK);
|
|
QTRY_VERIFY_WITH_TIMEOUT(shown.count() > 0, 2000);
|
|
QCOMPARE(stream_publish_finish(publisher), STREAM_OK);
|
|
QTRY_VERIFY_WITH_TIMEOUT(stream_get_info(publisher, &info) == STREAM_OK && info.owners == 1, 2000);
|
|
window.close(); QTest::qWait(20);
|
|
}
|
|
debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_INFO);
|
|
#endif
|
|
}
|
|
|
|
void delayedDecoderAndShortStreamDrain() {
|
|
#if defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG)
|
|
const AVCodec* encoder = avcodec_find_encoder_by_name("libx264"); QVERIFY(encoder);
|
|
AVCodecContext* context = avcodec_alloc_context3(encoder); QVERIFY(context);
|
|
context->width = 160; context->height = 90; context->pix_fmt = AV_PIX_FMT_YUV420P;
|
|
context->time_base = {1, 25}; context->max_b_frames = 0;
|
|
AVDictionary* options = nullptr; av_dict_set(&options, "preset", "ultrafast", 0); av_dict_set(&options, "tune", "zerolatency", 0);
|
|
QCOMPARE(avcodec_open2(context, encoder, &options), 0); av_dict_free(&options);
|
|
AVFrame* frame = av_frame_alloc(); QVERIFY(frame); frame->width = 160; frame->height = 90; frame->format = context->pix_fmt;
|
|
QCOMPARE(av_frame_get_buffer(frame, 32), 0);
|
|
for (int plane = 0; plane < 3; plane++) for (int y = 0; y < (plane ? 45 : 90); y++)
|
|
memset(frame->data[plane] + y * frame->linesize[plane], plane ? 128 : 80, plane ? 80 : 160);
|
|
frame->pts = 0; QCOMPARE(avcodec_send_frame(context, frame), 0);
|
|
AVPacket* packet = av_packet_alloc(); QVERIFY(packet); QCOMPARE(avcodec_receive_packet(context, packet), 0);
|
|
QByteArray access(reinterpret_cast<const char*>(packet->data), packet->size);
|
|
av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&context);
|
|
struct Delay { Delay() { delayedDecoder = true; } ~Delay() { delayedDecoder = false; } } delay;
|
|
for (unsigned count : {3u, 8u}) {
|
|
QCOMPARE(stream_publish_finish(publisher), STREAM_OK); stream_close(publisher); publisher = nullptr;
|
|
QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK);
|
|
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch;
|
|
StreamPlayerWindow window(group, inst->node_id, epoch, "Delayed decoder"); window.show();
|
|
QSignalSpy shown(&window, &StreamPlayerWindow::framePresented);
|
|
for (unsigned i = 0; i < count; i++) {
|
|
QByteArray data = i ? access : QByteArray(4, '\0') + access;
|
|
stream_media_block media{}; media.format = {160, 90, 0, STREAM_MEDIA_H264, 0, 1}; media.video_count = 1;
|
|
media.video[0] = {i + 1, i * 40000u, (uint32_t)data.size(), 0, (uint32_t)data.size(),
|
|
(uint8_t)(i ? 0 : STREAM_MEDIA_KEY), reinterpret_cast<const uint8_t*>(data.constData())};
|
|
uint8_t wire[7345]; size_t length = 0;
|
|
QCOMPARE(stream_media_encode(&media, wire, sizeof(wire), &length), STREAM_OK);
|
|
QCOMPARE(stream_publish_block(publisher, wire, length), STREAM_OK); QTest::qWait(i ? 1 : 30);
|
|
}
|
|
QTest::qWait(100);
|
|
if (count == 3) QCOMPARE(shown.count(), 0);
|
|
else QTRY_VERIFY_WITH_TIMEOUT(shown.count() > 0, 1500);
|
|
QCOMPARE(stream_publish_finish(publisher), STREAM_OK);
|
|
QTRY_COMPARE_WITH_TIMEOUT(shown.count(), (int)count, 2000);
|
|
QTRY_VERIFY_WITH_TIMEOUT(stream_get_info(publisher, &info) == STREAM_OK && info.owners == 1, 2000);
|
|
window.close(); QTest::qWait(10);
|
|
}
|
|
#else
|
|
QSKIP("Delayed FFmpeg decoder fixture requires Linux and FFmpeg");
|
|
#endif
|
|
}
|
|
|
|
void finishAndDetachPendingOwner() {
|
|
QCOMPARE(stream_publish_finish(publisher), STREAM_OK); QTRY_COMPARE(snapshots.last().mid(9), QByteArray("[]"));
|
|
stream_close(publisher); publisher = nullptr;
|
|
QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK);
|
|
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QVERIFY(info.descriptor.source_epoch != epoch);
|
|
QVERIFY(gui_stream_open(103, group, inst->node_id, epoch)); StreamRecord event;
|
|
QVERIFY(receive(103, event)); QCOMPARE(event.reason, STREAM_CLOSE_SOURCE_CHANGED);
|
|
QVERIFY(gui_stream_open(104, group, inst->node_id, info.descriptor.source_epoch));
|
|
gui_stream_detach(inst); QVERIFY(!gui_stream_ready()); QTest::qWait(20);
|
|
QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u);
|
|
}
|
|
|
|
void channelClosureNotifiesViewer() {
|
|
QVERIFY(gui_stream_attach(inst)); QVERIFY(gui_stream_watch(group));
|
|
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK);
|
|
QVERIFY(gui_stream_open(105, group, inst->node_id, info.descriptor.source_epoch));
|
|
StreamRecord event; QVERIFY(receive(105, event)); QCOMPARE(event.kind, StreamRecordKind::Opened);
|
|
QByteArray channel = QByteArray::number(group); chat_core_delete_channel(inst, channel.constData());
|
|
QVERIFY(receive(105, event)); QCOMPARE(event.kind, StreamRecordKind::Closed); QCOMPARE(event.reason, STREAM_CLOSE_GROUP);
|
|
QTRY_COMPARE(snapshots.last().mid(9), QByteArray("[]"));
|
|
QVERIFY(!stream_handle_active(publisher)); QCOMPARE(stream_handle_reason(publisher), STREAM_CLOSE_GROUP);
|
|
}
|
|
|
|
void cleanupTestCase() {
|
|
poll.stop(); gui_stream_detach(inst); stream_close(publisher); uasync_mark_stopped(ua);
|
|
SoundManager::instance()->shutdown();
|
|
utun_instance_destroy(inst); uasync_destroy(ua, 0);
|
|
QCOMPARE(u_get_allocated_count(), allocations);
|
|
}
|
|
};
|
|
QTEST_MAIN(StreamViewTest)
|
|
#include "test_stream_view.moc"
|