nvenc: intra refresh для low-latency multi-source push

Phase 5e revisited: visual artefacts в bottom rows 16-cell grid'а оказались
не race condition в cuframes ring buffer (как первоначальная гипотеза),
а burstiness IDR. Сложный grid (много границ между ячейками) генерит
огромные IDR (~400 КБ), которые переполняют mediamtx writeQueueSize=256
и discard'ятся → VLC видит покалеченный bitstream.

Правильное решение — canonical low-latency streaming pattern: вместо
периодических IDR использовать NVENC intra refresh. Вместо одного gigant'а
intra-кадра раз в секунду, кодируем N столбцов intra-блоков в каждом кадре.
За intra_refresh_period кадров — полный refresh. Bitstream становится
почти ровным — все кадры одинакового размера, никаких spike'ов.

Это индустриальный стандарт для low-latency: WebRTC, Twitch low-latency
mode, GeForce NOW, Zoom — все используют intra refresh без IDR.

Trade-off: новый клиент ждёт ~1 период (1 сек) для построения reference
frame. Для CCTV приемлемо. ffmpeg snapshot one-shot не работает на таких
потоках (нужен полный warmup), но VLC/TV/Frigate handles штатно.

Содержимое:

- cfc_encoder_config_t: добавлены intra_refresh + intra_refresh_period.
- nvenc.c: при enableIntraRefresh=1 устанавливается
  intraRefreshPeriod/intraRefreshCnt и идуs IDR period отключается через
  NVENC_INFINITE_GOPLENGTH.
- examples/grid_record: флаг --intra-refresh (период = fps).

Live-validated: rtsp://192.168.88.23:554/load16ir
- 16-cell 1080p 4×4 6Mbps intra refresh ON
- mediamtx tишина: ни одного 'reader is too slow' warning'а
- VLC connect → чистая картинка во всех 16 ячейках
- 100 кадров логи: '1 IDR' (только начальный) — после стартового никаких
  больше IDR не было, ровный bitstream

Этот flag не default для случая single-source (Phase 1 simple_record) —
там IDR-based GOP всё ещё лучше (полный keyframe = быстрый connect).
Включать осознанно для multi-source grid'ов через --intra-refresh.
This commit is contained in:
2026-06-03 07:05:35 +01:00
parent af70829a1d
commit b8661f4017
3 changed files with 32 additions and 1 deletions
+9 -1
View File
@@ -95,6 +95,7 @@ int main(int argc, char **argv)
int fps = 25, bitrate = 4000, max_seconds = 0;
int out_w = 1920, out_h = 1080;
int border_thickness = 0; /* 0 = без border'ов */
int intra_refresh = 0; /* 1 = intra refresh вместо IDR (low-latency multi-source) */
cfc_composer_cell_t cells[MAX_CELLS] = { 0 };
static char cell_keys[MAX_CELLS][64];
int num_cells = 0;
@@ -135,10 +136,11 @@ int main(int argc, char **argv)
{"mqtt-instance", required_argument, 0, 'I'}, /* instance ID для топиков */
{"mqtt-user", required_argument, 0, 'U'},
{"mqtt-pass", required_argument, 0, 'P'},
{"intra-refresh", no_argument, 0, 'R'},
{0, 0, 0, 0},
};
int c;
while ((c = getopt_long(argc, argv, "o:c:f:b:W:H:s:r:i:t:C:M:I:U:P:", opts, NULL)) != -1) {
while ((c = getopt_long(argc, argv, "o:c:f:b:W:H:s:r:i:t:C:M:I:U:P:R", opts, NULL)) != -1) {
switch (c) {
case 'o': out_path = optarg; break;
case 'c':
@@ -175,6 +177,7 @@ int main(int argc, char **argv)
case 'I': mqtt_instance = optarg; break;
case 'U': mqtt_user = optarg; break;
case 'P': mqtt_pass = optarg; break;
case 'R': intra_refresh = 1; break;
case 't': {
if (num_texts >= MAX_CELLS) { fprintf(stderr, "max %d texts\n", MAX_CELLS); return 1; }
/* Опциональный prefix "id=NAME:" — задаёт control-plane ID. */
@@ -391,7 +394,12 @@ int main(int argc, char **argv)
.gop_size = fps,
.num_b_frames = 0,
.preset = "ll",
.intra_refresh = intra_refresh,
.intra_refresh_period = fps, /* полный цикл за 1 секунду */
};
if (intra_refresh) {
fprintf(stderr, "[grid_record] intra refresh ON (period=%d кадров)\n", fps);
}
cfc_encoder_t *enc = NULL;
if (cfc_encoder_create(&ecfg, &enc) != 0) {
fprintf(stderr, "cfc_encoder_create failed\n");
+7
View File
@@ -47,6 +47,13 @@ typedef struct cfc_encoder_config {
/* Пресет — соответствует NV_ENC_TUNING_INFO + preset GUID.
* "ll" = low-latency, "p4" = preset P4 (баланс), "p7" = highest quality. */
const char *preset; /* "ll", "p4", "p7" — по умолчанию "ll" */
/* Intra refresh — вместо периодических IDR кодирует N столбцов intra-блоков
* в каждом кадре, за period кадров полный refresh. Ровный bitrate без
* spike'ов, идеально для low-latency multi-source RTSP push.
* 0 = выключено (GOP-based с IDR раз в gop_size). */
int32_t intra_refresh; /* 0 = off, 1 = on */
int32_t intra_refresh_period; /* кадров на полный цикл (например 25 = 1 сек @ 25fps) */
} cfc_encoder_config_t;
typedef struct cfc_encoder cfc_encoder_t;
+16
View File
@@ -268,6 +268,22 @@ int cfc_encoder_create(const cfc_encoder_config_t *cfg, cfc_encoder_t **out)
ec.encodeCodecConfig.h264Config.repeatSPSPPS = 1;
ec.encodeCodecConfig.h264Config.idrPeriod = ec.gopLength;
/* Intra refresh — отключает периодические IDR, вместо этого N столбцов
* пикселей кодируются как intra в каждом кадре. За intra_refresh_period
* кадров полный refresh. Результат — ровный bitrate без spike'ов, идеально
* для multi-source RTSP push в mediamtx с маленьким writeQueueSize.
* Trade-off: новый клиент ждёт ~1 период для построения reference frame
* (для CCTV приемлемо). См. также NVENC SDK §Picture Encoding/Intra Refresh. */
if (cfg->intra_refresh) {
int period = cfg->intra_refresh_period > 0 ? cfg->intra_refresh_period : 25;
ec.encodeCodecConfig.h264Config.enableIntraRefresh = 1;
ec.encodeCodecConfig.h264Config.intraRefreshPeriod = period;
ec.encodeCodecConfig.h264Config.intraRefreshCnt = period;
/* Отключаем периодические IDR — они не нужны при intra refresh. */
ec.encodeCodecConfig.h264Config.idrPeriod = NVENC_INFINITE_GOPLENGTH;
ec.gopLength = NVENC_INFINITE_GOPLENGTH;
}
/* 4) Init encoder. */
NV_ENC_INITIALIZE_PARAMS ip = { 0 };
ip.version = NV_ENC_INITIALIZE_PARAMS_VER;