Введение
В предыдущей статье мы выяснили, что 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, RFC 1889).
Ключевая идея: каждый пакет несёт метаданные, по которым приёмник может восстановить порядок, обнаружить потери и согласовать время. А что делать с потерями (скрыть, запросить повторно, дождаться ключевого кадра), решает приложение.
RTP работает поверх UDP (хотя возможны и другие транспорты, в том числе TCP внутри RTSP). Это протокол транспортного уровня в смысле заголовка RFC, но, как сказано в разделе 1 RFC 3550, он умышленно «не полон»: это protocol framework, который будет часто встроен в само приложение, а не реализован как отдельный слой. Поэтому говорить, что RTP находится на «5-м или 7-м уровне OSI», я бы не стал: стандарт так себя не описывает.
+-----------------------------------+
| Приложение (H.264 / AAC / Opus) |
+-----------------------------------+
| RTP | <- Добавляет timestamp, seq и тип нагрузки
+-----------------------------------+
| UDP | <- Скорость без блокировки очереди
+-----------------------------------+
| IP | <- Маршрутизация пакетов
+-----------------------------------+
RTP разделяет данные и управление на два независимых потока:
- RTP (Data Channel): переносит медиаданные, разбитые на пакеты с номерами и временными метками.
- RTCP (Control Protocol): параллельный поток, который не несёт видео, а передаёт статистику приёма (потери, джиттер), отчёты отправителя для синхронизации и данные об источнике. По умолчанию RTCP использует следующий (нечётный) порт после порта RTP, но это лишь рекомендация, а не жёсткое правило (RFC 3550, §11). Доля полосы под RTCP — по рекомендации около 5% от полосы сессии (RFC 3550, §6.2).
Структура протокола RTP
RTP-пакет состоит из заголовка (минимум 12 байт) и полезной нагрузки (Payload).
Здесь нас интересуют четыре поля заголовка:
- Sequence Number (16 бит). Увеличивается на 1 с каждым отправленным пакетом. Начальное значение выбирается случайно, а не с нуля. Позволяет упорядочить пакеты и обнаружить потери.
- Timestamp (32 бита). Момент выборки (sampling instant) первого октета данных, считанный по монотонным часам отправителя, а не показание системных часов (RFC 3550, §5.1). Начальное значение тоже случайное, поэтому абсолютное число ничего не значит: важны только разности. Если кадр разбит на несколько пакетов, у всех пакетов будет одинаковый Timestamp.
- SSRC (32 бита). Идентификатор источника синхронизации в рамках RTP-сессии. Значение выбирается случайно, и при обнаружении коллизии источник обязан его сменить. Поэтому надёжным «паспортом камеры» SSRC не является: после перезапуска потока он может измениться.
- PT (Payload Type, 7 бит). Индекс формата нагрузки. Некоторые значения закреплены статически (например,
0— PCMU,8— PCMA,26— JPEG), а значения 96–127 динамические: за какой кодек отвечает, например,96, определяет SDP (a=rtpmap:96 H264/90000) для конкретной сессии (RFC 3551). Неизвестный PT приёмник должен игнорировать.
Timestamp измеряется не в миллисекундах, а в тиках тактовой частоты (Clock Rate). Для видео H.264 и H.265 стандарты требуют 90 000 Гц (RFC 6184, RFC 7798). Тогда при 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).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 в приложенной библиотеке):
#[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:
/// Знаковая разница `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). Смысл такой: небольшой скачок вперёд и небольшое отставание считаются нормой, большой скачок не принимается сразу, а только если следующий пакет продолжает новую последовательность (скорее всего, источник перезапустился). Ниже его реализация на 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):
| Режим | Разрешённые пакеты | Комментарий |
|---|---|---|
| 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 бит):
+---------------+---------------+
| 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). Поэтому условие «E=1 и M=1» как обязательное лучше не использовать: достаточно E=1 для завершения NAL, а границу кадра определять другими признаками (ниже).
Сборка FU-A на Rust выглядит так. Обратите внимание на восстановление NAL-заголовка из F/NRI индикатора и Type из FU header:
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»:
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). Типы 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). Исходный NAL-заголовок восстанавливается так:
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:
- Складывает входящие RTP-пакеты в буфер по
Sequence Number. - Ждёт небольшой интервал (target delay). Для локальной сети обычно берут десятки миллисекунд, а точное значение зависит от сети и допустимой задержки.
- Отдаёт пакеты строго по порядку.
- Если пакет опоздал на большее время, чем окно буфера (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-часов):
/// Время прихода в тиках 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). Чтобы звук совпадал с видео, нужен RTCP Sender Report (SR). В нём камера периодически сообщает пару: «мой текущий RTP Timestamp 123456 соответствует времени NTP 14:00:00.000» (плюс счётчики отправленных пакетов и байтов). Получив такие пары для обоих потоков, приёмник пересчитывает их в общую шкалу времени.
Нюансы:
- Первый SR приходит не сразу. Интервал RTCP не меньше 5 с, а для первого отчёта он вдвое меньше (2,5 с) (RFC 3550, §6.2). Пока SR нет, точно согласовать аудио и видео нельзя.
RTP-Infoв ответе наPLAYдаёт стартовыеseqиrtptimeкаждой дорожки (см. статью про RTSP). Это нужно, чтобы связать Timestamp с началом воспроизведения, но абсолютного времени он сам по себе не даёт. ONVIF идёт дальше: требует, чтобы в ответе наPLAYбылиRangeсо стартом в часах (clock) иRTP-Infoсrtptime, и тогда клиент может вычислить абсолютное время (ONVIF Streaming Spec, §5.2.1.1.2).- Время камеры может быть сбито. NTP-время в SR берётся из часов камеры, и если они не синхронизированы, абсолютное значение неверно. Но относительная привязка аудио и видео одной камеры остаётся корректной, потому что обе метки получены из одних и тех же часов.
Для многокамерных инсталляций (VSaaS) часто применяют схему Wall-clock anchored: нода не доверяет времени камеры, фиксирует локальное время приёма и строит единый timeline по всем камерам объекта. У схемы есть цена: время приёма включает сетевой джиттер. Чтобы не «шатать» шкалу, берут не отдельное значение, а минимум разности arrival − rtp_time в скользящем окне: задержки в очередях только увеличивают её, поэтому минимум ближе всего к «чистому» смещению:
/// Оценка смещения 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-кадры) не принимается за переполнение:
/// Разворачивает 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):
$ [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; данные по 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).
Подход, который не требует доверять часам камеры, — тот самый 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 и тип из определённого набора (резервные типы отбрасываются):
/// 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:
/// Может ли 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,
}
}
И наконец, решение принимается голосованием, причём только если пакетов достаточно, а один кодек «проходит» заметно чаще другого:
/// Голосование по набору 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, если камера поддерживает).
Проверка пропуска — это три строки в начале приёма пакета:
// потеря пакета: незавершённый фрагмент использовать нельзя
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). Для сегодняшних задач есть более зрелые варианты (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 (июль 2003) и RFC 1889 (январь 1996).
- RFC 3551: RTP Profile for Audio and Video.
- RFC 6184: RTP Payload Format for H.264 и RFC 7798: RTP Payload Format for H.265.
- RFC 2326: RTSP (interleaved, §10.12).
- RFC 3711: SRTP, RFC 4585 (PLI), RFC 5104 (FIR).
- ONVIF Streaming Specification v19.06 (§5.2.1.1.2, §6.3).
- Internet-Draft: Media over QUIC Transport (черновик).
Код
- Библиотека на Rust с тестами (
articles/rtp/examples/rtp-toolkit): разбор заголовка,seq_delta, трекер seq по A.1, jitter по A.8, разворот timestamp, оценка смещения часов, сборка H.264/H.265, определение кодека. 21 тест проходит (cargo test).
Связанные статьи