Мы написали свой кодек для lossless трансляции экрана на Rust
Я Артур Валиев, разработчик EVRT
Когда мы форкнули RustDesk и начали строить EvertyDesk — корпоративный удалённый рабочий стол с поддержкой Hyper-V, Proxmox, VirtualBox и умным агентом — первое, во что упёрлись, было видео.
H.264 — стандарт де-факто. TeamViewer, AnyDesk, Windows App — все используют H.264. Он работает. Но он lossy. И вот здесь начинается проблема, о которой в статьях про remote desktop обычно не говорят.
Когда оператор смотрит на терминал с git diff или читает трейс в VSCode — один размытый пиксель в букве это уже другой символ. H.264 при любом разумном битрейте вносит артефакты в области с резким контрастом. Точно туда, где у нас весь текст. Это неприемлемо.
H.264 в lossless режиме (QP=0, lossless profile) существует, но поддержка в декодерах — через пень-колоду, а для mostly-static контента предикторы рассчитанные под видео с движением дают нулевой выигрыш над тривиальным delta-кодером.
Нам не нужен motion estimation. Нам нужно: вот предыдущий кадр, вот текущий, вот список изменившихся тайлов, сожми только их.
Мы написали EVRTCK.
Wire format
Описывать протокол словами скучно. Вот пакет побайтово:
Size Field Notes 0 4 B magic 0x45 0x56 0x43 0x4B — ASCII «EVCK» 4 1 B version 0x01 — единственная реализованная версия 5 1 B flags 0x01 = KEYFRAME · 0x02 = NOP (кадр идентичен предыдущему) 6 4 B frame_id u32 LE, монотонно возрастающий 10 4 B width u32 LE, пикселей 14 4 B height u32 LE, пикселей 18 2 B map_bytes u16 LE = ceil(tile_count / 8) 20 N B tile_map битовый массив: бит i установлен если тайл i грязный 20+N … payloads последовательно, только для грязных тайлов
Каждый payload начинается с байта mode. Три возможных значения:
SOLID 0x01 4 байта RGBA — весь тайл залит одним цветом DELTA 0x02 u32 length + ZRLE-сжатый XOR-дельта ZSTD 0x03 u32 length + zstd level-1 XOR-дельта
Тайл — 32×32 пикселя = 4096 байт в BGRA. На 1080p: 60×34 = 2040 тайлов. Tile map: ceil(2040/8) = 255 байт. Полный пакет для 0% dirty = 275 байт. При 15% dirty (308 грязных тайлов, пользователь печатает в терминале) — ~1.7 КБ. Против 7 864 320 байт raw BGRA.
Кодирование тайла: три режима
MODE_SOLID
Если все 1024 пикселя тайла одного цвета — фон, заливка, иконка — весь payload это 5 байт (mode + RGBA). Встречается значительно чаще, чем кажется: большинство UI-элементов монохромны по тайлам.
fn try_solid(tile: &[u8]) -> Option<[u8; 4]> { let mut chunks = tile.chunks_exact(4); let first = chunks.next()?; let color = [first[0], first[1], first[2], first[3]]; for chunk in chunks { if chunk != color { return None; } } Some(color) }
Для остальных тайлов — XOR-дельта между текущим и предыдущим кадром, затем компрессия. Выбор между ZRLE и zstd: если len(zrle) < raw_delta × 0.9 — ZRLE, иначе zstd level 1.
ZRLE эффективен когда большинство байт дельты — нули: небольшое изменение в текстовом курсоре, один символ в строке. Runs of zeros кодируются в 2 байта. zstd level 1 (~500 МБ/с throughput) выигрывает при высокой энтропии дельты — перемещение окна, частичный скролл.
Одна тонкость: prev хранится в BGRA (raw capture format от DXGI/GDI), а на wire отдаём RGBA. Конвертация — в момент сборки тайла в contiguous буфер, без отдельного прохода:
for y in y0..y1 { for x in x0..x1 { let off = (y * width + x) * 4; // BGRA → RGBA: swap индексов 0 ↔ 2, G и A не трогаем tile.push(rgba[off + 2]); // R tile.push(rgba[off + 1]); // G tile.push(rgba[off ]); // B tile.push(rgba[off + 3]); // A tile_prev.push(prev[off + 2]); tile_prev.push(prev[off + 1]); tile_prev.push(prev[off ]); tile_prev.push(prev[off + 3]); } }
XOR после конвертации корректен: оба буфера в одном формате, операция XOR channel-независима.
Параллелизм через rayon
Тайлы независимы — идеально для data-parallel workload. Каждая задача читает только свои строки из cur и prev (shared read, никакой синхронизации), пишет в свой элемент результирующего вектора:
let tile_results: Vec<Option<(Vec<u8>, u8)>> = (0..tile_count) .into_par_iter() .map(|idx| { let tx = idx % tiles_x; let ty = idx / tiles_x; if tile_is_dirty(rgba, prev, width, height, tx, ty) { Some(encode_tile_buf(rgba, prev, width, height, tx, ty)) } else { None } }) .collect();
Ни одного Mutex, ни одного Arc. Rust ownership гарантирует корректность статически: rgba и prev — shared &[u8], доступные из любого потока; tile_results собирается из независимых Vec<u8>.
Keyframe throughput: 89 МиБ/с на 720p, 91 МиБ/с на 1080p, 91 МиБ/с на 4K. Три разных разрешения, одна цифра — кодек упёрся в memory bandwidth, а не в вычисления. Rayon максимально использует все ядра, дальнейшая оптимизация на CPU невозможна без смены алгоритма.
IDR, смена кодека и неочевидный баг
Параллельно с EVRTCK живёт H.264 через Media Foundation (Windows) и NVENC (если есть NVIDIA). Переключение — на основе dirty ratio и типа контента. Для видеопотока внутри браузера или анимаций EVRTCK проигрывает: он lossless, у него нет motion compensation, и при 90% dirty он шлёт все грязные тайлы целиком.
Один из первых неочевидных багов: при переключении H.264 → EVRTCK клиент получал первый P-frame EVRTCK и применял его к нулевому буферу (свежеинициализированный prev). Визуальный мусор на несколько секунд до следующего IDR.
Очевидное решение — IDR при переключении. Неочевидность: переключение двунаправленное. При возврате EVRTCK → H.264 H.264 декодер на клиенте тоже должен получить IDR, иначе он применяет P-frame дельты к неправильному опорному кадру.
// Недостаточно: только H264→EVRTCK if evrt_on && !was_evrt_on { force_recovery_key = true; } // Правильно: оба направления if evrt_on != was_evrt_on { force_recovery_key = true; } was_evrt_on = evrt_on;
IDR_MIN_EVRTCK = 10 секунд против IDR_MIN_H264 = 1200 мс. EVRTCK может позволить себе редкие keyframe-ы: P-frame даже после 10 секунд изменений на UI-контенте весит несколько килобайт. H.264 нуждается в частых IDR для recovery при потере пакетов.
NOP frame: честно об оптимизации
Самый распространённый сценарий в enterprise remote desktop: пользователь читает документ, ждёт деплоя, смотрит в Confluence. Кадры идентичны. До оптимизации:
Rayon запускал 2040 задач на 1080p. Каждая читала строки тайла с stride-ed доступом через 7680 байт (ширина строки 1920×4 байта). Ни одна не находила изменений. Итого: 3.17 мс на холостом ходу при 30 fps.
Решение выглядело элегантно: один rgba == prev перед rayon-циклом. Rust компилирует сравнение слайсов в вызов memcmp, который на x86-64 реализован через AVX2 и сравнивает 32 байта за такт. При bandwidth ~50 ГБ/с это должно было занять ~0.15 мс.
// Быстрый путь: кадр идентичен предыдущему if !is_keyframe && rgba == prev { let mut out = Vec::with_capacity(20); out.extend_from_slice(MAGIC); out.push(VERSION); out.push(FLAG_NOP); // 0x02 out.extend_from_slice(&frame_id.to_le_bytes()); out.extend_from_slice(&(width as u32).to_le_bytes()); out.extend_from_slice(&(height as u32).to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); // map_bytes = 0 return (out, FrameStats::default()); } // Criterion: 2.93 мс. Ускорение — 8%.
Почему не 20×.
rgba == prev— этоmemcmpдвух буферов по 8.29 МБ каждый. Оба больше L3 кэша. Оба загружаются из RAM: 16.58 МБ при реальном DDR4 bandwidth ~5–6 ГБ/с = ~2.8–3 мс. Rayon раньше читал ровно те же 16.58 МБ, только через strided-доступ с overhead на 2040 задач — вот эти ~0.24 мс мы и сэкономили.
Настоящая победа другая: пакет уменьшился с 275 байт до 20 байт. Декодер при FLAG_NOP читает header, возвращает prev как есть — буквально ноль работы. При 30 fps на статичном экране:
До 61 Кбит/с 30 × 275 байт/кадр × 8 После 5 Кбит/с 30 × 20 байт/кадр × 8
Benchmark: Criterion, реальные числа
Все замеры: Criterion 0.5, 100 сэмплов, opt-level=3 lto=thin, Windows 11 x86-64, CPU backend (GPU Phase 2 в разработке).
Сценарий | Время | Throughput |
|---|---|---|
Keyframe 720p | 39.4 мс | 89.3 МиБ/с |
Keyframe 1080p | 86.7 мс | 91.2 МиБ/с |
Keyframe 4K | 344.6 мс | 91.8 МиБ/с |
P-frame 0% dirty — NOP path | 2.93 мс | 2.63 ГиБ/с |
P-frame 5% dirty — движение курсора | 3.17 мс | 2.44 ГиБ/с |
P-frame 15% dirty — печать в терминале | 3.34 мс | 2.31 ГиБ/с |
P-frame 50% dirty — скролл в браузере | 4.18 мс | 1.85 ГиБ/с |
P-frame 90% dirty — видео в браузере | 5.37 мс | 1.44 ГиБ/с |
Roundtrip encode+decode 1080p 15% | 3.99 мс | 1.94 ГиБ/с |
30 fps бюджет = 33.3 мс. Даже при 90% dirty мы в 6.2× запасе. Интересно что разница между 0% и 5% dirty — почти ноль: overhead rayon и tile scan на 1755 чистых тайлах доминирует над кодированием 102 грязных.
Wire size (синтетические фреймы с solid-color тайлами — реальный контент будет больше, порядки те же):
Dirty | Encoded | vs 7.91 МиБ raw |
|---|---|---|
0% | 20 Б | 393 216:1 |
5% | 779 Б | 10 648:1 |
15% | 1.7 КБ | 4 642:1 |
50% | 5.2 КБ | 1 551:1 |
90% | 9.2 КБ | 877:1 |
Где мы слабы — честно
Dirty detection завязан на CPU scanWindows Desktop Duplication API (IDXGIOutputDuplication::AcquireNextFrame) возвращает dirty rectangles как метаданные к захваченному кадру — бесплатно, без сканирования. Используй мы их, tile_is_dirty не нужен вообще. Это Phase 2: WGPU-бэкенд уже написан и инициализирует GPU-буферы при старте, но пока работает на CPU fallback.
Нет протокола рассинхронизацииЕсли TCP-буфер переполнился и пакет потерялся — prev у энкодера и декодера расходятся навсегда до следующего IDR (до 10 секунд). Нужен: frame hash в каждом пакете и out-of-band механизм запроса IDR декодером при mismatch. Сейчас этого нет.
TILE_SIZE = 32 захардкоженДля 4K разумнее 64 или 128: tile_map overhead и per-tile spawn cost при 2040 тайлах перевешивают прирост от точного dirty detection. Для мобильных клиентов (Android, 720p) — возможно 16.
Rayon overhead при sparse dirtyПри < 50 грязных тайлах sequential encode быстрее чем spawn 2040 rayon-задач. Adaptive threshold: если dirty_count < 50 — sequential, иначе — rayon. Не реализовано.
90% dirty → лучше IDR чем P-frameПри высоком dirty_ratio overhead на tile_map, per-tile headers и 2040 rayon-задач делает keyframe эффективнее. Конкретный порог нигде не специфицирован и не измерен.
Абстрактные цифры encode-time ничего не говорят сетевому инженеру. Вот что уходит в провод при 30 fps и 60 fps — по сценариям:
Сценарий | Encode | Размер пакета | @ 30 fps | @ 60 fps |
|---|---|---|---|---|
Idle — статичный экран | 2.93 мс | 20 Б | 5 Кбит/с | 10 Кбит/с |
Мигание курсора, статус-бар | 3.17 мс | 779 Б | 187 Кбит/с | 373 Кбит/с |
Печать в терминале (15% dirty) | 3.34 мс | 1.7 КБ | 408 Кбит/с | 816 Кбит/с |
Скролл в браузере (50% dirty) | 4.18 мс | 5.2 КБ | 1.2 Мбит/с | 2.4 Мбит/с |
Видео в браузере (90% dirty) | 5.37 мс | 9.2 КБ | 2.2 Мбит/с | 4.4 Мбит/с |
Raw BGRA 1080p — без кодека | — | 7.86 МБ | 1 887 Мбит/с | 3 774 Мбит/с |
При типичном корпоративном сценарии — оператор читает логи, иногда переключает вкладки — реальный трафик не выходит за 200–400 Кбит/с. Это меньше, чем голосовой звонок в Telegram.
Единственный сценарий где EVRTCK проигрывает H.264 — видео в браузере при 60 fps: 4.4 Мбит/с против ~2 Мбит/с у lossy H.264. Там мы переключаемся на H.264 автоматически по dirty_ratio. Текст и UI — наша территория.
Latency budget: 33.3 мс на 30 fps
Этап | Время | Источник |
|---|---|---|
DXGI capture (IDXGIOutputDuplication) | ~1 мс | Windows API overhead |
EVRTCK encode — idle (NOP path) | 2.93 мс | Criterion |
EVRTCK encode — typing 15% dirty | 3.34 мс | Criterion, 1080p |
EVRTCK encode — scroll 50% dirty | 4.18 мс | Criterion, 1080p |
EVRTCK decode на клиенте | 0.65 мс | roundtrip 3.99 − encode 3.34 |
Сеть LAN 1 Гбит (408 Кбит/с) | < 1 мс | — |
Сеть WAN Москва–СПб VPN | ~15 мс | типичный RTT |
Итого LAN, typing | ~6 мс из 33.3 мс | 5.2× запас |
Итого WAN Москва–СПб, typing | ~21 мс из 33.3 мс | 1.6× запас |
Encode занимает 10–18% frame budget даже в худшем типичном случае. Ботлнек у нас никогда не кодек — всегда сеть.
EVRTCK против протоколов конкурентов
Протокол | Lossless текст | Idle трафик | Typing трафик |
|---|---|---|---|
RFB / VNC (ZRLE) | ✓ | ~0 Кбит/с | ~800 Кбит/с |
RDP / RemoteFX (H.264) | ✗ lossy | 50–200 Кбит/с | 1–3 Мбит/с |
H.264 lossless (QP=0) | ✓ | 200–500 Кбит/с | 2–5 Мбит/с |
EVRTCK | ✓ | 5 Кбит/с | 408 Кбит/с |
RFB/VNC — ближайший родственник по архитектуре. Разница: VNC не делает XOR-дельту на уровне пикселя, не использует zstd, и нет MODE_SOLID. При изменении одного символа в терминале VNC шлёт весь изменившийся прямоугольник. EVRTCK шлёт тайл с XOR-дельтой где ненулевых байт максимум 100.
Encode по разрешениям: полная таблица
P-frame бенчмарки запускались на 1080p. Для других разрешений — линейная экстраполяция по числу тайлов (числа bandwidth-bound, масштабирование линейное):
Разрешение | Тайлов | Keyframe | Idle NOP | Typing 15% | Scroll 50% | @ 30 fps typing |
|---|---|---|---|---|---|---|
720p | 1 035 | 39.4 мс | 2.93 мс | ~1.7 мс | ~2.1 мс | ~208 Кбит/с |
1080p | 2 040 | 86.7 мс | 2.93 мс | 3.34 мс | 4.18 мс | 408 Кбит/с |
1440p | 3 240 | ~138 мс | ~2.93 мс | ~4.2 мс | ~5.3 мс | ~648 Кбит/с |
4K | 8 160 | 344.6 мс | ~2.93 мс | ~8.4 мс | ~10.5 мс | ~1.6 Мбит/с |
Keyframe на 4K — 344 мс — единственная цифра, которая выглядит страшно. Но keyframe случается раз в 10 секунд. 344 мс / 10 000 мс = 3.4% overhead к среднему encode time. На практике незаметно.
NOP latency не масштабируется с разрешением — это всегда memcmp (bandwidth-bound) плюс 20-байтный write. 720p даёт тот же 2.93 мс: оба буфера всё равно вылетают из L3.
Где это работает
EVRTCK — не proof of concept. Это production кодек в EvertyDesk: корпоративный удалённый рабочий стол для российского Enterprise на основе форка RustDesk.
Фича | Статус |
|---|---|
EVRTCK lossless для text/UI | Production |
Автопереключение EVRTCK ↔ H.264 по dirty_ratio | Production |
Smart Agent — heartbeat, push-конфиг, уведомления | Production |
Hyper-V / Proxmox / VirtualBox управление | Production |
GPU backend (WGPU) | Beta |
DXGI dirty rects Phase 2 | Roadmap Q3 2026 |
Исходники EVRTCK выйдут с публичной бетой. Попробовать уже сейчас — desk.everty.ru.
H.264 — отличный кодек для видео. Для экрана, где 90% времени неподвижный текст и пиксель-в-пиксель точность критична — он избыточен в одном месте и недостаточен в другом. EVRTCK решает ровно ту задачу, которую нужно решить, и не решает то, что решать не нужно.
33 строки wire format. Три режима кодирования тайла. 20 байт на статичный кадр. 408 Кбит/с на печать в терминале. 16 unit-тестов, все зелёные.
Если у вас есть вопросы по реализации или собственные цифры на сравнимой задаче — пишите в комментарии.
