
Всем привет! Меня зовут Илья Григорьев, я старший бэкенд-разработчик в команде Телемоста. В этой статье я разберу наш опыт разработки двух фич последнего года — ИИ-конспект с Алисой Про и облачной записи на Диск. Покажу, как мы проектировали их архитектуру, почему не всё получилось с первого раза, с какими системными и техническими ограничениями столкнулись при работе с медиаданными и как в итоге выстроили пайплайн их обработки и анализа.
Идея этих фич возникла из практической потребности: иногда встречи накладываются друг на друга или не получается подключиться вовремя, и встречу приходится пропускать. Чтобы не упускать важное, мы создали возможность быстро восстановить контекст. При этом Телемостом ежемесячно пользуются более 8,3 млн человек, поэтому любые решения нужно было сразу проектировать с учётом высокой нагрузки и стабильности.
Подход к реализации конспектирования и облачной записи схожий, поэтому мы разрабатывали их параллельно. Сначала во время активной фазы конференции аудио- и видеопотоки участников сохраняются в облачное хранилище. Затем запускаются «подкапотные» механизмы агрегации и анализа медиаданных.
В финале пользователи получают готовый артефакт:
для конспектирования это текстовый отчёт, который приходит на почту всем участникам встречи.
для облачной записи — письмо создателю встречи со ссылками на видео- и аудиофайлы. Запись полностью дублирует происходившее на конференции, хранится на Яндекс Диске и воспроизводится прямо в браузере, без необходимости что-то скачивать.

Эволюция архитектуры: от «спагетти» к стейт-машинам
При проектировании системы облачной записи проще всего пойти по пути наименьшего сопротивления — написать линейный код, который последовательно выполняет все этапы обработки. Но этот подход критически неустойчив к ошибкам: любой сбой вынуждает запускать процесс с самого начала. Это и ресурсы тратит впустую на повторные операции, и заметно увеличивает время ожидания для пользователя.
Попытка исправить ситуацию через сохранение промежуточных данных в базу обычно приводит к другой проблеме: код начинает обрастать бесконечными проверками. При перезапуске нужно понять, какие куски логики можно пропустить, какие данные переиспользовать — и всё это через нагромождение if-ов. В итоге получается конструкция, в которой легко запутаться и которую тяжело тестировать.
Именно поэтому мы остановились на стейт-машинах — подходе, в котором у нашей команды накоплена большая экспертиза (советую посмотреть доклады ребят на Joker). Стейт-машина даёт наглядную модель вместо запутанных условий и, что важнее всего, позволяет гарантированно возобновлять работу с последнего зафиксированного состояния. После каждого успешного перехода мы сохраняем прогресс в базу — и весь конвейер обработки становится надёжным.

Схема стейт-машины конспектирования

Схема стейт-машины облачной записи

Проблема ожидания внешних событий
Стейт-машина решает проблему надёжности, но сразу ставит новый вопрос: что делать, когда нужно дождаться длительной внешней операции? Типичный пример — распознавание речи. Мы отправляем аудиодорожку в сервис ASR (Automatic Speech Recognition), получаем в ответ task_id и вынуждены периодически опрашивать сервис, готов ли результат. Этот процесс может занимать от одной до десяти минут, и всё это время стейт-машина фактически простаивает.
Чтобы не изобретать велосипед в каждом стейте, мы выделили два подхода:
Активное ожидание (polling): стейт-машина остаётся в текущем состоянии и через определённые интервалы «просыпается», чтобы проверить статус задачи по API. Просто в реализации, но при большом количестве параллельных процессов создаёт лишнюю нагрузку на базу данных и планировщик.
Регистрация внешних событий: система переходит в состояние ожидания и «засыпает» до момента, пока внешний сервис сам не пришлёт уведомление (webhook) или пока не наступит нужное событие в нашей инфраструктуре.
Ситуацию осложняло то, что таких внешних зависимостей у нас много: нужно дождаться не только распознавания речи, но и обработки видео, генерации саммари нейросетью и успешной загрузки тяжёлых файлов на Яндекс Диск. Чтобы управлять этим хаосом мы внедрили событийную модель — механизм, который позволяет стейт-машине элегантно вставать на паузу и возобновляться ровно в тот момент, когда внешняя система подтверждает готовность данных.
Проектирование механизма ожидания
Как реализовать ожидание внешнего события? Самый очевидный путь — создать в текущем стейте отдельный поток (alarm thread), который через wait/notify будет периодически просыпаться, проверять статус задачи и снова засыпать.
Но для высоконагруженного сервиса это тупиковый путь по двум причинам:
Во-первых, исчерпание квот: пока поток спит внутри стейта, стейт-машина считается активной. У нас есть жёсткие лимиты на количество одновременно выполняющихся машин, и такие «спящие» процессы быстро съели бы всю квоту, блокируя обработку других встреч.
Во-вторых, отсутствие отказоустойчивости: выполнение жёстко привязывается к конкретному инстансу бэкенда. Если инстанс перезагрузится или упадёт, состояние ожидания будет потеряно.
Чтобы обойти эти ограничения, мы внедрили новый тип состояния — Waiting State. Его ключевая идея: между проверками стейт-машина вообще не запускается и не занимает ресурсы бэкенда. Для каждого такого стейта задаётся интервал (Try Interval) — период, раз в который система проверяет наступление события. А поскольку состояние сохраняется в базе, следующую проверку может выполнить любой свободный инстанс приложения — никакой привязки к конкретной машине.
Отдельно предусмотрен контроль времени ожидания: если внешний сервис, например ASR, «залипает» в статусе in progress, процесс не зависнет навсегда. Для каждого Waiting State задаётся жёсткий тайм-аут, по истечении которого система генерирует алерт для оперативного вмешательства. Итоговая логика проста: если событие наступило — стейт-машина переходит к следующему шагу, если нет — выполнение откладывается до следующей итерации.
Автоматизация ретраев и скрытие сложности
@Override public CloudRecordingState nextState(CloudRecordingStateTransitionContext ctx) { CloudRecordingState nextState = nextStateFunction.apply(ctx); int attempt = ctx.getCloudRecordingDtoOnStart().getProcessAttempt(); if (ctx.err() && attempt + 1 >= propertyManager.getCloudRecordingStateProcessorRetryMaxCount()) { return failureActions == FailureActions.FINISH_AND_STALLED ? CLOUD_RECORDING_FINISHED : nextState; } else if (ctx.err()) { return ctx.getStateOnStart(); } else { return nextState; } }
После запуска стейт-машины обнаружилась новая проблема: возникало много мелких сетевых и инфраструктурных ошибок, которые лечились простым ручным перезапуском с последнего стейта. Решение казалось очевидным — внедрить автоматические повторы.
Главная трудность была в том, чтобы сделать это красиво и переиспользуемо, не загромождая бизнес-логику самих стейтов. Мы скрыли всю механику в абстрактных классах. Логика выбора следующего шага теперь сводится к трём случаям: если ошибка есть и лимит попыток исчерпан — переходим в терминальный стейт (Error); если ошибка есть, но попытки остались — возвращаем state_on_start и перезапускаем текущий стейт; если всё прошло успешно — штатно идём дальше. Номер попытки сохраняется в базе и обнуляется только при успешном выходе из стейта.
Такой подход позволил сделать ретраи дефолтным поведением для всех стейтов, не усложняя написание нового кода. Наработки оказались настолько универсальными, что мы уже успешно переиспользовали их в других проектах Яндекса.
Когда архитектурные вопросы были решены, мы запустили тестовую облачную запись — и получили шокирующий результат: видеозапись часовой встречи подготавливалась целых 10 часов.

Для сервиса такого уровня это, разумеется, неприемлемо. Узкое место нашлось быстро — этап сборки видеофайла. Но прежде чем рассказывать, как мы его ускорили, нужно немного погрузиться в то, как вообще устроена работа с медиаданными в Телемосте.
Структура исходных данных и медиапотоки
Чтобы понять, где возникли задержки, нужно разобраться, в каком виде данные вообще поступают на вход. Исходная информация по каждому пользователю сохраняется в облачное хранилище в виде аудио- и видеочанков — небольших фрагментов в среднем по 30 секунд, из которых формируются треки.
Со звуком всё относительно просто: последовательность чанков образует аудиотрек — голос участника. Но у одного пользователя таких треков может быть несколько: основной микрофон и Display Audio (звук при демонстрации экрана). При этом между чанками могут быть пропуски.

С видео сложнее. Мы оперируем треками разных типов и качества: поток с камеры в низком разрешении (layer: low), поток в высоком качестве и отдельный трек с шаринга экрана в Ultra. Всё это нужно корректно свести воедино.

FFmpeg и CPU-intensive задачи
Обработка видео, его кодирование и декодирование — крайне ресурсоёмкая задача. Чтобы не нагружать основные бэкенды, мы вынесли эти процессы на отдельный пул воркеров.
Для всех манипуляций с медиа мы используем FFmpeg — индустриальный стандарт для записи, конвертации и стриминга аудио и видео. Типичная команда в нашем конвейере выглядит примерно так:
ffmpeg -i input.webm -vf "scale=1920:1080" -c:v libvpx -b:v 2M output.webm
Здесь мы берём исходный файл, приводим его к разрешению FullHD, кодируем с помощью libvpx с битрейтом 2 Мбит/с и сохраняем результат.
Но при интеграции FFmpeg в наш стек обнаружилась фундаментальная проблема: у этой библиотеки нет удобного нативного SDK для Java. Это заставило нас искать обходные пути для управления процессами обработки.
Работа с FFmpeg в Java: обертки и процессы
Поскольку у FFmpeg нет нативной библиотеки для Java, основным способом взаимодействия остаётся CLI. Чтобы не городить громоздкий код при каждом вызове, мы инкапсулировали логику формирования команд в класс FFmpegCommand. Внутри него хранится всё необходимое: путь до бинарника FFmpeg в системе, конфигурация входных файлов с флагами преобразования (кодеки, битрейт, разрешение) и путь для сохранения результата.
Метод toCommandString собирает эти данные в строковую команду, которую мы передаём в CommandExecutor. Тот через ShellUtils запускает процесс в операционной системе, дожидается его завершения и обрабатывает результат: если FFmpeg возвращает ошибку — выбрасываем исключение, если всё прошло успешно — получаем stdout.
public class FFmpegCommand implements Command { private final String executablePath; @Singular private final List<FFmpegParam> parameters; @Singular private final List<String> outputParameters; private final String outputSource; @Override public String toCommandString() { String args = parameters.stream() .map(p -> p.name() + " \"" + p.value() + "\"") .collect(Collectors.joining(" ")); String outputArgs = String.join(" ", outputParameters); return executablePath + " -y " + args + " " + outputArgs + " " + " \"" + outputSource + "\""; }
public class CommandExecutor { public String executeCommand(Command command) { return executeCommand(command.toCommandString(), command.includeStderrInOutput()); } public String executeCommand(String command, boolean includeStderrInOutput) { log.info("Full command {}", command); long startTime = System.currentTimeMillis(); ExecResult execResult = ShellUtils.executeGrabbingOutput(command); if (!execResult.isSuccess()) { throw new FFmpegExecutionException( String.format("Command %s Failed: %s", command, execResult.getOutputJoined()) ); } log.info("Command {} took {} ms to complete. Output: {}", command, System.currentTimeMillis() - startTime, execResult.getOutputJoined()); return includeStderrInOutput ? execResult.getOutput() : execResult.getStdout(); } }
Поиск узкого горлышка
Возвращаемся к главной проблеме — десятичасовой сборке часового видео. Анализ показал, что команды FFmpeg сами по себе крайне тяжёлые: энкодинг видео — классическая CPU-intensive задача. В первоначальной реализации все команды выполнялись строго последовательно, что и создавало колоссальные задержки.
Очевидное решение — параллелизм. Но здесь обнаружилась специфика инструмента: один процесс FFmpeg способен утилизировать практически все доступные ресурсы процессора на машине. Параллелить задачи в рамках одного воркера бессмысленно — они просто борются за ресурсы, не давая выигрыша в скорости.
Единственный рабочий вариант — распределять задачи между разными воркерами. Но это потребовало механизмов синхронизации через облачное хранилище: разные узлы системы должны уметь эффективно обмениваться результатами промежуточной обработки.
Оптимизация через MapReduce: слоты и сегменты
Для кардинального ускорения сборки мы применили парадигму MapReduce. Чтобы понять логику, пойдём от обратного: итоговую видеозапись можно представить как совокупность непересекающихся во времени отрезков — сегментов.
Мы выбрали длительность сегмента в 4–5 минут. Ключевая особенность: внутри такого интервала «сетка» — расположение участников на экране — статична. Никто не заходит в конференцию, не выходит, не включает и не выключает камеру. Это критически упрощает финальную сборку кадра. Поскольку сегменты независимы, их можно рендерить параллельно, а в конце просто склеить — в FFmpeg склейка готовых файлов является очень «дешёвой» и быстрой операцией.
Однако параллелизации на уровне сегментов оказалось недостаточно, и мы пошли глубже — декомпозировали сам сегмент. Поскольку видеоданные каждого участника хранятся в облаке отдельно, мы ввели понятие слота: короткое видео одного конкретного пользователя в рамках одного сегмента.
В итоге процесс выглядит так: мы запускаем сотни независимых задач на сборку слотов, распределяем их по всему пулу воркеров, затем объединяем в сегменты — и наконец сводим в единый файл.

Изменения в стейт-машине
Чтобы поддержать эту логику, нам пришлось пересмотреть структуру стейтов.
Раньше процесс состоял всего из двух этапов: в первом стейте ставилась одна огромная задача на сборку всего видео, во втором (Waiting State) система просто ждала её завершения. Медленно и неэффективно.
Теперь стейтов стало пять, что позволило гибко управлять параллелизмом. В первом — Starting Slots Video Build — происходит основная магия планирования: вся видеозапись нарезается на временные сегменты, внутри каждого выделяются слоты по числу активных пользователей, и система сразу генерирует и ставит в очередь все задачи по сборке слотов для всех сегментов одновременно.

Завершение конвейера: сборка сегментов и медиа-комбайнер
После подготовки слотов стейт-машина переходит к этапу Starting Segments Video Build. Это типичный Waiting State: система периодически опрашивает базу, проверяя готовность всех слотов для конкретного сегмента. Как только пазл из пользовательских видео для пятиминутного отрезка собран, запускается задача на рендер самого сегмента.
Когда все сегменты готовы, в игру вступают финальные стадии — Starting Media Combiner и Waiting for Media Combiner. Здесь выполняется одна высокоуровневая задача: все видеосегменты склеиваются в единое полотно, аудиотреки всех пиров миксуются в итоговую аудиодорожку, она накладывается на видеоряд, и результат упаковывается в контейнер для загрузки на Яндекс Диск.
Проблема взрывного роста задач и 30-кратное ускорение
Переход на MapReduce породил новый вызов: количество задач выросло на порядки. Одна конференция теперь генерирует десятки и сотни мелких тасок, и при большом потоке записей воркеры перестали справляться с очередью.
Чтобы разгрести этот завал, мы сделали два шага.
Масштабирование: расширили пул воркеров, подобрав оптимальное соотношение CPU и RAM.
Тонкая настройка FFmpeg: оптимизировали параметры энкодинга, чтобы снизить нагрузку на процессор без потери качества.
Результат превзошёл ожидания: подготовка часовой записи сократилась с 10 часов до 20 минут — ускорение в 30 раз.
На мониторинге разница «до» и «после» видна наглядно. Мы сравнивали два дня с одинаковой нагрузкой — зелёная область на графиках показывает активные записи в конференциях.

По постпроцессингу: в первый день синяя область (записи в обработке) росла как огромный горб — задачи копились и не успевали завершаться. Во второй день она стала едва заметной: данные обрабатываются практически в реальном времени. График очереди задач рассказывает ту же историю: гигантский наплыв первого дня во второй день исчез полностью.
Внедрение стейт-машин и парадигмы MapReduce превратило неповоротливый процесс в масштабируемую и быструю систему, готовую к нагрузкам Телемоста.
Итоги и выводы
Из этой истории я бы выделил три главных урока.
Стейт-машины — спасение для сложных и долгих процессов. Когда у вас десятки интеграций с внешними сервисами и высокий риск инфраструктурных сбоев, линейный код не выдерживает критики. Стейт-машина гарантирует, что даже после череды ошибок процесс рестартует, а пользователь в итоге получит свой конспект или видеозапись.
Не бойтесь кастомизировать инструменты. Мы не просто взяли готовую концепцию, а адаптировали её под свои нужды: внедрили Waiting State для экономии ресурсов и автоматизировали ретраи на уровне базовых классов. Система стала не только надёжной, но и удобной для переиспользования в новых проектах.
Разделяй и властвуй. Если перед вами тяжёлая монолитная задача, которая выполняется часами — дробите её. Переход к модели «сегменты и слоты» позволил эффективно утилизировать ресурсы распределённого пула воркеров и ускорить обработку в 30 раз.
Проектируйте надёжно, оптимизируйте смело — и не бойтесь сложных вызовов.
Комментарии (3)

OlegIct
07.05.2026 08:20Телемост до сих пор поджаривает процессора или исправили? Без издевки, просто интересно как так случилось. Год-два назад несколько раз пользовался, ноутбуки часто выключались: перегрев, батарея села, тротлинг мешал. Была бы интересна история фэйла, как менеджер проекта с этим пытался справиться и пытался ли. Диск, почта работают отлично (кроме маркетинговых экспериментов с аутентификацией и картинкой с острова Пасхи), но Телемост удивил.

Warperus
07.05.2026 08:20Изобретение велосипедрв - это весело, но зачем?
Эти состояния/прицессы, повторы, подпроцессы, сигналы и таймеры, параллельные, компенсационные и т.д. уже давно реализованы в bpmn-движках.
cdriper
Оооочень странная у вас вводная. Если код можно написать линейно, то в этой линейной схеме, очевидно, проще реализовать необходимую корректную логику обработки любых ошибок.
В FSM появляется смысл только при появлении асинхронных процессов о чем у вас вообще нет ни слова.