Сейчас, когда утилита say понравилась многим, а я получил массу вопросов и предложений пришло время придать этой массе некоторое ускорение, сформировав сильные вопросы в детальное теническое описание проекта. На днях утилита была добавлена в AUR, что и подколкнуло меня всё же расписать как всё устроено под капотом.

Для начала уточню: say, это инструмент для видеозвонков, работающий напрямую в окне терминала. Отдельный GUI не требуется: видеопоток рендерится прямо в терминале в текстовом виде (см. рисунок выше). Качество изображения напрямую зависит от размеров терминального окна. Разумеется, приведённый скриншот демонстрирует лишь принцип рендеринга, реальная частота кадров около 30 fps.
Так как же формируется картинка?
Картинка формируется на основе глифов, этот подход я уже подробно и наглядно разбирал в статье Функциональное IT искусство, где описан сам принцип построения изображения. Здесь же я сосредоточусь на реализации: как именно say делает это на практике, шаг за шагом.
Итак, у нас есть размеры терминала. Экран логически делится на две области: в одной отображается поток с локальной камеры, во второй, входящее видео от удалённой стороны. Под капотом работает сервис, отслеживающий изменения размеров терминала. При любом ресайзе он пересчитывает схему разбиения, вертикальную или горизонтальную, затем вычисляет актуальные размеры вьюпортов, применяет их локально и сразу же отправляет обновлённые параметры вьюпорта второму участнику.
Перед отрисовкой gocam запрашивает кадр с камеры и передаёт его кодеку. Кодек, в свою очередь, учитывает локальные размеры вьюпорта и возвращает байтовый массив, уже подготовленный для вывода в терминал.
По тому же принципу формируется и байтовый массив для удалённого клиента. Как упоминалось выше, при изменении размеров терминала обновлённые параметры отправляются второй стороне. Получив этот сигнальный пакет от удаленного клиента, кодек сразу формирует кадры уже нужного размера для удалённого клиента. Т.е. наш кодек всегда знает размер вьюпорта терминала удаленного клиента и готовит кадры с учетом этого размера.
Такой подход выбран для минимизации трафика между участниками. Если бы мы передавали «сырые» кадры с камеры напрямую, а удалённая сторона уже рендерила их под своё разрешение, сразу возник бы вопрос формата передачи. Очевидно, данные пришлось бы сжимать. Но как именно? Допустим, используем JPEG (к слову, на раннем этапе именно так и было). В этом случае мы получаем:
say у Алисы сжимает кадры из камеры в JPEG
say у Алисы отправляет кадры JPEG
say у Боба принимает кадры JPEG
say у Боба пережимает кадры JPEG в глифы
say у Боба отображает глифы
Сравним с текущей реализацией:
say у Алисы сжимает кадры из камеры сразу в глифы
say у Алисы отправляет глифы
say у Боба принимает глифы
say у Боба отображает глифы
Как видно из второй схемы, мы избавляемся от этапов сжатия и распаковки JPEG на обеих сторонах. Это существенно снижает нагрузку на CPU: кодирование JPEG даже при ~20 FPS уже нетривиальная и ресурсоёмкая задача. Так же мы убираем один лишний шаг по преобразованию из JPEG в глифы.
Подводя итог: каждый клиент оперирует двумя наборами размеров локальным и удалённым. При получении кадра с камеры рендер выполняется дважды: для собственного терминала с локальными размерами и для удалённой стороны с согласованными удалёнными размерами. В результате каждый участник рендерит изображение как для себя, так и для второго клиента, а при получении сетевого пакета остаётся лишь вывести готовый кадр на экран.
Разумеется, при активном ресайзе терминала (например, если тянуть границу мышью) возможны кратковременные артефакты: текст переносится между строками и изображение слегка искажается. Впрочем, это лишь вопрос мгновений... уже через несколько миллисекунд рендер перестраивается под новые габариты. Можно сказать, такие глитчи только добавляют атмосферы.

Ты говоришь кодек, а можешь поподробнее?
Этот вопрос часто возникал из-за путаницы, оставшейся после прошлой статьи, так что расставим точки над i.
В утилите фактически две ключевые части. Первая, кодек, который берёт исходное изображение и преобразует его в набор глифов и соответствующих им цветов. Вторая, отображатель в терминале.
Рендер подготавливает изображение строго под конкретные размеры и возможности терминала. Эту часть я назвал BABE-T. Он работает напрямую с теми самыми глифами, которые реально выводятся в терминале (их ровно 51), и дополнительно выполняет всю вспомогательную работу, необходимую для эффективной передачи видеокадров.
Отображение в терминале реализовано через библиотеку tcell (существует несколько её реализаций). Эти библиотеки не просто печатают символы, а работают диффами: при передаче очередного кадра вычисляются изменения и перерисовываются только модифицированные ячейки. Это снижает нагрузку как на CPU, так и на сам терминал, за счёт чего изображение получается более гладким, без заметных артефактов.
В статье я описывал кодек BABE, который во многом появился благодаря ограничениям терминала и наследию ZX Spectrum. Он работает не с глифами, а с полноценными паттернами размером от 1×1 до 8×8.
В BABE-T ситуация иная: используются глифы фиксированного размера 4×8, всего 51 штука (плюс несколько инвертированных вариантов). Отличается и модель цвета. Если в BABE-T применяется палитра на 256 цветов, то в BABE используются три отдельные палитры — по одной на каждый цветовой канал. В сумме они покрывают всё 24-битное цветовое пространство, то есть порядка 16 миллионов цветов.
Т.е. еще раз... BABE-T это специально заточенный движок для сжатия в глифы передаче по сети и легкой прорисовке терминалом. А BABE полноценный формат хранения картинок, с потерями но отличным качеством, высоченной скоростью и неплохим сжатием. Это такой же формат как например jpeg или png или QUI.
Использовать именно сам BABE можно прямо сейчас, например для хранения коллекций фотографий.

Tы говоришь рендер, а что по факту?
А по факту, действительно рендерится. Под капотом выполняется приведение изображения к нужному размеру, причём это скорее оптимизация, чем классический ресайз. Мы избегаем лишних аллокаций, работаем напрямую с YCbCr-массивом, полученным от камеры, и используем только необходимые пиксели, просто пропуская лишние.
Далее начинается поиск ближайшего паттерна, это самая ресурсоёмкая часть рендера. На этом этапе вычисляются усреднённые цвета, после чего формируется и упорядочивается палитра. При этом сама сортировка максимально оптимизирована: по сути, она выполняется прямо в момент обращений к цветам, при каждом обращении к элементу палитры инкрементируется его индекс. В результате ограничение палитры до 256 цветов достигается без заметной нагрузки на CPU.
Палитра всего 256 цветов? А выглядит не так!
Да, на практике это выглядит так, будто палитра значительно шире... и это ещё одна оптимизация. Фактически используются наиболее часто встречающиеся цвета кадра, а при увеличении числа «промахов» палитра динамически пересобирается под текущее изображение. 256 цветов были выбраны лишь потому что в среднем палитра вообще составляет около половины от этого объема. Лицо человека в кадре со всеми оттенками это 50 − 70 цветов, еще несколько цветов на фон.
К слову, сформированная палитра передаётся только в ключевых кадрах. Ключевыми считаются кадры, следующие за теми, в которых ошибка палитры достигает максимума. Механика простая: если на очередном кадре видно, что фон изменился настолько, что требуется новый цвет, кадр отправляется в текущем виде, при этом формируется и сохраняется обновлённая палитра. Уже следующий кадр рендерится с новой палитрой и помечается как ключевой.
Таким нехитрым образом мы существенно экономим трафик, передавая ключевые кадры только тогда, когда это действительно необходимо. Более того, экономия достигается и на представлении цветов: для индекса цвета используется всего один байт.
Всего у нас есть три компоненты Y Cb и Br, это цифровое пространство очень интересно само по себе, и кстати дает уже неплохое сжатие цвета за счет уменьшеной энтропии данных. Каждый массив лежит отдельно массив Y, массив Cb массив Br, и это опять же сделано для оптимизации так как далее у нас к массивам применяется дельта-кодирование.
Поверх этого весь кадр дополнительно сжимается с помощью zstd. После подготовки кадр помещается в буфер отправки и уходит по UDP.
Погоди, а что с глифами, мы их как передаем?
Да, глифы, это базовые элементы, из которых собирается изображение. Их всего 51, а значит под индекс глифа не требуется целый байт... для кодирования любого глифа нам достаточно 6 бит, а значит массив битов это битовый массив и 6 битных элементов но лишь из тех битов которые в данный момент есть в кадре.
Каждый глиф это фон и цвет, т.е. два индекса на палитру цветов, соответственно у нас таким же образом получается два байтовых массива индексов, первый на фоновые цвета, второй на цвета основные. Кстати для индекстов так же применяется дельта кондирование.
Теперь немного арифметики
Берём кадр 320×200 = 64 000 пикселей, в RGB это 3 байта на пиксель:
64 000 × 3 = 192 000 байт ≈ 187,5 Кб несжатых данных.
Переводим кадр в глифы. Один глиф кодирует блок 4×8, значит сетка:
320/4 × 200/8 = 80 × 25 = 2000 глифов.
Дальше считаем полезную нагрузку:
индекс глифа: 6 бит → 2000 × 6 = 12 000 бит = 1500 байт
на каждый глиф два цвета по 1 байту → 2000 × 2 = 4000 байт
палитра (макс) 256 байт
Итого: 1500 + 4000 + 256 = 5756 байт ≈ 5,6 Кб (или ≈ 5,3 Кб без палитры).
После zstd это обычно ужимается примерно до ~3 Кб на кадр.
При 30 FPS получаем ~3 Кб × 30 ≈ 90 Кб/с трафика.

Погоди а как вообще пересылаются данные?
Данные передаются по двум протоколам TCP и UDP. По TCP идут сигнальные сообщения: размеры вьюпортов, рукопожатие, пинги и прочий служебный обмен. По UDP передаются медиаданные, видеокадры и аудиофреймы.
С аудио всё заметно проще: PCM-поток считывается с микрофона, попадает в буфер кодека G.722, сжимается и отправляется по UDP. Каждый пинг, передаваемый по сигнальному каналу, содержит UTC-timestamp, это позволяет оценивать сетевую задержку и RTT.
В качестве туннеля для передачи TCP и UDP-пакетов используется yggdrasil mesh, протокол с хорошо документированной Go-реализацией. Выбор пал именно на него по прагматичной причине: отсутствие зависимостей от C-кода.
По тем же соображениям не использовались готовые сигнальные стеки вроде WebRTC: они избыточны, сложны в портировании на разные платформы и тянут за собой массив зависимостей.
Кодеки G.722 и глифы выбраны в том же ключе, это внутренние реализации, не требующие сторонних пакетов и дополнительной установки со стороны пользователя.
Изначально рассматривалась реализация видеокодека H.261, но от этой идеи пришлось отказаться, о причинах я писал выше, собственный кодек оказался заметно эффективнее именно в контексте текущей архитектуры.
В итоге всё выглядит так: yggdrasil-туннель, сигнальный TCP-канал и дата-канал по UDP, оценка качества соединения и пара специализированных кодеков... G.722 для аудио и BABE-T для видео.
Окей, а просто картинки в терминале смотреть как?
Для отображения же любой картинки в терминале или любого видео в терминале, я сделал отдельную утилиту, которую легко использовать https://github.com/svanichkin/see. Работает она через ffmpeg поэтому может преобразовать любой видео или аудио формат и даже картинки нпосредственно в представление в терминале.

Я добавил несколько режимов отображения. Слева стандартный рендер: изображение собирается из текстовых глифов. Справа вывод через возможности терминалов вроде iTerm. Реализована поддержка всех основных режимов вывода: sixel, iTerm, Kitty. По сути, в терминале можно полноценно смотреть видео и даже фильмы: перемотка на стрелках, пауза пробел.
Утилита поддерживает несколько типов глифов: one (1×1), half (1×2), quarter (2×2), full (4×8), а также разные цветовые режимы: BW, Gray, Color. Например, ключ -quartergray включает кодирование глифами 2×2 с серой палитрой.
При указании флага -super see пытается использовать нативные графические возможности терминала sixel, iTerm или Kitty, полностью обходясь без глифов. В этом режиме терминал принимает JPEG или PNG, упакованные в base64, сам их декодирует и отрисовывает как обычное изображение.
На этом, пожалуй, всё. Я постарался максимально подробно разобрать, что происходит под капотом. Надеюсь, материал получился исчерпывающим. Если останутся вопросы — задавайте, постараюсь ответить.
Комментарии (4)

oxplean
13.12.2025 21:13трафика много жрёт - через vpn-over-sms не заработает
и неплохо бы увидеть работу на zx-babe
Maccimo
Только я хотел под предыдущей публикацией написать, что в списке хабов не хватает «Ненормального программирования», как автор уже тут с новой публикацией!
Кстати, а публикации на английском про этот видеотелефон есть?
svanichkin Автор
Да, есть на Reddit где встретили довольно тепло и есть на некоторых других ресурсах, где пост прошел не замеченым.