From 3b3ba3c4958ab1b217477866b289b8a565b4d1e3 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 14 Oct 2026 21:46:13 +0200 Subject: [PATCH] Add verified call profiles and defer bitrate replacement until IDR --- tools/chatgui/src/call_video_encoder.cpp | 92 ++++++++++++++++++++++++ tools/chatgui/src/call_video_encoder.h | 30 ++++++++ tools/chatgui/src/live_video_encoder.cpp | 76 +++++++++++++++++--- 3 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 tools/chatgui/src/call_video_encoder.cpp create mode 100644 tools/chatgui/src/call_video_encoder.h diff --git a/tools/chatgui/src/call_video_encoder.cpp b/tools/chatgui/src/call_video_encoder.cpp new file mode 100644 index 00000000..5eca0fdb --- /dev/null +++ b/tools/chatgui/src/call_video_encoder.cpp @@ -0,0 +1,92 @@ +#include "call_video_encoder.h" +#include "debug_config.h" +#include "../../../src/call/call_video_rate.h" +#include + +// Профиль и envelope задаются явно: автоматический выбор level при смене bitrate запрещён. +bool CallVideoEncoder::open(const LiveVideoConfig& config, uint64_t generation) { + if (!generation || config.profile == LiveVideoProfile::Automatic || config.references != 1 + || config.bitrate < CALL_VIDEO_RATE_MIN_BPS || config.bitrate > CALL_VIDEO_RATE_MAX_BPS) { + m_error = "Invalid call video encoder configuration"; + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call video open invalid generation=%llu bitrate=%d profile=%d refs=%d", + (unsigned long long)generation, config.bitrate, (int)config.profile, config.references); return false; + } + auto candidate = std::make_unique(); + if (!candidate->open(config)) { + m_error = candidate->error(); DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call video open failed: %s", qPrintable(m_error)); return false; + } + close(); m_encoder = std::move(candidate); m_config = config; m_config.backend = m_encoder->name(); m_generation = generation; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call video encoder opened generation=%llu codec=%d encoder=%s bitrate=%d level=%d", + (unsigned long long)generation, config.codec, qPrintable(m_config.backend), config.bitrate, config.level); return true; +} + +void CallVideoEncoder::close() { + if (m_encoder) DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call video encoder close generation=%llu pending_request=%llu", + (unsigned long long)m_generation, (unsigned long long)m_request); + m_encoder.reset(); m_config = {}; m_config.bitrate = 0; m_error.clear(); + m_generation = m_request = m_lastRequest = m_lastPts = m_lastKey = 0; m_pendingBitrate = 0; m_haveFrame = false; +} + +// Ядро сериализует команды; повторные/чужие requests не заменяют уже ожидающую команду. +bool CallVideoEncoder::requestBitrate(uint64_t generation, uint64_t request, uint32_t bitrate) { + if (!m_encoder || generation != m_generation || !request || request <= m_lastRequest || m_request + || bitrate < CALL_VIDEO_RATE_MIN_BPS || bitrate > CALL_VIDEO_RATE_MAX_BPS) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "call video bitrate rejected generation=%llu/%llu request=%llu last=%llu pending=%llu bitrate=%u", + (unsigned long long)generation, (unsigned long long)m_generation, (unsigned long long)request, + (unsigned long long)m_lastRequest, (unsigned long long)m_request, bitrate); return false; + } + m_request = m_lastRequest = request; m_pendingBitrate = bitrate; + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call video bitrate queued generation=%llu request=%llu applied=%d target=%u last_key=%llu", + (unsigned long long)generation, (unsigned long long)request, m_config.bitrate, bitrate, (unsigned long long)m_lastKey); return true; +} + +// Capture PTS продолжает идти при backpressure, поэтому запрет отправки не задерживает применение. +bool CallVideoEncoder::encode(const QImage& image, uint64_t pts, bool forceKey, LiveEncodedFrame& output, + CallVideoRateCompletion& completion) { + completion = {}; + if (!m_encoder || image.isNull() || pts > INT64_MAX || (m_haveFrame && pts <= m_lastPts)) { + m_error = "Invalid call video frame or non-monotonic PTS"; + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call video encode invalid generation=%llu pts=%llu last=%llu active=%d image_null=%d", + (unsigned long long)m_generation, (unsigned long long)pts, (unsigned long long)m_lastPts, + m_encoder != nullptr, image.isNull()); return false; + } + bool key = forceKey || !m_haveFrame || pts - m_lastKey >= 1000000; + QList frames; + if (key && m_request) { + completion = {m_generation, m_request, 0}; + if (m_pendingBitrate == (uint32_t)m_config.bitrate) completion.bitrate = m_pendingBitrate; + else { + LiveVideoConfig next = m_config; next.bitrate = m_pendingBitrate; + auto candidate = std::make_unique(); QElapsedTimer timer; timer.start(); + bool ready = candidate->open(next) && candidate->encode(image, pts, true, frames) + && frames.size() == 1 && frames.front().key && frames.front().pts == pts; + if (ready) { + m_encoder = std::move(candidate); m_config = next; completion.bitrate = m_pendingBitrate; + DEBUG_INFO(DEBUG_CATEGORY_CALL, + "call video bitrate committed generation=%llu request=%llu bitrate=%u pts=%llu prepare_ms=%lld bytes=%lld", + (unsigned long long)m_generation, (unsigned long long)m_request, completion.bitrate, + (unsigned long long)pts, (long long)timer.elapsed(), (long long)frames.front().data.size()); + } else { + m_error = candidate->error(); if (m_error.isEmpty()) m_error = "Replacement encoder did not produce an immediate IDR"; + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call video replacement failed generation=%llu request=%llu target=%u: %s", + (unsigned long long)m_generation, (unsigned long long)m_request, m_pendingBitrate, qPrintable(m_error)); + frames.clear(); + } + } + m_request = 0; m_pendingBitrate = 0; + } + if (frames.isEmpty() && !m_encoder->encode(image, pts, key, frames)) { + m_error = m_encoder->error(); + if (completion.request) completion.bitrate = 0; + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call video encoding failed generation=%llu: %s", + (unsigned long long)m_generation, qPrintable(m_error)); return false; + } + if (frames.size() != 1 || frames.front().pts != pts || (key && !frames.front().key)) { + m_error = "Call encoder delayed output or failed to produce IDR"; + if (completion.request) completion.bitrate = 0; + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call video output contract failed generation=%llu pts=%llu frames=%lld key_requested=%d", + (unsigned long long)m_generation, (unsigned long long)pts, (long long)frames.size(), key); return false; + } + m_haveFrame = true; m_lastPts = pts; if (frames.front().key) m_lastKey = pts; + output = std::move(frames.front()); return true; +} diff --git a/tools/chatgui/src/call_video_encoder.h b/tools/chatgui/src/call_video_encoder.h new file mode 100644 index 00000000..61901d5f --- /dev/null +++ b/tools/chatgui/src/call_video_encoder.h @@ -0,0 +1,30 @@ +/* Кодер видеотрека звонка; только video worker, без камеры, транспорта и таймеров. + * Смена bitrate ожидает ближайший IDR (период <= 1с по capture PTS). Новый encoder + * заменяет старый только после полного первого IDR. Completion передаётся в uasync + * независимо от admission видео. PTS, generation и frame_id принадлежат владельцу. + * open получает уже согласованный profile/level/VBV; сетевой CAPS проверяет владелец. */ +#pragma once +#include "live_video_encoder.h" + +struct CallVideoRateCompletion { + uint64_t generation = 0, request = 0; + uint32_t bitrate = 0; // request != 0, bitrate == 0 — отказ; request == 0 — нет результата. +}; + +class CallVideoEncoder { +public: + bool open(const LiveVideoConfig& config, uint64_t generation); + void close(); + bool requestBitrate(uint64_t generation, uint64_t request, uint32_t bitrate); + // Один вход -> один выход. На false output не меняется; completion нужно обработать и на ошибке. + bool encode(const QImage& image, uint64_t pts, bool forceKey, LiveEncodedFrame& output, CallVideoRateCompletion& completion); + uint32_t bitrate() const { return m_config.bitrate; } + QString error() const { return m_error; } +private: + std::unique_ptr m_encoder; + LiveVideoConfig m_config; + QString m_error; + uint64_t m_generation = 0, m_request = 0, m_lastRequest = 0, m_lastPts = 0, m_lastKey = 0; + uint32_t m_pendingBitrate = 0; + bool m_haveFrame = false; +}; diff --git a/tools/chatgui/src/live_video_encoder.cpp b/tools/chatgui/src/live_video_encoder.cpp index b79360bf..1ec3450b 100644 --- a/tools/chatgui/src/live_video_encoder.cpp +++ b/tools/chatgui/src/live_video_encoder.cpp @@ -66,18 +66,62 @@ AVPixelFormat softwareFormat(const AVCodec* codec) { } return AV_PIX_FMT_NONE; } + +// Проверяем фактический bitstream: одного принятого encoder option недостаточно. +bool verifiedProfile(const LiveVideoConfig& config, const QList& output) { + if (output.size() != 1 || !output.front().key) return false; + const auto& data = output.front().data; + const uint8_t *header, *frame; size_t headerSize, frameSize; + if (stream_media_key_parse(reinterpret_cast(data.constData()), data.size(), + &header, &headerSize, &frame, &frameSize) != STREAM_OK) return false; + QByteArray annex(reinterpret_cast(header), headerSize); annex.append(reinterpret_cast(frame), frameSize); + bool idr = false, mainTier = config.codec == STREAM_MEDIA_H264; + for (qsizetype i = 0; i + 4 < annex.size(); i++) { + const auto* p = reinterpret_cast(annex.constData() + i); + int prefix = p[0] == 0 && p[1] == 0 ? (p[2] == 1 ? 3 : p[2] == 0 && p[3] == 1 ? 4 : 0) : 0; + if (!prefix || i + prefix >= annex.size()) continue; + int type = config.codec == STREAM_MEDIA_H264 ? p[prefix] & 31 : (p[prefix] >> 1) & 63; + idr |= config.codec == STREAM_MEDIA_H264 ? type == 5 : type == 19 || type == 20; + if (config.codec == STREAM_MEDIA_H265 && type == 33 && i + prefix + 3 < annex.size()) + mainTier = (p[prefix + 3] & 0x20) == 0; + } + const AVCodec* codec = avcodec_find_decoder(config.codec == STREAM_MEDIA_H264 ? AV_CODEC_ID_H264 : AV_CODEC_ID_HEVC); + AVCodecContext* decoder = codec ? avcodec_alloc_context3(codec) : nullptr; + AVPacket* packet = av_packet_alloc(); AVFrame* decoded = av_frame_alloc(); + int result = decoder && packet && decoded ? 0 : AVERROR(ENOMEM); + if (result >= 0) { decoder->thread_count = 1; result = avcodec_open2(decoder, codec, nullptr); } + if (result >= 0) result = av_new_packet(packet, annex.size()); + if (result >= 0) { memcpy(packet->data, annex.constData(), annex.size()); result = avcodec_send_packet(decoder, packet); } + if (result >= 0) result = avcodec_receive_frame(decoder, decoded); + int profile = config.codec == STREAM_MEDIA_H264 ? AV_PROFILE_H264_CONSTRAINED_BASELINE : AV_PROFILE_HEVC_MAIN; + bool valid = result >= 0 && idr && mainTier && decoder->profile == profile && decoder->level == config.level + && !decoder->has_b_frames && decoded->format == AV_PIX_FMT_YUV420P + && decoded->width == config.width && decoded->height == config.height; + if (!valid) DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, + "live profile verification failed codec=%d result=%d idr=%d main_tier=%d profile=%d/%d level=%d/%d pixel=%d B=%d", + config.codec, result, idr, mainTier, decoder ? decoder->profile : -1, profile, + decoder ? decoder->level : -1, config.level, decoded ? decoded->format : -1, decoder ? decoder->has_b_frames : -1); + av_frame_free(&decoded); av_packet_free(&packet); avcodec_free_context(&decoder); return valid; +} } #endif bool LiveVideoEncoder::open(const LiveVideoConfig& config) { close(); + bool constrained = config.profile != LiveVideoProfile::Automatic; if (config.width < 16 || config.height < 16 || config.width > 4096 || config.height > 4096 || config.width % 2 || config.height % 2 || config.fps < 1 || config.fps > 60 - || config.bitrate < 64000 || config.bitrate > 16000000 || config.codec < 1 || config.codec > 5) { + || config.bitrate < 64000 || config.bitrate > 16000000 || config.codec < 1 || config.codec > 5 + || config.vbvBits < 0 || config.vbvBits > 16000000 || config.references < 0 || config.references > 16 + || (constrained && (config.level <= 0 || config.level > 255 || config.references != 1 || !config.vbvBits + || !((config.codec == 1 && config.profile == LiveVideoProfile::H264ConstrainedBaseline) + || (config.codec == 2 && config.profile == LiveVideoProfile::H265Main))))) { m->error = "Invalid video configuration"; DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop encoder invalid configuration"); return false; } #ifdef HAVE_FFMPEG for (const auto& name : encoders(config.codec)) { + if (!config.backend.isEmpty() && name != config.backend.toLatin1()) continue; + if (constrained && name != "libx264" && name != "libx265") continue; const AVCodec* encoder = avcodec_find_encoder_by_name(name.constData()); if (!encoder) continue; // Проба не оставляет references/PTS в live-контексте: после двух KEY encoder открывается заново. @@ -87,12 +131,14 @@ bool LiveVideoEncoder::open(const LiveVideoConfig& config) { if (!m->codec) { m->error = "Video encoder allocation failed"; break; } auto* c = m->codec; c->width = config.width; c->height = config.height; c->time_base = {1, 1000000}; c->framerate = {config.fps, 1}; - c->bit_rate = config.bitrate; c->rc_max_rate = config.bitrate; c->rc_buffer_size = config.bitrate / 4; + c->bit_rate = config.bitrate; c->rc_max_rate = config.bitrate; + c->rc_buffer_size = config.vbvBits ? config.vbvBits : config.bitrate / 4; c->gop_size = config.fps; c->max_b_frames = 0; c->thread_count = 2; + if (config.references) c->refs = config.references; c->flags |= AV_CODEC_FLAG_GLOBAL_HEADER | AV_CODEC_FLAG_CLOSED_GOP; c->color_range = AVCOL_RANGE_MPEG; c->colorspace = AVCOL_SPC_BT709; c->color_primaries = AVCOL_PRI_BT709; c->color_trc = AVCOL_TRC_BT709; - AVPixelFormat sw = softwareFormat(encoder); + AVPixelFormat sw = constrained ? AV_PIX_FMT_YUV420P : softwareFormat(encoder); int result = 0; if (name.endsWith("_vaapi")) { sw = AV_PIX_FMT_NV12; c->pix_fmt = AV_PIX_FMT_VAAPI; @@ -112,8 +158,17 @@ bool LiveVideoEncoder::open(const LiveVideoConfig& config) { if (name == "libx264" || name == "libx265") { av_dict_set(&options, "preset", "ultrafast", 0); av_dict_set(&options, "tune", "zerolatency", 0); av_dict_set(&options, "forced-idr", "1", 0); - if (name == "libx264") av_dict_set(&options, "x264-params", "scenecut=0:open-gop=0:repeat-headers=0", 0); - else av_dict_set(&options, "x265-params", "bframes=0:rc-lookahead=0:open-gop=0:pools=1:frame-threads=1:log-level=error", 0); + QByteArray params = name == "libx264" ? "scenecut=0:open-gop=0:repeat-headers=0" + : "bframes=0:rc-lookahead=0:open-gop=0:pools=1:frame-threads=1:log-level=error"; + if (constrained) { + c->thread_count = 1; + av_dict_set(&options, "profile", name == "libx264" ? "baseline" : "main", 0); + QByteArray level = QByteArray::number(config.level / (name == "libx264" ? 10.0 : 30.0), 'f', 1); + params += ":ref=1:"; + if (name == "libx264") params += "level=" + level; + else params += "level-idc=" + level + ":high-tier=0:hrd=0:info=0:scenecut=0"; + } + av_dict_set(&options, name == "libx264" ? "x264-params" : "x265-params", params.constData(), 0); } else if (name.endsWith("_nvenc")) { av_dict_set(&options, "preset", "p1", 0); av_dict_set(&options, "tune", "ull", 0); av_dict_set(&options, "zerolatency", "1", 0); av_dict_set(&options, "rc-lookahead", "0", 0); @@ -162,10 +217,15 @@ bool LiveVideoEncoder::open(const LiveVideoConfig& config) { DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "desktop encoder cannot force an independent KEY name=%s error=%s", name.constData(), qPrintable(m->error)); break; } + if (constrained && !verifiedProfile(config, output)) { + m->error = "Encoder output violates the requested profile"; break; + } continue; } - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop encoder ready name=%s codec=%d size=%dx%d fps=%d bitrate=%d two_KEYs=verified", - name.constData(), config.codec, config.width, config.height, config.fps, config.bitrate); + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, + "desktop encoder ready name=%s codec=%d size=%dx%d fps=%d bitrate=%d level=%d refs=%d vbv=%d profile_verified=%d", + name.constData(), config.codec, config.width, config.height, config.fps, config.bitrate, + config.level, config.references, c->rc_buffer_size, constrained); return true; } } @@ -264,7 +324,7 @@ bool LiveVideoEncoder::setBitrate(int bitrate) { if (bitrate < 64000 || bitrate > 16000000) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop live bitrate invalid value=%d", bitrate); return false; } #ifdef HAVE_FFMPEG // libx264 перечитывает эти три значения через reconfig_encoder при подаче следующего кадра. - if (m->codec && m->name == "libx264") { + if (m->codec && m->name == "libx264" && m->config.profile == LiveVideoProfile::Automatic) { m->codec->bit_rate = m->codec->rc_max_rate = bitrate; m->codec->rc_buffer_size = bitrate / 4; m->config.bitrate = bitrate; DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop encoder bitrate updated in place name=libx264 bitrate=%d", bitrate); return true; }