# Как устроен RTP: получаем видеокадры из UDP-пакетов в реальном времени

Date: 2026-09-20
Author: Nikita Saytarov (RockEdge)
Source: https://www.rockedge.ru/blog/rtp-protocol-overview/
Markdown: https://www.rockedge.ru/blog/rtp-protocol-overview.md
Index: https://www.rockedge.ru/llms.txt
License: свободно использовать и развивать с указанием автора и источника

## Введение

В [предыдущей статье](/blog/rtsp-protocol-overview/) мы выяснили, что RTSP — это «пульт дистанционного управления» видеопотоком. Мы устанавливаем соединение, согласовываем кодеки с помощью SDP (Session Description Protocol) и нажимаем кнопку `PLAY`.

На этом задача RTSP выполнена, а основную работу по доставке данных берёт на себя **RTP (Real-time Transport Protocol)**.

RTP упаковывает кадры, закодированные H.264 или H.265, в сетевые пакеты, снабжает их порядковыми номерами и временными метками. Вместе с RTCP он даёт приёмнику информацию, достаточную, чтобы восстановить порядок, обнаружить потери и согласовать по времени звук и видео. Сам RTP при этом ничего не «гарантирует»: доставку и синхронизацию обеспечивает приложение на основе этих данных. В статье я разберу, как устроен RTP, почему для видео понадобился отдельный протокол и с какими проблемами (фрагментация, джиттер, рассинхронизация) сталкивается инженер при разработке систем захвата видео (Ingest Nodes).

> **О точности.** Утверждения о стандартах я сверял с текстами RFC и даю ссылки. Код из статьи собран в небольшую библиотеку на Rust, покрытую тестами (21 тест): примеры не придуманы «на глаз». Утверждения о поведении конкретных камер чаще всего основаны на форумах, а не на документации вендоров, и я помечаю их **⚠ проверить**.


## История появления RTP

В начале 1990-х интернет был преимущественно текстовым: сети проектировали под гипертекст (HTTP), файлы (FTP) и почту (SMTP). Главной задачей была **целостность данных**: если при скачивании файла терялся хотя бы один бит, файл становился бесполезным, и загрузку приходилось повторять. Поэтому доминировал стек IP + TCP: IP занимался маршрутизацией, а TCP отвечал за надёжность доставки.

К середине 1990-х ситуация начала меняться:

- Появились первые эксперименты с передачей звука и видео в реальном времени. Например, MBone (экспериментальная multicast-сеть) и коммерческий RealAudio, вышедший в 1995 году.
- Зародилась персональная видеоконференцсвязь, например программа CU-SeeMe.

Инженеры столкнулись с тем, что существующие протоколы плохо подходят для передачи медиаданных.

## Потоковое видео поверх TCP

Попытка передавать видео в реальном времени поверх TCP выявляет три фундаментальные проблемы, которые невозможно было решить простой оптимизацией кода.

### 1. Head-of-Line Blocking (блокировка начала очереди)

TCP доставляет данные приложению **строго по порядку**. Если пакет №3 потерялся, а №4, №5 и №6 уже пришли, стек ОС не отдаст их приложению. Он будет держать их в памяти, пока отправитель не заметит потерю (по таймауту или по повторным подтверждениям, Duplicate ACK) и не переотправит №3.

Для живого видео это проблема: если потерянный пакет содержал кусок ключевого кадра (I-frame), картинка замирает на время переотправки, которое не меньше одного RTT (Round Trip Time), а при таймауте может быть заметно больше. Для сетей с RTT в сотни миллисекунд это уже ощутимо. Когда пакет наконец приходит, приёмник получает накопившиеся данные пачкой.

### 2. Устаревание данных (Time-sensitivity)

В интерактивных системах ценность данных быстро падает со временем: кадр, который опоздал, уже не нужен, потому что есть более свежий. Допустимая задержка зависит от задачи (для видеоконференции это доли секунды, для видеонаблюдения и записи — иначе). Но принцип один: **повторная отправка устаревших данных занимает канал, который лучше отдать свежим**. TCP такой выбор не оставляет: он повторяет всё.

Здесь важно не перегнуть: TCP не «невозможен» для видео. HLS и DASH работают поверх HTTP/TCP, а в RTSP есть режим interleaved. Но они принимают более высокую задержку (HLS — секунды и больше). Проблема TCP именно в **низкой** задержке.

### 3. Отсутствие понятия «времени» на уровне транспорта

TCP оперирует «потоком байт». У него есть порядковые номера байт, но нет информации, когда эти байты были сгенерированы и когда их нужно показать. При кодировании с переменным битрейтом (VBR) тяжёлые кадры передаются дольше, лёгкие — быстрее, и TCP не даёт плееру информации о том, как восстановить исходную временную шкалу (timeline).

## Попытка использовать UDP

Очевидная альтернатива — UDP (User Datagram Protocol). Он работает по принципу «отправил и забыл»: нет блокировки очереди, нет подтверждений, а заголовок занимает всего 8 байт. Потерялся пакет — приложение решает, что с этим делать.

Но отправка «сырых» данных (например, NAL-юнитов H.264) прямо в UDP порождает новые проблемы:

- **Нарушение порядка (Packet Reordering).** UDP не гарантирует порядок доставки. Пакет №5 может обогнать №4, например из-за маршрутизации по разным путям. Без номеров декодер получит данные в произвольном порядке, и на картинке появятся артефакты (поломанные макроблоки).
- **Нет межпотоковой синхронизации (Lip-Sync).** Видео и аудио идут разными потоками, и в UDP нет механизма, который их сопоставит.
- **Нет границ кадров.** Сети имеют ограничение на размер пакета (MTU, обычно 1500 байт для Ethernet), а кадр может весить десятки килобайт. Его нужно резать на части, а UDP не сообщает приложению, где кончается один кадр и начинается следующий.

## Появление RFC 1889 и философия RTP

В январе 1996 года вышел RFC 1889 «RTP: A Transport Protocol for Real-Time Applications». Его авторы — Хеннинг Шульцринне, Стивен Каснер, Рон Фредерик и Ван Якобсон, а разрабатывался он в рабочей группе Audio-Video Transport (AVT) в IETF. В 2003 году его заменил RFC 3550, причём, как сказано в самом документе, формат пакетов на проводе при этом не менялся ([RFC 3550](https://www.rfc-editor.org/rfc/rfc3550), [RFC 1889](https://www.rfc-editor.org/rfc/rfc1889)).

Ключевая идея: **каждый пакет несёт метаданные, по которым приёмник может восстановить порядок, обнаружить потери и согласовать время**. А что делать с потерями (скрыть, запросить повторно, дождаться ключевого кадра), решает приложение.

RTP работает поверх UDP (хотя возможны и другие транспорты, в том числе TCP внутри RTSP). Это протокол транспортного уровня в смысле заголовка RFC, но, как сказано в разделе 1 RFC 3550, он умышленно «не полон»: это *protocol framework*, который будет часто встроен в само приложение, а не реализован как отдельный слой. Поэтому говорить, что RTP находится на «5-м или 7-м уровне OSI», я бы не стал: стандарт так себя не описывает.

```text
+-----------------------------------+
|  Приложение (H.264 / AAC / Opus)  |
+-----------------------------------+
|               RTP                 |  <- Добавляет timestamp, seq и тип нагрузки
+-----------------------------------+
|               UDP                 |  <- Скорость без блокировки очереди
+-----------------------------------+
|               IP                  |  <- Маршрутизация пакетов
+-----------------------------------+
```

RTP разделяет данные и управление на два независимых потока:

- **RTP (Data Channel):** переносит медиаданные, разбитые на пакеты с номерами и временными метками.
- **RTCP (Control Protocol):** параллельный поток, который не несёт видео, а передаёт статистику приёма (потери, джиттер), отчёты отправителя для синхронизации и данные об источнике. По умолчанию RTCP использует следующий (нечётный) порт после порта RTP, но это лишь рекомендация, а не жёсткое правило ([RFC 3550, §11](https://www.rfc-editor.org/rfc/rfc3550)). Доля полосы под RTCP — по рекомендации около 5% от полосы сессии ([RFC 3550, §6.2](https://www.rfc-editor.org/rfc/rfc3550)).

## Структура протокола RTP

RTP-пакет состоит из заголовка (минимум 12 байт) и полезной нагрузки (Payload).

<img src="https://www.rockedge.ru/images/rtp-packet-structure.svg" alt="Структура RTP пакета" width="100%" />

Здесь нас интересуют четыре поля заголовка:

1. **Sequence Number (16 бит).** Увеличивается на 1 с каждым отправленным пакетом. Начальное значение выбирается **случайно**, а не с нуля. Позволяет упорядочить пакеты и обнаружить потери.
2. **Timestamp (32 бита).** Момент выборки (sampling instant) первого октета данных, считанный по монотонным часам отправителя, а не показание системных часов ([RFC 3550, §5.1](https://www.rfc-editor.org/rfc/rfc3550)). Начальное значение тоже случайное, поэтому абсолютное число ничего не значит: важны только разности. Если кадр разбит на несколько пакетов, у всех пакетов будет **одинаковый Timestamp**.
3. **SSRC (32 бита).** Идентификатор источника синхронизации в рамках RTP-сессии. Значение выбирается случайно, и при обнаружении коллизии источник обязан его сменить. Поэтому надёжным «паспортом камеры» SSRC не является: после перезапуска потока он может измениться.
4. **PT (Payload Type, 7 бит).** Индекс формата нагрузки. Некоторые значения закреплены статически (например, `0` — PCMU, `8` — PCMA, `26` — JPEG), а значения **96–127 динамические**: за какой кодек отвечает, например, `96`, определяет SDP (`a=rtpmap:96 H264/90000`) для конкретной сессии ([RFC 3551](https://www.rfc-editor.org/rfc/rfc3551)). Неизвестный PT приёмник должен игнорировать.

Timestamp измеряется не в миллисекундах, а в **тиках тактовой частоты (Clock Rate)**. Для видео H.264 и H.265 стандарты требуют 90 000 Гц ([RFC 6184](https://www.rfc-editor.org/rfc/rfc6184), [RFC 7798](https://www.rfc-editor.org/rfc/rfc7798)). Тогда при 30 кадрах/с шаг Timestamp между соседними кадрами равен `3000` (90000 / 30), при 25 кадрах/с — `3600`, при 29,97 — `3003`. Для аудио частота обычно равна частоте дискретизации (для PCMA — 8000). Но шаг не обязан быть постоянным: он зависит от реальной частоты кадров, а у кадров с переупорядочиванием (B-кадры) Timestamp может идти не по возрастанию, хотя Sequence Number растёт монотонно.

### Заголовок пакета (RTP Header)

Если открыть дамп RTP-пакета в Wireshark, он будет начинаться примерно так (hex):
`80 e0 1a 2b 00 2b c3 50 1f 2d 9a 00`

Разберём по полям:

* `80` → переводим из hex в бинарный вид: 80 (hex) -> 1000 0000 (bin). Анализируем биты:
    * `10` (первые 2 бита) -> Version = 2 (актуальная версия RTP).
    * `0` (3-й бит) -> Padding = 0 (выравнивания в конце пакета нет). Padding (P) — флаг выравнивания (1 бит). Если он равен 1, в конце пакета (после данных) добавлены байты-заполнители, а последний байт пакета указывает их количество (включая самого себя).
    * `0` (4-й бит) -> Extension = 0 (расширений заголовка нет). Extension (X) — флаг расширения (1 бит). Если X = 1, к базовому заголовку добавляется расширение, структуру которого определяет профиль или приложение. Это запас на будущее. Например, в WebRTC расширения заголовка применяются активно, а в ONVIF-стриминге для воспроизведения записи используется расширение с абсолютным временем (идентификатор `0xABAC`, [ONVIF Streaming Spec §6.3](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v1906.pdf)).
    * `0000` (последние 4 бита) -> CSRC Count = 0 (микшеров нет, сразу за SSRC идёт полезная нагрузка). CSRC Count (CC) — количество идентификаторов Contributing Source (4 бита). Если поток идёт от одного источника (например, от вашей камеры), CC = 0.
* `e0` → второй байт, содержит сразу два поля (1110 0000 в двоичном виде):
    * Первый бит `M=1` — Marker bit. Его смысл задаёт профиль. Для видео по RFC 6184 и RFC 7798 это **последний пакет access unit** (то есть кадра), поэтому `M=1` значит «кадр закончился на этом пакете», а `M=0` — «в кадре есть ещё пакеты». Говорить «M=0 — промежуточный кадр» неточно: промежуточным бывает пакет, а не кадр.
    * Оставшиеся 7 бит — Payload Type (PT): `110 0000` = **96**, то есть динамический тип. Какой именно кодек за ним стоит, сказано в SDP. Значения могут быть от 0 до 127. Из статических:
        * 0 → PCMU (G.711 µ-law, базовый звук в телефонии)
        * 8 → PCMA (G.711 A-law, европейский стандарт телефонии)
        * 9 → G.722 (широкополосный звук; историческая особенность: частота дискретизации 16 кГц, а RTP-часы объявлены как 8000)
        * 26 → JPEG (Motion JPEG)
        * 34 → H.263 (старый видеокодек для видеоконференций)
        * H.264, H.265, VP8, VP9, Opus и многое другое используют динамический диапазон 96–127 и определяются в SDP при установке соединения.
* `1a 2b` → Sequence = 6699. Сквозной порядковый номер пакета (`uint16`). По нему понимаем, не потерялось ли что-то и в каком порядке склеивать данные.
* `00 2b c3 50` → Timestamp = 2 868 048. Временна́я метка (`uint32`). При частоте 90 кГц это 31,87 с от случайной начальной точки. Абсолютное значение ничего не говорит, смысл имеют только разности между пакетами. По ним приёмник понимает, в какой момент относительно других кадров показать этот.
* `1f 2d 9a 00` → SSRC = 523 082 240. Идентификатор источника (`uint32`). Позволяет отличить этот поток от других (например, от аудиодорожки) в рамках той же сессии.

Разбор можно проверить кодом (тест `header_from_the_article` в приложенной библиотеке):

```rust
#[test]
fn header_from_the_article() {
    let pkt = [0x80, 0xe0, 0x1a, 0x2b, 0x00, 0x2b, 0xc3, 0x50, 0x1f, 0x2d, 0x9a, 0x00];
    let h = parse_header(&pkt).unwrap();
    assert!(h.marker);
    assert_eq!(h.payload_type, 96);
    assert_eq!(h.seq, 6699);
    assert_eq!(h.timestamp, 2_868_048);
    assert_eq!(h.ssrc, 523_082_240);
    assert_eq!(h.payload_offset, 12);
}
```

## Sequence Number: модульная арифметика

`Sequence Number` — это `u16`, диапазон `0..65535`, после чего счётчик возвращается к `0` (rollover). Частота переполнения зависит от битрейта и размера пакетов. При 10 Мбит/с и пакетах по 1400 байт это около 893 пакетов в секунду, то есть переполнение раз в **≈73 секунды**, а не «каждые несколько секунд». При высоких битрейтах оно происходит чаще, поэтому обработку надо делать правильно с самого начала.

**Как правильно считать потери?** Нельзя писать `if seq != last + 1 { lost += seq - last - 1; }`: это сломается при переполнении и при переупорядочивании (reorder). Нужна знаковая разница по модулю 2^16:

```rust
/// Знаковая разница `b - a` по модулю 2^16: >0 — вперёд, 0 — дубликат, <0 — «опоздал».
pub fn seq_delta(a: u16, b: u16) -> i16 {
    b.wrapping_sub(a) as i16
}
```

Результат `delta = seq_delta(prev, cur)` читается так:

* `delta == 1` — ожидаемый следующий пакет.
* `delta > 1` — пропущено `delta - 1` пакетов.
* `delta == 0` — дубликат.
* `delta < 0` — пакет опоздал или пришёл не по порядку (reorder).

Для статистики и расчёта джиттера удобно развернуть 16-битный `seq` в расширенный (`extended_seq`), увеличивая старшие биты при каждом переполнении. Начальное значение случайно, поэтому нельзя считать, что поток начинается с нуля. RFC 3550 в Приложении A.1 приводит готовый алгоритм с тремя константами: `MAX_DROPOUT = 3000`, `MAX_MISORDER = 100`, `MIN_SEQUENTIAL = 2` ([RFC 3550, A.1](https://www.rfc-editor.org/rfc/rfc3550)). Смысл такой: небольшой скачок вперёд и небольшое отставание считаются нормой, большой скачок не принимается сразу, а только если следующий пакет продолжает новую последовательность (скорее всего, источник перезапустился). Ниже его реализация на Rust:

```rust
/// Возвращает true, если пакет принят (RFC 3550 A.1 `update_seq`).
pub fn update(&mut self, seq: u16) -> bool {
    let udelta = seq.wrapping_sub(self.max_seq);
    if self.probation > 0 {
        if seq == self.max_seq.wrapping_add(1) {
            self.probation -= 1;
            self.max_seq = seq;
            if self.probation == 0 {
                self.init_seq(seq);
                self.received += 1;
                return true;
            }
        } else {
            self.probation = MIN_SEQUENTIAL - 1;
            self.max_seq = seq;
        }
        return false;
    } else if udelta < MAX_DROPOUT {
        if seq < self.max_seq {
            self.cycles += SEQ_MOD; // переполнение 16 бит
        }
        self.max_seq = seq;
    } else if udelta as u32 <= SEQ_MOD - MAX_MISORDER as u32 {
        // большой скачок
        if seq as u32 == self.bad_seq {
            // два последовательных пакета после скачка — считаем, что источник перезапустился
            self.init_seq(seq);
        } else {
            self.bad_seq = (seq as u32 + 1) & (SEQ_MOD - 1);
            return false;
        }
    } else {
        // дубликат или сильное переупорядочивание — принимаем, но max_seq не двигаем
    }
    self.received += 1;
    true
}
```

А число потерянных пакетов считается так: `expected = extended_max − base_seq + 1`, `lost = expected − received`. Тесты в библиотеке проверяют переполнение, потерю одного пакета, скачок и небольшой reorder.

## Фрагментация (MTU) и сборка NAL-юнитов

В Ethernet MTU обычно равен 1500 байт. За вычетом заголовков IP (20 байт) и UDP (8 байт) на UDP payload остаётся **1472** байта. Из них 12 байт занимает RTP-заголовок, поэтому на полезную нагрузку RTP приходится максимум **1460** байт, а в случае FU-A, где ещё 2 служебных байта, на сами данные остаётся **1458**. На практике инженеры закладывают запас: туннели и VPN уменьшают MTU, а при IPv6 заголовок больше. Ключевой кадр (I-frame) в 4K может весить сотни килобайт, поэтому один кадр превращается в десятки и сотни пакетов.

Можно было бы отправить кадр одной большой UDP-датаграммой и позволить IP-стеку разрезать её на фрагменты. Но тогда потеря **любого** IP-фрагмента означает потерю всей датаграммы. Поэтому RTP-пейлоад-форматы режут данные на уровне приложения: так потеря затрагивает один пакет, а не всю датаграмму. RFC 6184 отдельно отмечает и то, что фрагментация на уровне приложения позволяет передавать NAL-юниты больше 64 КБ.

Для H.264 это описано в RFC 6184, для H.265 — в RFC 7798.

### Режимы пакетизации H.264

Параметр SDP `packetization-mode` определяет, какие типы пакетов разрешены ([RFC 6184, §6](https://www.rfc-editor.org/rfc/rfc6184)):

| Режим | Разрешённые пакеты | Комментарий |
| :--- | :--- | :--- |
| **0** (по умолчанию) | Только single NAL (типы 1–23) | Без фрагментации и агрегации: NAL должен помещаться в один пакет. Приёмник обязан поддерживать. |
| **1** (Non-Interleaved) | single NAL, STAP-A (24), FU-A (28) | Режим, ориентированный на низкую задержку. Именно его я ожидаю увидеть в SDP большинства камер (**⚠ проверить** на своих моделях). |
| **2** (Interleaved) | STAP-B (25), MTAP (26, 27), FU-A, FU-B (28, 29) | Допускает переупорядочивание NAL при передаче. Поддерживается не всеми приёмниками. |

Какой режим использует конкретная камера, указано в `a=fmtp:... packetization-mode=...`. Если параметра нет, это режим 0.

Мелкие NAL-юниты (SPS, PPS, SEI) часто объединяют в один пакет **STAP-A**: `[индикатор, тип 24][размер 16 бит][NAL][размер][NAL]...`. Крупные режут на **FU-A**.

Ещё одна практическая деталь: RTP передаёт NAL-юниты **без стартовых кодов**. Декодеры, которые ждут формат Annex B (последовательности `00 00 00 01` перед каждым NAL), требуют, чтобы вы добавили стартовые коды при сборке. А MP4 использует другой формат (длина перед каждым NAL), поэтому при записи в контейнер понадобится ещё одно преобразование.

### Анатомия FU-A (то, что вы парсите руками)

Если в SDP кодек H.264, парсер смотрит сразу за 12-байтовым RTP-заголовком (если нет CSRC и расширений!). Первый байт нагрузки определяет тип пакета (младшие 5 бит):

```text
+---------------+---------------+
| FU indicator  |   FU header   |
+---------------+---------------+
|F|NRI| Type=28 |S|E|R| Type    |
+---------------+---------------+
```

* `FU indicator` (1 байт):
    * `F` (1 бит) — forbidden_zero_bit (всегда 0).
    * `NRI` (2 бита) — важность NAL-юнита (nal_ref_idc). Значение **копируется из фрагментируемого NAL-юнита**, а не выбирается произвольно. Например, у несущих ссылки кадров оно обычно ненулевое, а у неопорных может быть 0.
    * `Type` = 28 (5 бит) — «это не целый NAL, это фрагмент FU-A».
* `FU header` (1 байт):
    * `S=1` (Start, 1 бит) — первый пакет фрагментированного NAL. Сигнал парсеру: «выделяй буфер и начинай копить байты».
    * `E=1` (End, 1 бит) — последний пакет этого NAL.
    * `R` (1 бит) — зарезервирован, отправитель обязан выставлять 0.
    * `Type` (5 бит) — исходный тип NAL до нарезки (например, 5 — IDR, 1 — не-IDR). Парсер собирает оригинальный однобайтовый NAL-заголовок из `F`/`NRI` индикатора и `Type` из FU header и вставляет его в начало склеенного буфера.

Конец NAL-юнита и конец кадра — разные вещи. `E=1` означает, что **закончился один NAL-юнит**. В одном кадре (access unit) обычно несколько NAL-юнитов (например, SPS, PPS, потом несколько слайсов). Отдавать склеенные данные в декодер можно после каждого завершённого NAL, а решение о том, что закончился весь кадр, принимается отдельно. Флаг `M=1` на последнем пакете кадра в этом помогает, но RFC 6184 разрешает декодеру использовать его только как раннюю подсказку и **запрещает полагаться на него** ([RFC 6184, §5.1](https://www.rfc-editor.org/rfc/rfc6184)). Поэтому условие «E=1 **и** M=1» как обязательное лучше не использовать: достаточно `E=1` для завершения NAL, а границу кадра определять другими признаками (ниже).

Сборка FU-A на Rust выглядит так. Обратите внимание на восстановление NAL-заголовка из `F/NRI` индикатора и `Type` из FU header:

```rust
28 => {
    let fu_hdr = p[1];
    let (s, e) = (fu_hdr & 0x80 != 0, fu_hdr & 0x40 != 0);
    if s {
        // NRI и F берём из индикатора, тип — из FU header
        let nal_hdr = (p[0] & 0xE0) | (fu_hdr & 0x1F);
        let mut v = vec![nal_hdr];
        v.extend_from_slice(&p[2..]);
        self.frag = Some(v);
    } else if let Some(f) = self.frag.as_mut() {
        f.extend_from_slice(&p[2..]);
    }
    if e {
        if let Some(f) = self.frag.take() {
            Self::push_nal(out, &f);
        }
    }
}
```

STAP-A разбирается проще. Это последовательность «длина + NAL»:

```rust
24 => {
    // STAP-A: [индикатор][size16][NAL]...
    let mut i = 1;
    while i + 2 <= p.len() {
        let n = u16::from_be_bytes([p[i], p[i + 1]]) as usize;
        i += 2;
        if i + n > p.len() {
            break;
        }
        Self::push_nal(out, &p[i..i + n]);
        i += n;
    }
}
```

### H.265: те же идеи, другие номера

В H.265 (RFC 7798) заголовок NAL занимает **2 байта**: `F` (1 бит), `Type` (6 бит), `LayerId` (6 бит), `TID` (3 бита). Значение `TID` равно `temporal_id + 1`, поэтому **0 в этом поле запрещён**, а `LayerId` в базовой версии стандарта всегда 0 ([RFC 7798, §1.1.4](https://www.rfc-editor.org/rfc/rfc7798)). Типы RTP-пакетов: `48` — агрегация (AP), `49` — фрагментация (FU), `50` — PACI. FU здесь устроен так: двухбайтный PayloadHdr с `Type=49` (поля F, LayerId и TID копируются из исходного NAL), затем один байт FU header: `S`, `E` и 6-битный `FuType` (равен типу исходного NAL), а потом данные ([RFC 7798, §4.4.3](https://www.rfc-editor.org/rfc/rfc7798)). Исходный NAL-заголовок восстанавливается так:

```rust
49 => {
    if p.len() < 3 {
        return;
    }
    let fu_hdr = p[2];
    let (s, e) = (fu_hdr & 0x80 != 0, fu_hdr & 0x40 != 0);
    if s {
        // F, LayerId, TID — из PayloadHdr; Type — из FuType
        let b0 = (p[0] & 0x81) | ((fu_hdr & 0x3F) << 1);
        let mut v = vec![b0, p[1]];
        v.extend_from_slice(&p[3..]);
        self.frag = Some(v);
    } else if let Some(f) = self.frag.as_mut() {
        f.extend_from_slice(&p[3..]);
    }
    if e {
        if let Some(f) = self.frag.take() {
            Self::push_nal(out, &f);
        }
    }
}
```

Эти ветки проверены тестами: цикл «нарезать — собрать» возвращает исходные байты, а NRI в FU-A копируется (`0x65` → индикатор `0x7C`, `0x41` → `0x5C`).

### Проблема «кривых» прошивок: определение границ кадра

В идеальном мире кадр завершается по `M=1`. Но, как уже сказано, стандарт запрещает на это полагаться, а некоторые прошивки, по сообщениям, флаг не ставят вовсе (**⚠ проверить** на вашей камере). Поэтому сборщик с самого начала строят с политикой принудительного закрытия кадра, чтобы он не ждал маркер вечно и не терял память. Кадр считается завершённым (и уходит в декодер или отбрасывается как битый), если выполнено любое из условий:

* **Маркер.** Пришёл пакет с `M=1`.
* **Смена Timestamp.** Пришёл пакет с другим RTP Timestamp: все пакеты одного кадра имеют одинаковый Timestamp, значит, предыдущий кадр закончился, а маркер мы пропустили. Это условие надёжно, только если пакеты уже упорядочены (после jitter-буфера).
* **Таймаут сборки (watchdog).** Если за заданный бюджет времени (например, 30–50 мс; значение подбирается под FPS и сеть) новых пакетов для текущего Timestamp не было, буфер закрывается принудительно.

## Jitter Buffer

RTP поверх UDP не сохраняет порядок пакетов и равномерность интервалов и не гарантирует доставку. На Wi-Fi пакеты часто приходят пачками и вперемешку. Отдавать их в декодер как есть нельзя, поэтому используют **Jitter Buffer**:

1. Складывает входящие RTP-пакеты в буфер по `Sequence Number`.
2. Ждёт небольшой интервал (target delay). Для локальной сети обычно берут десятки миллисекунд, а точное значение зависит от сети и допустимой задержки.
3. Отдаёт пакеты строго по порядку.
4. Если пакет опоздал на большее время, чем окно буфера (late packet), он отбрасывается, чтобы не увеличивать общую задержку (latency).

Не путайте два «джиттера». **Jitter-буфер** — это структура данных в вашем приложении. А **interarrival jitter** — число, которое приёмник считает и отправляет камере в RTCP Receiver Report. RFC 3550 (приложение A.8) определяет его так: `transit = arrival − timestamp`, `d = |transit − transit_prev|`, `jitter += (d − jitter) / 16`. Время прихода и timestamp должны быть в одних единицах (тиках RTP-часов):

```rust
/// Время прихода в тиках RTP-часов (для видео 90 кГц).
#[derive(Default, Debug)]
pub struct Jitter {
    prev_transit: Option<i64>,
    pub value: f64,
}

impl Jitter {
    pub fn update(&mut self, arrival_ticks: u64, rtp_ts: u32) {
        let transit = arrival_ticks as i64 - rtp_ts as i64;
        if let Some(prev) = self.prev_transit {
            let d = (transit - prev).abs() as f64;
            self.value += (d - self.value) / 16.0;
        }
        self.prev_transit = Some(transit);
    }
}
```

Тест проверяет два случая: при идеально равномерной доставке оценка равна нулю, а при чередовании «вовремя / с опозданием на 10 мс» она сходится к 900 тикам (10 мс при 90 кГц).

## Синхронизация аудио и видео (RTCP)

Сам по себе `RTP Timestamp` не привязан к реальному времени: у каждого потока (видео, аудио) своя случайная начальная точка и своя частота. Сравнивать Timestamp разных потоков напрямую нельзя ([RFC 3550, §5.1](https://www.rfc-editor.org/rfc/rfc3550)). Чтобы звук совпадал с видео, нужен **RTCP Sender Report (SR)**. В нём камера периодически сообщает пару: «мой текущий RTP Timestamp `123456` соответствует времени NTP `14:00:00.000`» (плюс счётчики отправленных пакетов и байтов). Получив такие пары для обоих потоков, приёмник пересчитывает их в общую шкалу времени.

Нюансы:

- **Первый SR приходит не сразу.** Интервал RTCP не меньше 5 с, а для первого отчёта он вдвое меньше (2,5 с) ([RFC 3550, §6.2](https://www.rfc-editor.org/rfc/rfc3550)). Пока SR нет, точно согласовать аудио и видео нельзя.
- **`RTP-Info` в ответе на `PLAY`** даёт стартовые `seq` и `rtptime` каждой дорожки (см. статью про RTSP). Это нужно, чтобы связать Timestamp с началом воспроизведения, но абсолютного времени он сам по себе не даёт. ONVIF идёт дальше: требует, чтобы в ответе на `PLAY` были `Range` со стартом в часах (clock) и `RTP-Info` с `rtptime`, и тогда клиент может вычислить абсолютное время ([ONVIF Streaming Spec, §5.2.1.1.2](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v1906.pdf)).
- **Время камеры может быть сбито.** NTP-время в SR берётся из часов камеры, и если они не синхронизированы, абсолютное значение неверно. Но *относительная* привязка аудио и видео одной камеры остаётся корректной, потому что обе метки получены из одних и тех же часов.

Для многокамерных инсталляций (VSaaS) часто применяют схему **Wall-clock anchored**: нода не доверяет времени камеры, фиксирует локальное время приёма и строит единый timeline по всем камерам объекта. У схемы есть цена: время приёма включает сетевой джиттер. Чтобы не «шатать» шкалу, берут не отдельное значение, а **минимум** разности `arrival − rtp_time` в скользящем окне: задержки в очередях только увеличивают её, поэтому минимум ближе всего к «чистому» смещению:

```rust
/// Оценка смещения arrival − rtp_time как минимума по скользящему окну.
/// Минимум устойчив к задержкам очередей: они только добавляют, но не вычитают.
pub struct ClockMapper {
    window: usize,
    offsets: VecDeque<i64>,
}

impl ClockMapper {
    pub fn new(window: usize) -> Self {
        Self { window, offsets: VecDeque::new() }
    }
    pub fn add(&mut self, arrival_ticks: i64, ext_rtp_ticks: i64) {
        self.offsets.push_back(arrival_ticks - ext_rtp_ticks);
        if self.offsets.len() > self.window {
            self.offsets.pop_front();
        }
    }
    pub fn offset(&self) -> Option<i64> {
        self.offsets.iter().copied().min()
    }
    /// Когда кадр «должен» быть показан (в тиках часов клиента)
    pub fn playout(&self, ext_rtp_ticks: i64, buffer_ticks: i64) -> Option<i64> {
        self.offset().map(|o| ext_rtp_ticks + o + buffer_ticks)
    }
}
```

32-битный Timestamp перед этим нужно «развернуть» в 64-битную шкалу (иначе через ~13 часов при 90 кГц счётчик переполнится). Делать это надо с **разностью со знаком**: так шаг назад (B-кадры) не принимается за переполнение:

```rust
/// Разворачивает timestamp. Допускает «шаг назад» (B-кадры): разница берётся со знаком.
#[derive(Default, Debug)]
pub struct TsUnwrapper {
    last: Option<(u32, i64)>,
}

impl TsUnwrapper {
    pub fn unwrap(&mut self, ts: u32) -> i64 {
        let ext = match self.last {
            None => ts as i64,
            Some((prev_ts, prev_ext)) => prev_ext + ts.wrapping_sub(prev_ts) as i32 as i64,
        };
        self.last = Some((ts, ext));
        ext
    }
}
```

Внутри одной камеры для выравнивания аудио и видео лучше использовать пары NTP↔RTP из SR, а wall-clock применять для сопоставления *разных* камер. Эта связка и проверена в тестах: оценка смещения при случайных задержках до 50 мс отклоняется не более чем на 400 тиков (≈4 мс).

## RTP внутри RTSP (TCP Interleaved)

RTP можно передавать не по UDP, а прямо внутри TCP-соединения RTSP, чтобы обойти NAT и файрволы. Какую долю потоков составляет такой режим в реальных системах, я не знаю: надёжной статистики нет, а режим выбирается настройками клиента. Блоки разделяются символом `$` (фрейминг, [RFC 2326, §10.12](https://www.rfc-editor.org/rfc/rfc2326)):

`$ [channel: 1 байт] [length: 2 байта, big-endian] [RTP- или RTCP-пакет]`

Здесь есть тонкость: по тому же соединению приходят **и** текстовые RTSP-ответы (например, на keep-alive), **и** бинарные блоки с `$`. Парсер должен смотреть на первый байт: `$` (0x24) — это блок данных, иначе — начало текстового сообщения. Из-за TCP блок может прийти по частям, поэтому читать надо ровно `length` байт, а не «сколько пришло».

Сервис захвата (Ingest) удобно делать **Transport-agnostic**: сетевой слой достаёт RTP-пакет из `$`-обёртки и отдаёт его туда же, куда попал бы пакет из UDP-сокета. TCP гарантирует порядок, поэтому переупорядочивание в этом режиме не нужно, но сглаживание подачи и контроль задержки всё равно нужны.

## SRTP и обратная связь (кратко)

RTP сам не шифруется. Для защиты есть **SRTP** (RFC 3711), а в ONVIF Media2 предусмотрены SRTP и RTSPS ([RFC 3711](https://www.rfc-editor.org/rfc/rfc3711); данные по Media2 — из WSDL и спецификации ONVIF Core). Для запроса нового ключевого кадра после потерь в мире RTP есть отдельные RTCP-сообщения: PLI (Picture Loss Indication, RFC 4585) и FIR (Full Intra Request, RFC 5104). Но поддерживает ли их конкретная камера, нужно проверять — **⚠ проверить**. Если не поддерживает, остаётся ждать следующий IDR по расписанию (GOP) или переподключаться.

## Реальные камеры: проблемы и подходы

Реализации RTP в реальных устройствах отличаются, и их надо учитывать. Я не буду приводить долю китайских камер в проектах как факт: данных для такого утверждения у меня нет. Ниже — четыре проблемы. Там, где есть стандарт, я ссылаюсь на него, а там, где опираюсь на сообщения сообщества, ставлю **⚠ проверить**.

### Проблема 1: timestamp камеры и часы приёмника

У камеры и у вашего сервера **две разные пары часов**, и они всегда немного расходятся. Это нормально: кварцы отличаются на десятки ppm (миллионных долей). Для оценки: расхождение в 50 ppm даёт `7200 с × 50·10⁻⁶ = 0,36 с` за два часа. Поэтому рассказы о десятках секунд рассинхронизации за два часа скорее указывают не на «дрейф кварца», а на ошибку в обработке timestamp, перезапуск потока или неверный clock rate. Поэтому конкретные числа про дрейф конкретных SoC я не привожу: подтверждения нет.

Что действительно происходит и стоит учитывать:

- **Скачки.** После переподключения, перезагрузки камеры или смены настроек Timestamp и SSRC могут начаться заново. Нужно обнаруживать разрыв шкалы и переякоривать её, а не склеивать «как есть».
- **Нерегулярные интервалы.** Реальный FPS может плавать, шаг Timestamp не обязан быть ровным.
- **B-кадры.** Если кодек использует B-кадры (профиль Baseline их не содержит), Timestamp в порядке передачи не монотонен. Timestamp отражает момент выборки (отображения) кадра, а порядок декодирования задаётся самим потоком: в H.264 передача, декодирование и отображение полностью развязаны ([RFC 6184, §1.1 и §5.1](https://www.rfc-editor.org/rfc/rfc6184)).

Подход, который не требует доверять часам камеры, — тот самый **Wall-clock anchored** из раздела выше: развернуть Timestamp (`TsUnwrapper`), оценить смещение к своим часам минимумом по окну (`ClockMapper`) и считать разрыв шкалы (скачок за пределы допустимого, например на секунды вперёд или назад дальше окна переупорядочивания) сигналом к переякориванию. Обратите внимание: я намеренно не предлагаю формулу вида `DTS = prev + clamp(ΔRTP, min, max)`. Ограничивая шаг, она искажает Timestamp и плохо сочетается с оценкой смещения. Основа здесь — разность развёрнутых Timestamp плюс оценка смещения, а решение о разрыве принимается явно.

### Проблема 2: бит Marker

Стандарт говорит, что для видео `M=1` ставится на последнем пакете кадра, но декодер **не должен** на него полагаться (RFC 6184). Есть сообщения, что отдельные прошивки оставляют `M=0` на всех пакетах (**⚠ проверить**; мне не удалось найти публичного подтверждения по конкретным чипам или вендорам). Но так как на маркер нельзя полагаться и по стандарту, решение одно: определять границы кадра по трём условиям (маркер, смена Timestamp, watchdog), как описано выше.

Симптом, если этого не сделать: сборщик ждёт `M=1`, буфер растёт, нода сбрасывает поток целиком. Поэтому стоит ограничить размер буфера сборки: по объёму и по времени.

### Проблема 3: SDP и реальный поток расходятся

SDP описывает поток на момент `DESCRIBE`. Если настройки камеры изменили (например, сменили кодек в веб-интерфейсе) без пересоздания сессии, в RTP может приехать другой кодек, а SDP останется прежним. Публичного подтверждения такого поведения по конкретным моделям я не нашёл (**⚠ проверить**), но защитить код нетрудно независимо от вендора: **не доверяйте SDP на слово, а проверяйте согласованность**.

Одного байта для этого мало, и наивная проверка легко ошибается. Типичная идея — смотреть на биты первого байта: `& 0x1F` для H.264 и `(b >> 1) & 0x3F` для H.265. Но у неё есть ловушка: первый байт H.265 FU, `0x62`, в младших пяти битах даёт значение 2, то есть «валидный» тип H.264, и простая проверка путает кодеки (это зафиксировано отдельным тестом). Рабочий вариант опирается на структурные правила заголовков и голосование по нескольким пакетам.

Для H.265 используются правила из RFC 7798: `F = 0`, `LayerId = 0`, `TID ≠ 0` и тип из определённого набора (резервные типы отбрасываются):

```rust
/// H.265: допустимые определённые типы (резервные отбрасываем) + типы RTP-пакетов 48..50.
fn h265_type_defined(t: u8) -> bool {
    matches!(t, 0..=9 | 16..=21 | 32..=40 | 48..=50)
}
/// Может ли payload быть началом H.265 пакета (single NAL, AP, FU)?
pub fn plausible_h265(p: &[u8]) -> bool {
    if p.len() < 3 {
        return false;
    }
    let f = p[0] >> 7;
    let typ = (p[0] >> 1) & 0x3F;
    let layer_id = ((p[0] & 1) << 5) | (p[1] >> 3);
    let tid = p[1] & 7;
    f == 0 && layer_id == 0 && tid != 0 && h265_type_defined(typ)
}
```

Для H.264 — `F = 0`, ожидаемые типы (data partitioning, типы 2–4, в камерах практически не встречается), а в FU-A бит `R` равен 0:

```rust
/// Может ли payload быть началом H.264 пакета (single NAL, STAP-A, FU-A)?
pub fn plausible_h264(p: &[u8]) -> bool {
    if p.len() < 2 {
        return false;
    }
    if p[0] & 0x80 != 0 {
        return false; // forbidden_zero_bit
    }
    // Типы 2-4 (data partitioning) в потоках камер практически не встречаются, зато
    // байт 0x62 (H.265 FU, type 49) даёт в младших 5 битах именно 2 — исключаем их.
    match p[0] & 0x1F {
        1 | 5..=12 | 24 => true,
        28 => p[1] & 0x20 == 0, // бит R в FU header обязан быть 0
        _ => false,
    }
}
```

И наконец, решение принимается голосованием, причём только если пакетов достаточно, а один кодек «проходит» заметно чаще другого:

```rust
/// Голосование по набору payload. Решение принимаем, только если один кодек
/// «проходит» заметно чаще другого.
pub fn guess(payloads: &[&[u8]]) -> Guess {
    let (mut a, mut b) = (0usize, 0usize);
    for p in payloads {
        a += plausible_h264(p) as usize;
        b += plausible_h265(p) as usize;
    }
    let n = payloads.len();
    if n < 8 {
        return Guess::Unknown;
    }
    if a * 10 >= n * 9 && b * 2 < n {
        Guess::H264
    } else if b * 10 >= n * 9 && a * 2 < n {
        Guess::H265
    } else {
        Guess::Unknown
    }
}
```

Тесты используют синтетические потоки (SPS/PPS в STAP-A, IDR из FU-A, P-слайсы для H.264; VPS/SPS/PPS, IDR из FU и TRAIL_R для H.265): в них правильный кодек побеждает, а на одном пакете функция честно отвечает `Unknown`. Это эвристика, не гарантия. Надёжнее всего сверять результат с SDP и в итоге разобрать SPS. Если определённый кодек не совпал с заявленным, перестраивайте парсер и заново запрашивайте `DESCRIBE`, не рассчитывая, что поток «исправится сам».

### Проблема 4: пропуск пакетов внутри FU-A

Если ключевой кадр нарезан на 100 пакетов FU-A, потеря даже одного промежуточного (например, `seq = 105` из серии 100..200) делает собранный NAL-юнит бинарным мусором: пакет с потерянной частью нельзя «дособрать».

* **Симптом.** Если передать частично собранный NAL в декодер, картинка может «рассыпаться» до следующего I-кадра (в зависимости от декодера: некоторые скрывают ошибки лучше).
* **Решение — Sequence Gap Invalidation.** Если между пакетом `S=1` и пакетом `E=1` пропущен номер (`delta > 1`), сборка немедленно бракуется: накопленный буфер сбрасывается. Дальше решение зависит от типа потерянного NAL: потеря кусочка IDR или опорного кадра портит все последующие кадры до следующего IDR, поэтому такой кадр лучше отбросить и ждать следующий ключевой (или запросить его через PLI/FIR, если камера поддерживает).

Проверка пропуска — это три строки в начале приёма пакета:

```rust
// потеря пакета: незавершённый фрагмент использовать нельзя
if let Some(exp) = self.expected_seq {
    if seq != exp && self.frag.take().is_some() {
        self.dropped_fragments += 1;
    }
}
self.expected_seq = Some(seq.wrapping_add(1));
```

Тест `fua_lost_packet_drops_whole_nal` проверяет, что при потере средней части NAL не выдаётся, счётчик брака увеличивается, а следующий целый NAL собирается нормально.

## От RTP к MoQ: эволюция доставки

В моей Edge-First архитектуре RTP — это **уровень захвата (Ingest)**. Дальше Edge-нода собирает готовые H.264/H.265 кадры и отдаёт их наружу. Для раздачи наружу я рассматриваю MoQ (Media over QUIC): он ориентирован на низкую задержку, а независимые потоки QUIC должны уменьшать эффект блокировки очереди. Но надо помнить статус: на 2026-10-01 это Internet-Draft `draft-ietf-moq-transport-22`, а не стандарт ([datatracker](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/)). Для сегодняшних задач есть более зрелые варианты (LL-HLS, WebRTC), поэтому выбор MoQ — осознанная ставка, а не единственный путь. Заявлять, что MoQ «без проблем с Head-of-Line blocking», я бы не стал: он снижает эту проблему для независимых потоков, но не отменяет сетевых потерь как таковых.


## Ключевые выводы

- **RTP — это транспорт, а не гарантия.** Он не устанавливает соединение: его задача — перенести куски кадров и дать приёмнику метаданные (seq, timestamp), чтобы тот собрал их обратно.
- **RTP не гарантирует доставку и порядок.** При UDP пакеты теряются и перемешиваются, поэтому логика восстановления ложится на депакетизатор и jitter-буфер.
- **Синхронизация строится на Timestamp и RTCP SR.** Сравнивать Timestamp разных потоков напрямую нельзя: нужны пары NTP↔RTP из Sender Report.
- **Используйте знаковую разность и расширенный seq.** `seq_delta` и алгоритм A.1 из RFC 3550 решают переполнение, дубликаты и reorder.
- **Фрагментация — главная сложность.** Нужно аккуратно реализовать RFC 6184 и RFC 7798: STAP-A/AP, FU-A/FU, восстановление NAL-заголовка, добавление стартовых кодов.
- **Не полагайтесь на Marker bit.** Определяйте границы кадра по `M`, смене Timestamp и watchdog.
- **Не доверяйте SDP на слово.** Сверяйте его с реальным потоком, но эвристику проверяйте тестами.
- **Всё, что отмечено «проверить»,** стоит воспроизвести на вашем оборудовании.


## Источники

**Стандарты**

- [RFC 3550: RTP](https://www.rfc-editor.org/rfc/rfc3550) (июль 2003) и [RFC 1889](https://www.rfc-editor.org/rfc/rfc1889) (январь 1996).
- [RFC 3551: RTP Profile for Audio and Video](https://www.rfc-editor.org/rfc/rfc3551).
- [RFC 6184: RTP Payload Format for H.264](https://www.rfc-editor.org/rfc/rfc6184) и [RFC 7798: RTP Payload Format for H.265](https://www.rfc-editor.org/rfc/rfc7798).
- [RFC 2326: RTSP](https://www.rfc-editor.org/rfc/rfc2326) (interleaved, §10.12).
- [RFC 3711: SRTP](https://www.rfc-editor.org/rfc/rfc3711), [RFC 4585](https://www.rfc-editor.org/rfc/rfc4585) (PLI), [RFC 5104](https://www.rfc-editor.org/rfc/rfc5104) (FIR).
- [ONVIF Streaming Specification v19.06](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v1906.pdf) (§5.2.1.1.2, §6.3).
- [Internet-Draft: Media over QUIC Transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) (черновик).

**Код**

- Библиотека на Rust с тестами (`articles/rtp/examples/rtp-toolkit`): разбор заголовка, `seq_delta`, трекер seq по A.1, jitter по A.8, разворот timestamp, оценка смещения часов, сборка H.264/H.265, определение кодека. 21 тест проходит (`cargo test`).

**Связанные статьи**

- [RTSP: как протокол 1998 года остаётся основным способом забирать видео с IP-камер](/blog/rtsp-protocol-overview/).
- [ONVIF: как три компании договорились о языке для камер](/blog/onvif-protocol-overview/).