Bohdan Rykush
Ко всем заметкам

WebRTC · Chromium · LiveKit · захват экрана

Почему Chromium не удерживает 60 FPS при захвате экрана

Пресет 1080p60 не гарантирует, что браузер действительно будет выдавать 60 кадров в секунду. Разбираем путь кадра от getDisplayMedia до WebRTC-кодировщика и CFR-решение на основе canvas, requestFrame() и изменения одного пикселя.

01 августа 2026 г. · 6 мин чтения

Выбранные 60 FPS не гарантируют 60 кадров на выходе

При браузерном захвате экрана частота кадров зависит от нескольких независимых уровней. Приложение может выбрать пресет 1080p60, запросить 60 FPS через getDisplayMedia() и задать WebRTC-кодировщику maxFramerate: 60. Но это ещё не означает, что источник действительно создаст 60 кадров в секунду.

В этой реализации проблема проявлялась в Chromium на статичном или почти статичном изображении. Частота native capture track и кодировщика могла оставаться около прежнего потолка в 30 FPS, хотя целью были 60 FPS. Подтверждённый механизм — переменная, зависящая от содержимого частота кадров. Экономия ресурсов логично объясняет такую оптимизацию, но для конкретной production-сессии эта причина отдельно не измерялась.

Chromium умеет не передавать повторно неизменившиеся кадры и ограничивать работу захвата, чтобы не тратить CPU без необходимости. Для документа или презентации это разумно. Для трансляции, где дальнейшие компоненты ожидают постоянный поток 60 FPS, оптимизация становится проблемой.

На каком этапе может пропасть FPS

Захват экрана состоит из нескольких звеньев:

Экран или окноgetDisplayMedia trackWebRTC-кодировщикLiveKit SFUВидео зрителя

Каждое звено может показывать своё значение FPS. Для диагностики нужно разделять хотя бы следующие показатели:

УровеньЧто измерятьЧто означает низкое значение
Настройки трекаtrack.getSettings().frameRateСогласованный параметр, но не доказательство реальной частоты
Источникmedia-source.framesPerSecondБраузер подаёт в pipeline недостаточно кадров
Выход кодировщикаoutbound-rtp.framesPerSecondКадры ограничиваются или теряются до отправки
ВоспроизведениеgetVideoPlaybackQuality()Узкое место может быть в доставке, декодировании или рендеринге

В реализации фактический FPS захвата читается из RTC-отчёта media-source, а выходной FPS — из outbound-rtp. Это важно: getSettings().frameRate может по-прежнему показывать запрошенное или согласованное значение, хотя реальных кадров приходит меньше.

Почему constraints и contentHint оказались недостаточны

Базовая конфигурация уже выглядела правильно:

videoTrack.contentHint = 'motion';

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: {
    width: { ideal: 1920, max: 1920 },
    height: { ideal: 1080, max: 1080 },
    frameRate: { ideal: 60, max: 60 },
  },
});

contentHint = 'motion' подсказывает WebRTC сохранять движение и частоту кадров в приоритете перед максимальной детализацией. ideal: 60 задаёт желаемое значение, а max: 60 — верхнюю границу. Эти параметры не заставляют статичный источник создавать новый кадр каждые 16,7 мс.

Для high-FPS пресета после выбора источника применяется более строгое ограничение:

await track.applyConstraints({
  frameRate: { min: 60, ideal: 60, max: 60 },
});

Оно применяется после picker, потому что спецификация W3C Screen Capture запрещает min и exact в исходном вызове getDisplayMedia(). Если источник не поддерживает требование, код повторяет попытку с необязательным max: 60.

На уровне публикации также нужны degradationPreference: 'maintain-framerate', maxFramerate: 60 и отсутствие лишних simulcast-слоёв. Они не создают кадры, но не позволяют следующему этапу снова ввести ограничение 30 FPS или потратить CPU на несколько одновременных encodings.

CFR-решение разделяет обновление изображения и выходной ритм

Если native source работает с переменной частотой, приложение может создать отдельный выходной трек с постоянным cadence. getDisplayMedia() остаётся источником изображения, а canvas-обёртка управляет моментом передачи кадров в LiveKit.

Новый кадр источника1 пиксель и requestFrameNative capture trackТаймер 60 ГцСкрытое videoCanvas 1920x1080Constant-frame-ratetrackLiveKit publishTrack

Native-трек подключается к скрытому <video> через srcObject. requestVideoFrameCallback() срабатывает, когда источник действительно выдаёт новый кадр. Только тогда полное изображение копируется на canvas, поэтому 1920×1080 не перерисовывается на каждом выходном тике.

Canvas-трек создаётся в ручном режиме:

const outputStream = canvas.captureStream(0);
const outputTrack = outputStream.getVideoTracks()[0];

Ноль передаёт управление частотой приложению. При цели 60 FPS новый кадр запрашивается каждые 1000 / 60, то есть примерно раз в 16,7 мс:

let tick = false;

const frameTimer = window.setInterval(() => {
  if (sourceTrack.readyState === 'ended') return;

  tick = !tick;
  context.fillStyle = tick ? 'rgb(0, 0, 0)' : 'rgb(1, 1, 1)';
  context.fillRect(0, 0, 1, 1);
  outputTrack.requestFrame?.();
}, 1000 / targetFps);

То есть решение не выдаёт один кадр в секунду. Оно запрашивает отдельный кадр каждую 1/60 секунды.

Зачем менять один пиксель

По правилам CanvasCaptureMediaStreamTrack новый кадр появляется, когда он запрошен и на canvas нарисовано новое содержимое. Если вызывать requestFrame() на полностью неизменном canvas, браузер может не создать наблюдаемо новый кадр.

Перерисовывать всё изображение 1080p 60 раз в секунду слишком дорого для CPU и растеризации. Поэтому на каждом тике меняется только пиксель 1×1: его цвет переключается между rgb(0, 0, 0) и rgb(1, 1, 1). Визуально разница практически незаметна, но содержимое canvas формально отличается.

Такой подход стабилизирует выходной cadence, но не восстанавливает отсутствующее движение. Если native capture выдаёт 30 содержательно разных кадров, выходной трек может передать 60, однако часть из них повторит последнее изображение с изменением одного пикселя. Это CFR-адаптер, а не интерполяция кадров.

Когда применять обходной путь

Canvas frame pump включается только для пресетов выше 30 FPS. В режиме 1080p30 сохраняется native track: дополнительный canvas, таймер и копирование кадров расходовали бы CPU, не улучшая целевую частоту.

Нужен и безопасный fallback. Если недоступны canvas.captureStream, 2D context или воспроизведение скрытого video, публикуется native track. При остановке трансляции необходимо очистить таймер, отменить requestVideoFrameCallback, остановить выходной трек и исходный display-capture track. Иначе захват может продолжиться после завершения эфира.

setInterval() не является real-time scheduler. Высокая нагрузка на CPU и throttling фоновой вкладки всё ещё могут снижать частоту. Поэтому обходной путь дополняет RTC-мониторинг, а не заменяет его.

Как проверяется регрессия

E2E-сценарий подаёт synthetic source с частотой 30 FPS в пресет 1080p60. До исправления тест должен был обнаружить падение FPS. После добавления CFR-обёртки контракт требует, чтобы выход оставался заметно выше старого потолка в 30 FPS.

В репозитории заданы следующие пороги:

  • текущий выходной FPS — не ниже 48;
  • средний выходной FPS — не ниже 54;
  • целевое значение профиля — 60 FPS.

Это пороги регрессионного теста, а не сохранённый production benchmark. Они подтверждают, что в контролируемом сценарии pipeline больше не застревает на 30 FPS, но не гарантируют полные 60 FPS на любом устройстве или у зрителя.

Практическая проверка должна включать source FPS, outbound FPS, CPU limitation reason, dropped frames и viewer playback FPS. Только совокупность этих показателей позволяет отличить content-adaptive capture от перегрузки кодировщика или проблем доставки.

Практический вывод

Если браузерный screen share не удерживает 60 FPS, сначала нужно найти этап, на котором пропадают кадры. ideal: 60 и maxFramerate: 60 выражают намерение, но не гарантируют постоянный cadence для статичного источника.

Когда продукту действительно нужен constant-frame-rate output, рабочий компромисс выглядит так: native capture остаётся источником изображения, полный canvas обновляется только при настоящих source frames, а промежуточные выходные кадры запрашиваются через canvas.captureStream(0) и requestFrame(). Изменение одного пикселя делает их наблюдаемо новыми без 60 полных перерисовок 1080p в секунду.

Источники

Почему Chromium не удерживает 60 FPS при захвате экрана | Bohdan Rykush