Как мы несколько дней искали проблему с перемоткой WebM
- суббота, 1 августа 2026 г. в 00:00:08
В одном из наших проектов бот записывает видеовстречи из Яндекс Телемоста, сохраняет их в формате WebM и отправляет на backend. После обработки запись появляется в личном кабинете пользователя и воспроизводится через Video.js.
На стенде заметили проблему - если начать смотреть запись с самого начала, то все работает нормально. Перемотка на несколько минут тоже без вопросов. Но после перемотки, например, сразу на десятую минуту, плеер надолго уходил в загрузку. Браузер продолжал подтягивать данные, буфер заполнялся, но воспроизведение не начиналось.
Первым делом мы проверили самые очевидные вещи. Исправили MIME-тип, изменили настройки Video.js, попробовали ограничить ответы backend чанками по 50 МБ. Всe вроде бы логично, но никак не повлияло на поведение плеера.
В итоге оказалось, что проблема вообще не во фронтенде и не на беке, а внутри контейнера WebM.
Если упростить, то наш пайплайн выглядел так:

Пользователь не получает публичную ссылку на видео сразу - сначала клиент запрашивает список доступных записей. Дальше, по клику, фронтенд делает еще один запрос и получает данные для доступа к конкретному файлу:
type RecordingLinkResponse = { token: string; url: string; };
В контракте тогда не было contentType, поэтому тип файла на фронте приходилось определять самостоятельно. Мы брали его из URL, а затем передавали в Video.js.
videojs(element, { sources: [ { src: recordingUrl, type: getRecordingSourceType(recordingUrl), }, ], });
После этого браузер обращается к публичному endpoint - cервер проверяет токен, находит нужный файл и начинает отдавать его содержимое. Для перемотки браузер использует HTTP Range Requests - запрашивает только тот диапазон байт, в котором, как он предполагает, находится нужный фрагмент видео.
Проблемная запись имела такие характеристики:
Размер | 386 939 500 байт |
Длительность | 01:19:09.985 |
Контейнер | VP8, 1920×1080 |
Аудио | Opus |
Воспроизведение с самого начала видео работало без проблем. Перемотка на несколько минут вперед тоже. Проблемы начинаются, если перейти сразу на десятую минуту или дальше - браузер начинал загружать много данных, но воспроизведение долго не возобновлялось. При этом ни в интерфейсе плеера, ни в DevTools никаких ошибок не было.
На этом этапе было неясно даже направление поиска. Range-запросы приходили, сервер отвечал, данные загружались, ошибок не возникало. Единственной проблемой была очень долгая (иногда до двух минут) пауза после перемотки.
Первое, что бросилось в глаза - фронтенд вообще не знал, какой тип файла получает. В ответе API не было contentType, поэтому клиент пытался определить MIME-тип по URL. Если это не удавалось, использовался fallback:
{ src: recordingUrl, type: 'video/mp4', }
Но фактически backend возвращал такой ответ:
Content-Type: video/webm
Получалось, что Video.js считал файл MP4, хотя на самом деле воспроизводил WebM. Выглядело правдоподобно - если плеер получает неправильный MIME-тип, вполне можно ожидать странностей при загрузке или перемотке.
Мы добавили contentType в контракт API, и заодно изменили настройки предварительной загрузки:
videojs(element, { sources: [ { src: recordingUrl, type: contentType, }, ], preload: 'metadata', });
Параметр preload: "metadata" говорит браузеру, что при открытии страницы достаточно загрузить только метаданные файла, а не начинать скачивать его содержимое заранее.
После этого Video.js начал получать корректный MIME-тип. Ошибка вроде исправлена, но проблема с долгой перемоткой никуда не исчезла. Баг пофиксили, но дело не в нем.
Следующая версия возникла уже после просмотра сетевых запросов. После перемотки браузер отменял текущую загрузку и отправлял новый запрос с заголовком Range
Range: bytes=100000000-
Запрос вида bytes=N- означает: «верни всё содержимое файла, начиная с байта N». Backend отвечал корректным статусом 206 Partial Content:
HTTP/1.1 206 Partial Content Accept-Ranges: bytes Content-Type: video/webm Content-Range: bytes 100000000-386939499/386939500 Content-Length: 286939500
Показалось странным, что при перемотке записи на десятую минуту браузер запрашивает почти весь оставшийся файл - около 287 МБ. Возможно из-за такого объема данных плеер долго не может продолжить воспроизведение? Но сам по себе такой запрос корректный. Диапазон bytes=100000000- означает «отдать всё до конца файла». Кроме того, браузер вовсе не обязан скачивать этот диапазон полностью - он может остановить загрузку, когда посчитает что данных достаточно. Однако в DevTools мы видим, что браузер продолжает скачивать, а воспроизведение всё еще не начинается.
В этот момент появилась вполне логичная идея: если браузер просит слишком много, давайте попробуем ограничить размер одного ответа.
На backend добавили ограничение: независимо от того, какой диапазон запрашивал браузер, сервер возвращал не больше 50 МБ за один запрос. Начальная позиция по-прежнему бралась из заголовка Range, а конечная вычислялась так, чтобы размер ответа не превышал заданный лимит.
UX при этом не изменился вообще.
После загрузки первого чанка браузер сразу запрашивал следующий, затем еще один. Воспроизведение по-прежнему не начиналось. В этот момент стало понятно, что проблема не в объеме одного ответа. Мы просто разбили один большой запрос на несколько маленьких. Более того, каждый такой запрос - это новый сетевой round trip, повторная проверка токена и повторный поиск файла. Нагрузка на сервер даже немного выросла.
Главное же было в другом - чанк в 50 МБ никак не связан со структурой видео. Он ничего не знает ни о ключевых кадрах, ни о WebM-кластерах, ни о временной шкале. Это просто произвольная позиция внутри бинарного файла.

Получалось, что мы "оптимизировали" транспортный уровень, хотя проблема совсем в другом месте.
Как браузер перематывает видео
На этом этапе пришлось разобраться, как вообще работает перемотка в WebM.
Интуитивно кажется, что достаточно узнать, в каком месте файла находится нужный момент, запросить этот диапазон через HTTP Range и продолжить воспроизведение. На практике всё немного сложнее. Видео - это не просто последовательность кадров, записанных друг за другом. Контейнер WebM хранит гораздо больше информации:
закодированные аудио- и видеопотоки;
ключевые кадры;
кластеры с медиаданными;
служебные метаданные;
индекс Cues;
SeekHead, который помогает быстро находить другие части контейнера
Когда пользователь перематывает запись, плееру приходится решить две задачи. Во-первых, определить, где в файле находится нужный момент времени. Во-вторых, найти ближайший ключевой кадр, с которого можно начать декодирование.
Именно тут мы впервые задумались, что проблема может быть не в HTTP и не в Video.js, а в том, как устроен сам контейнер WebM.
HTTP Range отвечает только за передачу байтов, но никак не сообщает браузеру, какой диапазон соответствует двадцатой минуте записи. Чтобы сопоставить время с позицией в файле, нужен индекс, который хранится внутри самого контейнера.

Именно здесь появляются два элемента WebM, о важности которых я до этого даже не знал: Cues и SeekHead.
Что такое Cues и SeekHead
Cues - это индекс контейнера WebM (точнее, Matroska), который связывает временные метки с положением данных внутри файла.
Каждый CuePoint обычно содержит:
CueTime - момент времени
CueTrack - номер дорожки;
CueClusterPosition - позицию кластера в файле.
Именно благодаря этому индексу плеер может быстро ответить на вопрос: «Где примерно находится двадцатая минута записи?» — и сразу запросить нужный диапазон через HTTP Range. В спецификации Matroska прямо сказано, что Cues используется для оптимизации поиска по временным меткам. Для обычных файлов (не live-потоков) этот элемент рекомендуется добавлять, а для видеодорожек индекс обычно строится по ключевым кадрам.
Название Matroska происходит от русской матрёшки. Аналогия довольно удачная: внутри контейнера находятся другие вложенные элементы - сегменты, дорожки, кластеры, блоки и метаданные.
Кроме Cues существует еще SeekHead. Он решает похожую задачу, но уже на другом уровне. Вместо отдельных кадров или кластеров он хранит позиции крупных элементов контейнера, включая сами Cues. Если представить контейнер как книгу, то SeekHead — это оглавление, а Cues — предметный указатель. Сначала парсер быстро находит нужный раздел, а затем уже определяет, где находится интересующий момент видео.

Если индекс присутствует, браузер может практически сразу перейти к нужному месту. Если его нет, воспроизвести запись всё равно получится, но поиск нужного участка может занять заметно больше времени, особенно если файл лежит на удаленном сервере.
Исследуем исходный WebM
После этого стало интересно посмотреть, чем вообще отличается проблемный файл.
Я скачал исходную запись длительностью почти 80 минут и сначала посмотрел общую информацию о контейнере:
ffprobe \ -v error \ -show_entries \ format=format_name,duration,size,bit_rate:\ stream=index,codec_name,codec_type,width,height \ -of json \ input.webm
Затем отдельно проверил ключевые кадры:
ffprobe \ -v error \ -select_streams v:0 \ -skip_frame nokey \ -show_entries frame=best_effort_timestamp_time,key_frame,pict_type \ -of csv=p=0 \ input.webm
Получилось вот что:
5 750 WebM-кластеров;
1 094 ключевых кадра;
средний интервал между ключевыми кадрами - 4,35 секунды;
максимальный - около 46 секунд в районе 56-й минуты.
Если бы проблема действительно была в слишком редких ключевых кадрах, перемотка ломалась бы именно из-за них. Но интервал в среднем составлял чуть больше четырех секунд - вполне обычное значение. Получалось, что версия с "неудачным расположением ключевых кадров" тоже не подтверждается.
Оставалось посмотреть уже не на сами потоки, а на структуру контейнера.
Для просмотра я использовал mkvinfo. Если ffprobe в первую очередь показывает информацию о потоках, кодеках и временных характеристиках, то mkvinfo позволяет посмотреть внутреннюю структуру контейнера Matroska.
mkvinfo input.webm | grep -E 'Seek head|Cues|Cluster'
В контейнере присутствовали кластеры с медиаданными, но полностью отсутствовали SeekHead и Cues.
В этот момент всё наконец сложилось. Ключевые кадры в файле были. Кластеры тоже были. А вот индекса, который позволил бы быстро найти нужный кластер по времени, не было вообще. Из-за этого браузеру приходилось искать нужное место самостоятельно, последовательно загружая и анализируя файл. Именно поэтому воспроизведение после перемотки начиналось с такой большой задержкой.
Менять кодек, разрешение, битрейт или интервалы между ключевыми кадрами не требовалось. С самим видеопотоком всё было в порядке - проблема была в структуре контейнера. Нужно просто пересобрать WebM и добавить недостающий индекс:
ffmpeg -y \ -i input.webm \ -map 0 \ -c copy \ -cues_to_front 1 \ output.webm
Разберём параметры команды:
-y - разрешает перезапись выходного файла;
-i input.webm - исходная запись;
-map 0 - переносит все потоки из исходного файла;
-c copy - копирует потоки без декодирования и повторного кодирования;
-cues_to_front 1 - помещает индекс Cues в начало контейнера
По сути это обычный remux, а не перекодирование. Видео и аудио остаются без изменений - меняется только организация контейнера. При этом ffmpeg всё равно приходится полностью прочитать исходный файл и записать новый, поэтому операция требует времени и дополнительного места на диске. Но по сравнению с полноценным транскодированием ее стоимость значительно ниже.
Переносим исправление в записывающего бота
Исправлять уже загруженные файлы мы не стали. Правильное место - момент между завершением записи и публикацией файла. Новый пайплайн стал выглядеть так:

Упрощённая реализация на Python:
import os import subprocess from pathlib import Path def finalize_recording(source: Path) -> None: temporary = source.with_suffix(".processing.webm") subprocess.run( [ "ffmpeg", "-y", "-i", str(source), "-map", "0", "-c", "copy", "-cues_to_front", "1", str(temporary), ], check=True, capture_output=True, text=True, ) if not temporary.exists() or temporary.stat().st_size == 0: raise RuntimeError("Remuxed recording is empty") subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type,codec_name", "-of", "json", str(temporary), ], check=True, capture_output=True, text=True, ) os.replace(temporary, source)
Результат на стенде
После доработки мы проверили решение на стенде. Качество записи не изменилось, перемотка стала происходить без заметных задержек, а браузер перестал делать несколько последовательных Range-запросов в поисках нужного фрагмента. Дополнительная обработка занимала менее 0,5 % от длительности записи и практически не нагружала процессор, так как видео не декодировалось.
Расследование началось с фронтенда, затем пришло к backend и HTTP Range, а в итоге довело до изучения устройства контейнера WebM. Сначала казалось что ограничить размер ответа отличная идея, но на практике это увеличило нагрузку на сервер. Реальное решение оказалось проще: достаточно было выполнить remux записи перед публикацией. Правда, перед этим пришлось "всего лишь" разобраться, как браузер перематывает WebM и как вообще устроен контейнер Matroska.
Пожалуй, это одна из самых интересных сторон разработки: никогда заранее не знаешь, куда приведет очередной баг - иногда достаточно поправить пару строк кода, а иногда приходится разбираться, как устроена технология, с которой работаешь уже не первый год, но ни разу не задумывался, что у нее внутри.