Как я ускорил покадровую перемотку видео в Chromium
Разбор медиапайплайна Chromium, патча для последовательного seek, проверки кадров и результатов бенчмарка.

В видеоредакторе кнопка «следующий кадр» должна быстро показать соседнее изображение. На веб-странице для этого обычно ставят <video> на паузу, меняют currentTime и ждут события seeked. Отправной точкой для меня стала статья С. Д. Михайлюка о скорости перемотки HTMLVideoElement. Он мой научный руководитель. В своём исследовании он сравнил Chromium и Safari и показал, как в Chromium время перехода растёт до следующего ключевого кадра, а затем резко падает. Я решил выяснить причину этой «пилы».
При переходе на соседний кадр Chromium выполнял обычную перемотку: очищал рендерер, перемещал демультиплексор к ключевому кадру и заново декодировал данные до цели. Так браузер обрабатывает произвольные переходы. При последовательном движении вперёд часть работы повторяется на каждом шаге.
Я подготовил патч для медиапайплайна Chromium, тесты и бенчмарк. На сохранённом наборе измерений для Chromium 146 медиана операции снизилась с 6,8 до 0,3 мс. Патч не меняет HTML API: страница по-прежнему присваивает currentTime и получает seeked. Меняется внутренний путь, если переход можно выполнить из уже подготовленного состояния.
Видеодоклад
Смотреть запись с начала моего выступления, 12:27.
Проблема и мотивация
Покадровый доступ нужен для монтажа, разметки видео, предпросмотра эффекта и экспорта отдельных изображений. Приложение получает кадр, обрабатывает его и переходит к следующему. Когда обработка кадра занимает мало времени, задержка доступа начинает определять скорость всей задачи.
В HTML нет стандартного метода «перейти к следующему кадру». Приложение работает с временной шкалой:
video.pause();
const seekFinished = new Promise((resolve) => {
video.addEventListener('seeked', resolve, { once: true });
});
video.currentTime = targetTime;
await seekFinished;
Целевую позицию для видео с постоянной частотой кадров обычно вычисляют с шагом 1 / fps. Это запрос по времени, а не по номеру кадра. При переменной частоте кадров или сложной временной шкале контейнера такое вычисление само по себе не даёт гарантии точного номера изображения.
Переход с 10,000 на 10,033 секунды выглядит в JavaScript как маленькое изменение числа. Медиапайплайн получает полноценный запрос на перемотку. Событие seeked приходит после завершения внутренних операций.
Форма графика указывает на связь задержки со структурой видео: её рост и сброс совпадают с границами групп кадров. Сравнение Chromium и Safari помогает найти направление поиска, но не измеряет эффект моего патча. Для этого ниже используются две сборки одной версии Chromium.
Немного теории видео
Большинство видео хранит кадры с межкадровым сжатием. I-кадр можно декодировать самостоятельно. P-кадру нужны предыдущие опорные изображения, B-кадру могут понадобиться изображения с обеих сторон. Между ключевыми кадрами получается группа изображений, GOP.
Контейнер и кодек решают разные задачи. MP4 хранит сведения о треках, пакетах и временных метках. Демультиплексор находит и передаёт сжатые пакеты. Декодер восстанавливает изображение из пакетов и опорных кадров. В потоке с B-кадрами порядок чтения, декодирования и показа может различаться, поэтому «следующий пакет» нельзя считать «следующим видимым кадром».
Схема показывает те же этапы через структуры FFmpeg. AVFormatContext описывает открытый контейнер. Каждый AVStream представляет дорожку, например H.264-видео или AAC-аудио. При чтении получаются AVPacket со сжатыми данными и временными метками. Декодер, настроенный через AVCodec, выдаёт AVFrame. Пакеты и готовые кадры находятся в разных очередях. Даже если файл загружен в память, нужный кадр может ещё не быть декодирован.
Пакет обычно содержит метки времени декодирования и показа. При B-кадрах декодер может получить пакет раньше, чем кадр должен появиться на экране. Поэтому выбор следующего кадра нельзя свести к прибавлению единицы к индексу пакета. Даже если интерфейс приложения знает точный fps, он обращается к временной шкале, а медиапайплайн сам определяет, какие пакеты и опорные изображения нужны для целевого времени.
После сброса декодер снова проходит от ближайшего подходящего ключевого кадра до цели. Внутри одного GOP стоимость серии шагов может расти как сумма 1 + 2 + 3 и далее. На границе GOP этот рост начинается заново, поэтому весь проход по видео нельзя описывать как O(N²). При N переходах и средней длине цепочки зависимостей G грубая оценка стандартного пути составляет O(N · G). Патч убирает повторное прохождение цепочки при подходящих последовательных переходах. Новые кадры по-прежнему нужно читать и декодировать, так что меняется объём работы на шаг, а не зависимость от числа шагов.
Этот эффект хорошо заметен на видео с редкими ключевыми кадрами. Увеличение их числа помогает стандартному seek, потому что путь от I-кадра до цели становится короче. Цена такого обхода: более крупные файлы и необходимость перекодировать исходное видео.
Архитектура медиапайплайна Chromium
При обычном воспроизведении кадр проходит путь от файла до экрана через четыре уровня, показанные на схеме: веб-интерфейсы, Blink, медиастек Chromium и платформенные библиотеки. Страница работает с <video>. Браузер читает контейнер, декодирует аудио и видео, синхронизирует их и передаёт кадры на вывод.
От video-элемента до пайплайна
Страница обращается к HTMLVideoElement. После установки источника или изменения currentTime команда проходит через HTMLMediaElement в Blink. WebMediaPlayerImpl связывает элемент страницы с медиастеком: запускает воспроизведение и перемотку, передаёт состояние готовности обратно элементу. Когда внутренние операции завершены, страница получает событие seeked. Кадры декодируются ниже по цепочке.
PipelineController упорядочивает асинхронные операции над пайплайном, включая запуск, паузу и перемотку. PipelineImpl связывает демультиплексор с рендерером и управляет переходами между состояниями. Здесь выбирается обычный seek или продолжение работы с уже подготовленными данными. На последовательных схемах презентации эта цепочка раскрывается от HTMLMediaElement через WebMediaPlayer и PipelineImpl до demuxer и renderer.
От файла до декодированных кадров
Для файла, переданного в src, FFmpegDemuxer читает контейнер и разделяет его на дорожки. Он получает сжатые пакеты и кладёт их в очереди соответствующих потоков. При последовательном воспроизведении пакеты читаются дальше от текущей позиции; произвольное позиционирование использует av_seek_frame. На этом этапе изображения ещё нет: видеопакет содержит данные для декодера, аудиопакет содержит сжатый звук.
VideoDecoderStream получает пакеты видеодорожки и передаёт их декодеру. Для H.264 декодеру нужны сохранённые опорные изображения, чтобы восстановить P- и B-кадры. Выход декодера может прийти позже очередного запроса: обработка асинхронна, а порядок декодирования не всегда совпадает с порядком показа. Chromium также выбирает конкретную реализацию декодера по платформе и формату. Поэтому сброс декодера меняет больше, чем одну позицию чтения: он уничтожает состояние, подготовленное для следующих кадров.
От готового кадра до экрана
RendererImpl управляет общим медиавременем и согласует AudioRendererImpl с VideoRendererImpl. При воспроизведении звук и изображение должны следовать одной временной шкале. VideoRendererImpl запрашивает декодированные изображения и передаёт их в VideoRendererAlgorithm. Там кадры стоят в очереди с временными метками. Алгоритм решает, какой из них должен быть показан сейчас, а какие уже пропущены или ещё рано выводить.
Выбранный кадр передаётся compositor для вывода на экран. Когда пользователь задаёт новое время, в очереди ещё может оставаться старый кадр, а новый может находиться внутри декодера. Быстрая перемотка должна убрать устаревшие данные, дождаться подходящего изображения и обновить состояние аудио.
Почему решение затрагивает несколько слоёв
В системе одновременно существуют очередь сжатых пакетов, запросы в декодер и очередь готовых кадров. Демультиплексор может уже дочитать данные до будущей позиции, пока декодер обрабатывает прежний запрос. Рендерер может иметь подходящий видеокадр, пока аудиорендерер ещё ждёт данные. Быстрый путь должен проверить каждый слой и сообщить странице о завершении только после их согласования.
На обзорной схеме также есть MSE с ChunkDemuxer и зашифрованное видео с CDM. Эти ветки входят в медиастек иначе. Описанный ниже патч рассчитан на обычный src-путь через FFmpegDemuxer; включать его для других демультиплексоров без собственной проверки нельзя.
В DeepWiki для chromium/chromium можно читать описания подсистемы и переходить к классам и файлам открытого кода. Конкретное поведение можно проверить по исходникам медиаподсистемы Chromium и патчу.
Локализация проблемы
Стандартная цепочка в рассматриваемом src-сценарии выглядит примерно так:
HTMLVideoElement.currentTime
→ WebMediaPlayerImpl
→ PipelineImpl::RendererWrapper::Seek
→ AbortPendingReads
→ Renderer::Flush
→ Demuxer::Seek / FFmpeg av_seek_frame
→ чтение от подходящего ключевого кадра
→ декодирование до целевой позиции
→ seeked
Flush() очищает состояние рендерера; Demuxer::Seek() позиционирует поток. Такая последовательность надёжна для перемотки назад, дальнего скачка и случая, когда текущим очередям нельзя доверять. При переходе на соседний кадр вперёд те же шаги уничтожают ещё полезное состояние: пакеты могут оставаться в очереди, декодер может хранить опорные изображения, а рендерер уже иметь кадр около цели.
На измерениях это проявлялось как пила. Время перехода растёт по мере удаления от ключевого кадра и резко уменьшается после перехода через следующий ключевой кадр.
Вызов AVSeekFrame возвращает поток к ближайшему ключевому кадру, если быстрый вариант FFmpeg не находит подходящую точку. Декодер затем проходит цепочку зависимостей до запрошенного времени. На графике первая перемотка после I-кадра сравнительно дешева. Следующие становятся дороже, пока на границе GOP задержка не падает снова.
Схема с двумя рядами кадров показывает разницу. В варианте «все I» каждый переход может стартовать с нужного изображения. В IPB-потоке точкой входа служит ближайший I-кадр, после которого приходится восстанавливать P- и B-кадры. При очистке декодера на каждом шаге одна и та же часть GOP декодируется снова. Вычисление targetTime в JavaScript не объясняет форму графика.
Альтернативные решения
У браузера есть два метода, которые стоит проверить перед изменением медиапайплайна. HTMLMediaElement.seekToNextFrame() подходит по названию, но он нестандартный и помечен как устаревший. Приложению, которое работает в нескольких браузерах, на него опираться нельзя. fastSeek(time) позволяет браузеру выбрать близкую удобную точку декодирования ценой точности. Для извлечения конкретного кадра нужна точная временная позиция, которую приложение запрашивает через currentTime.
Можно перекодировать ролик в формат «все I», как на верхнем ряду схемы GOP. Тогда почти любой кадр становится точкой входа. Подход годится для заранее подготовленной медиатеки. Пользовательский файл пришлось бы сначала перекодировать, а результат занял бы больше места.
Декодирование можно вынести из стандартного video-элемента. ffmpeg.wasm позволяет читать контейнер и извлекать кадры в WebAssembly. У приложения появляется контроль над пакетами, кодеком и фильтрами. За это приходится загружать сборку FFmpeg и выделять ресурсы для обработки на стороне страницы. Для приложения, которому уже нужен FFmpeg в браузере, это может быть разумно; для одного действия «следующий кадр» слишком много дополнительной работы.
WebCodecs предоставляет VideoDecoder, EncodedVideoChunk и VideoFrame. Схема показывает, что сначала приложение разбирает контейнер и передаёт сжатые блоки декодеру. WebCodecs не демультиплексирует MP4 или WebM. В примере из презентации код загружает файл в ArrayBuffer, получает видеопоток, настраивает VideoDecoder, читает блоки и ждёт завершения декодирования, прежде чем нарисовать VideoFrame на canvas.
В демонстрации переход к следующему кадру работал, но его пришлось собрать из нескольких частей. Приложение следит за очередью декодера, закрывает использованные VideoFrame, выбирает кадр по временной метке и самостоятельно решает, что делать со звуком. Библиотека Mediabunny помогает с чтением и записью медиаконтейнеров, но не превращает HTMLVideoElement.currentTime в дешёвую операцию: это уже собственный пайплайн приложения.
Серверная обработка даёт доступ к нативному FFmpeg, оборудованию и отдельным рабочим процессам. Зато каждый запрос кадра требует передачи данных и серверных ресурсов; интерактивная покадровая навигация зависит от сети. Такой путь подходит для пакетного экспорта или централизованной обработки. Я работал над локальной перемоткой video-элемента с уже загруженным файлом.
Другие браузерные движки
В WebKit вызов HTMLMediaElement::seek() доходит до AVPlayerItem через seek(to:toleranceBefore:toleranceAfter:). Параметры допуска позволяют системному медиастеку учитывать точность перехода. У WebKit другой медиастек и другой контракт внутренних компонентов, поэтому патч Chromium к нему неприменим напрямую.
На графике Firefox задержка тоже растёт внутри GOP и резко падает на его границе. График получен на одном ролике и показывает сходство формы задержки; устройство медиапайплайна Gecko по нему определить нельзя. Данные Firefox не входят в оценку ускорения патча.
Патч: выбор быстрого пути
Я разместил развилку в PipelineImpl::RendererWrapper::Seek() перед стандартной последовательностью Flush() и Demuxer::Seek(). Здесь одновременно доступны направление перехода, демультиплексор и рендерер. Сам демультиплексор не знает, готов ли рендерер. WebMediaPlayerImpl не видит его очередь пакетов. DecoderStream не координирует аудио и видео. Решение на уровне PipelineImpl позволяет каждому компоненту ответить только за собственное состояние.
Условие состоит из трёх проверок:
const bool forward_seek = seek_timestamp >= renderer->GetMediaTime();
const bool demuxer_fast_forward =
forward_seek && demuxer_->ShouldFastForward(seek_timestamp);
const bool renderer_fast_forward =
forward_seek && demuxer_fast_forward &&
renderer->SupportsFastForward(seek_timestamp);
if (forward_seek && demuxer_fast_forward && renderer_fast_forward) {
renderer->FastForwardTo(
seek_timestamp,
base::BindOnce(&RendererWrapper::CompleteFastForwardSeek,
weak_factory_.GetWeakPtr(), seek_timestamp));
return;
}
// Дальше остаётся стандартный путь.
Обратная перемотка, неизвестная позиция чтения и ожидание данных у рендерера остаются на стандартном пути. Базовый Demuxer::ShouldFastForward() возвращает false, поэтому другие реализации демультиплексора не получают новое поведение автоматически. Публичный HTML API не меняется.
На схеме показаны два перехода по ленте кадров. Близкий целевой кадр можно получить продолжением декодирования. Для далёкой цели последовательное чтение может обойтись дороже, чем новый seek к ключевому кадру. ShouldFastForward() проверяет доступность цели без позиционирования, а SupportsFastForward() проверяет состояние рендерера.
Когда обе проверки проходят, PipelineImpl вызывает FastForwardTo() вместо последовательности AbortPendingReads, Flush, Demuxer::Seek. После CompleteFastForwardSeek пайплайн возвращается в рабочее состояние и сообщает о завершении операции. При отрицательном ответе любой проверки выполняется обычный seek. Поведение currentTime для произвольных переходов сохраняется.
Проверка данных в демультиплексоре
В раннем варианте я смотрел на диапазоны буферизации. Они отвечают на вопрос, какие интервалы были загружены, но не говорят, лежат ли нужные пакеты сейчас в очереди демультиплексора. Часть пакетов могла уже перейти в декодер. Для принятия решения этого недостаточно.
Текущий патч добавляет DecoderBufferQueue::ContainsTimestamp(): метод сравнивает целевую временную метку с первым и последним пакетом текущей очереди. Это проверка диапазона меток, а не поиск отдельного пакета с точным временем. FFmpegDemuxer::ShouldFastForward() выбирает опорный поток: сначала видео, а при его отсутствии аудио. Когда цель попадает в диапазон очереди, демультиплексор допускает продолжение чтения без нового позиционирования FFmpeg.
Если цель не попала в диапазон очереди, используется последняя прочитанная временная метка. Патч допускает цель не дальше 5 секунд позади позиции чтения и 2 секунд впереди неё. Это эвристические пределы для близких переходов; они требуют отдельной проверки на более разнообразных файлах. При неизвестной позиции чтения проверка возвращает false.
Целевое время может быть впереди текущей позиции воспроизведения, но позади read head демультиплексора: нужные пакеты были прочитаны заранее. При далёком переходе последовательное чтение становится дороже нового seek, и пайплайн использует стандартный путь.
Рендереры, декодер и синхронизация
После проверки демультиплексора RendererImpl::SupportsFastForward() проверяет состояние STATE_PLAYING и отсутствие ожидания данных. При покадровой навигации видео обычно на паузе: внешний playback_rate равен нулю, хотя внутренний рендерер может оставаться инициализированным и иметь данные. Патч допускает этот случай при наличии видеорендерера и достаточного буфера.
После выбора быстрой ветки RendererImpl останавливает внутренний ход времени, выставляет целевое медиавремя и вызывает FastForwardTo() у аудио- и видеорендереров. Их завершение собирает BarrierClosure. Событие seek не должно считаться завершённым, пока один поток уже перешёл на новую позицию, а второй ещё показывает или воспроизводит старую.
AudioRendererImpl переносит аудиочасы на новую позицию и очищает устаревшие аудиобуферы. VideoRendererImpl просит VideoRendererAlgorithm убрать кадры до цели и проверяет, есть ли уже кадр, подходящий для показа. Если его нет, запрашивает новый декодированный выход. Для кадра, начавшегося чуть раньше цели, учитывается длительность: удалять всё с меткой меньше targetTime нельзя, иначе рендерер на мгновение останется без правильного изображения.
DecoderStream::FastForwardTo() не вызывает Reset(). Он удаляет готовые выходы ниже порога и запоминает этот порог для результата, который ещё находится внутри декодера. Такой результат может вернуться после начала нового перехода; тогда его тоже нужно отбросить. Декодер при этом сохраняет опорные кадры, необходимые для дальнейшего декодирования.
Временная метка кадра обозначает начало показа, а изображение остаётся актуальным до следующего кадра. Удаление всех результатов с меткой меньше целевого времени может убрать изображение, которое ещё покрывает цель. Поэтому порог отбрасывания учитывает длительность кадра. VideoRendererAlgorithm оставляет пригодное изображение в очереди, а при его отсутствии VideoRendererImpl ждёт новый результат декодирования.
Пока новый результат готовится, от старой операции может прийти поздний callback. Патч проверяет такие выходы в FrameReady и отбрасывает кадры, которые уже не подходят к целевому времени. Для аудио логика другая: старые буферы нельзя отправлять на устройство вывода, а AudioClock должен начинать отсчёт от новой позиции. BarrierClosure объединяет завершение аудио и видео, чтобы общий seek завершился после обоих переходов.
У кадров в VideoRendererAlgorithm есть счётчики показа и пропуска. После удаления старых кадров патч сбрасывает их: прежние значения относятся к другой позиции. При завершении перехода RendererImpl повторно объявляет BUFFERING_HAVE_ENOUGH, если данные готовы. WebMediaPlayerImpl::DoSeek() снижает ready state в начале seek, а на быстром пути обычного цикла очистки и повторного наполнения может не быть.
Проверка правильного кадра
Ускорение перемотки бесполезно, если после неё остаётся старый или соседний кадр. Для видеоредактора ошибка на один кадр меняет результат экспорта. Я проверял два уровня поведения.
В патч добавлены три модульных теста Chromium. PipelineImplTest.FastForwardSeek проверяет выбор новой ветки без стандартных Flush() и Demuxer::Seek(). RendererImplTest.SupportsFastForwardAudioVideoPausedBothHaveEnough фиксирует случай видео на паузе, когда у аудио и видео достаточно данных. RendererImplTest.SupportsFastForwardReturnsFalseWhenWaiting требует возврата к обычному пути при ожидании данных.
Для проверки изображения я подготовил синтетические H.264/VP9-видео с номером кадра, закодированным через пиксельные признаки. После перемотки приложение считывало изображение через canvas и сопоставляло его с ожидаемым номером. В наборе сохранено 32 736 строк проверки. Событие seeked подтверждает завершение перехода, а этот тест проверяет, какой кадр показан.
Бенчмарк
Бенчмарк проекта написан на Vue 3 и TypeScript. Перед серией он загружает видео в blob: URL, чтобы сетевой запрос не попадал в горячий цикл. Затем строит последовательность временных меток с шагом 1 / fps, ставит видео на паузу и измеряет время между присваиванием currentTime и событием seeked через performance.now(). После события запускается следующий шаг. Инструмент сохраняет результаты в CSV и JSON-снапшоте для повторного анализа.
Метрика показывает задержку до события seeked. Время до появления пикселей на экране отдельно не измерялось, поэтому результаты нельзя приравнивать к частоте обновления дисплея или скорости декодирования кодека.
Для основного сравнения использовались ролики Big Buck Bunny в 480p и 720p: 30 кадров в секунду, длительность 60 секунд, обычная и увеличенная плотность I-кадров. Инструмент поддерживает набор из 30 видео, включая 1080p, 2160p и 60 кадров в секунду. Таблица ниже сводит сохранённые прогоны; ускорение зависит от конкретного ролика.
Тестовый стенд: Ubuntu 25.10, AMD Ryzen AI 9 HX 370, 32 ГБ RAM и NVIDIA RTX 5070. Для оценки эффекта патча сравнивались две сборки одной версии, Chromium 146 без изменения и Chromium 146 с изменением. Данные Chrome, Brave, Firefox и Yandex Browser использовались как фон: у них другие версии движка и не всегда одинаковое число измерений.
Результаты
В сохранённой сводке у двух сборок Chromium по 7204 операции:
| Метрика | Chromium 146 без патча | Chromium 146 с патчем |
|---|---|---|
| Медиана seek | 6,8 мс | 0,3 мс |
| Среднее время | 19,85 мс | 0,37 мс |
| 95-й процентиль | 69 мс | 1 мс |
| Расчётная частота операций | 50/с | 2734/с |
Медиана уменьшилась в 22,7 раза. Расчётная частота операций равна числу переходов, делённому на сумму их измеренного времени. Значение 2734 операции в секунду описывает пропускную способность бенчмарка; столько изображений на мониторе он не выводит. Среднее значение у исходной сборки намного выше медианы: часть переходов обходилась особенно дорого. После патча среднее и медиана близки.
Для отдельных видео различие ещё заметнее. На 480p с обычной плотностью ключевых кадров медиана исходной сборки была 28,0 мс против 0,3 мс с патчем. При десятикратно более частых I-кадрах исходная сборка ускорялась до 3,6 мс, а сборка с патчем оставалась около 0,3 мс. На 720p соответствующие пары составили 35,6 и 0,3 мс для обычного GOP, 4,8 и 0,3 мс для варианта с более частыми I-кадрами. Чем ближе следующий ключевой кадр, тем дешевле повторное декодирование у стандартного пути.
Результат относится к сохранённым видео, сборкам и стенду. Он не означает, что каждый currentTime в Chrome станет в 22,7 раза быстрее. Ускоренная ветка включается только после проверок, а большая перемотка или ожидание данных продолжат использовать старый путь. На уровне одного кадра также остаются расходы браузерных событий, декодирования, синхронизации и вывода.
Ограничения и материалы
Патч ориентирован на близкое движение вперёд в обычном src-пути через FFmpegDemuxer. Он не оптимизирует MSE и ChunkDemuxer, не ускоряет перемотку назад и не гарантирует выигрыш при дальнем скачке. Окна 5 и 2 секунды в проверке демультиплексора являются инженерной эвристикой. Для их точной настройки понадобился бы отдельный эксперимент на более разнообразных файлах.
Проверка кадров проводилась на H.264 и VP9. Для AV1 и HEVC, а также для разных аппаратных декодеров нужны дополнительные прогоны: их поведение при сохранении состояния может отличаться. Аппаратный путь отдельно не профилировался.
В репозитории проекта лежат C++-изменения, модульные тесты, бенчмарк и сводка результатов. Исходная проблема обсуждается в Chromium issue 418456081. Измерения получены на сборке Chromium с патчем; они не описывают производительность обычной установленной версии Chrome.
Материалы к разбору:
- Презентация (PDF): схемы медиапайплайна, фрагменты кода и графики из доклада.
- Ревью патча в Chromium: сам код изменения и обсуждение.
- Код, тесты и бенчмарк: исходники для воспроизведения эксперимента.