Bohdan Rykush
До всіх нотаток

WebRTC · Chromium · LiveKit · захоплення екрана

Чому Chromium не тримає 60 FPS і як це виправити

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

01 серпня 2026 р. · 6 хв читання

60 FPS у налаштуваннях — це ще не 60 кадрів на виході

У браузерному захопленні екрана частота кадрів проходить через кілька незалежних рівнів. Застосунок може вибрати пресет 1080p60, попросити getDisplayMedia() про 60 FPS і встановити для WebRTC-енкодера maxFramerate: 60. Проте це не означає, що джерело справді створюватиме 60 кадрів щосекунди.

У цьому випадку проблема проявлялася у Chromium на статичному або майже статичному зображенні. Native screen-capture track і енкодер могли залишатися біля старої межі приблизно 30 FPS, хоча ціллю були 60 FPS. Важливо не перебільшувати причину: код і тести підтверджують змінну, залежну від вмісту частоту кадрів. Формулювання про економію ресурсів є поясненням цієї оптимізації, а не окремо виміряним фактом для конкретного сеансу.

Chromium уміє не передавати повторно незмінені кадри й обмежувати роботу захоплення, щоб не витрачати CPU без потреби. Для презентації або документа це розумна поведінка. Для трансляції, де downstream-процес очікує постійний cadence 60 FPS, вона стає проблемою.

Де насправді може зникнути FPS

Screen sharing — це не один лічильник FPS, а ланцюжок:

Екран або вікноgetDisplayMedia trackWebRTC encoderLiveKit SFUВідео глядача

На кожному етапі можна побачити інше число. Тому діагностика має розділяти щонайменше такі показники:

РівеньЩо перевірятиЩо означає низьке значення
Налаштування трекуtrack.getSettings().frameRateУзгоджений параметр, але не надійний доказ фактичного cadence
Джерелоmedia-source.framesPerSecondБраузер не подає достатньо кадрів у pipeline
Вихід енкодераoutbound-rtp.framesPerSecondКадри втрачаються або обмежуються до відправлення
ВідтворенняgetVideoPlaybackQuality()Проблема може бути в доставці, декодуванні або рендерингу

У реалізації фактичний FPS захоплення читається з RTC stats media-source, а output 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 пресету після вибору джерела також застосовується жорсткіший constraint:

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 на кілька encoding layers.

CFR-фікс: відокремити оновлення зображення від частоти output

Якщо native source використовує variable frame rate, постійним можна зробити окремий вихідний трек. Для цього native getDisplayMedia() залишається джерелом зображення, але перед LiveKit з'являється canvas-обгортка.

Новий source frame1 px та requestFrameNative capture trackТаймер 60 ГцПрихований videoCanvas 1920x1080Constant-frame-ratetrackLiveKit publishTrack

Прихований <video> отримує native track через srcObject. requestVideoFrameCallback() викликається, коли джерело справді має новий кадр, і тільки тоді повне зображення копіюється на canvas. Так застосунок не перемальовує 1920×1080 без потреби.

Вихідний трек створюється в ручному режимі:

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). Різниця практично непомітна, але кадр формально відрізняється від попереднього.

Цей прийом стабілізує output cadence, але не відновлює рух, якого немає в джерелі. Якщо native capture видає 30 змістовно різних кадрів, вихідний трек може видати 60 кадрів, проте частина з них повторюватиме останнє зображення з однопіксельною зміною. Це CFR-адаптер, а не генератор нової візуальної інформації.

Де застосовувати фікс, а де залишити native track

Canvas frame pump вмикається лише для пресетів вище 30 FPS. Для 1080p30 native capture залишається без обгортки: додатковий canvas, таймер і копіювання кадрів витрачали б CPU без користі для цільового cadence.

Реалізація також повинна мати безпечний fallback. Якщо canvas.captureStream, 2D context або відтворення прихованого video недоступні, публікується native track. Під час зупинки потрібно очистити таймер, скасувати requestVideoFrameCallback, зупинити output track і початковий display-capture track. Інакше браузер може продовжити захоплення після завершення трансляції.

setInterval() не є real-time scheduler. Під високим CPU-навантаженням або у фоновій вкладці він може сповільнюватися. Тому CFR-фікс не скасовує моніторинг media-source та outbound-rtp stats.

Як перевірити, що фікс працює

Регресійний E2E-сценарій подає synthetic source на 30 FPS у пресет 1080p60. До виправлення цей сценарій мав виявляти падіння FPS. Після CFR-обгортки контракт змінився: output має залишатися вище старої межі 30 FPS.

У репозиторії для цього зафіксовані пороги:

  • поточний output FPS — щонайменше 48;
  • середній output 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, а проміжні output frames запитувати через canvas.captureStream(0) і requestFrame(). Однопіксельна зміна робить їх спостережувано новими без повного repaint 60 разів щосекунди.

Джерела

Більше від автора

Код, проєкти та професійні оновлення.

Продовжити знайомство зі мною та моєю роботою можна у GitHub і LinkedIn.

До всіх нотаток
Чому Chromium не тримає 60 FPS у screen sharing | Bohdan Rykush