WebRTC · Chromium · LiveKit · Bildschirmfreigabe
Warum Chromium beim Screen-Sharing keine 60 FPS hält
Ein 1080p60-Preset garantiert noch keine 60 tatsächlich erzeugten Frames pro Sekunde. Der Beitrag verfolgt den Frame-Weg von getDisplayMedia bis zum WebRTC-Encoder und erklärt einen CFR-Fix mit Canvas, requestFrame() und einer Ein-Pixel-Änderung.
01. August 2026 · 6 Min. Lesezeit
Ein 60-FPS-Preset liefert nicht automatisch 60 Frames
Bei einer browserbasierten Bildschirmfreigabe wird die Framerate an mehreren voneinander unabhängigen Stellen beeinflusst. Eine Anwendung kann 1080p60 auswählen, bei getDisplayMedia() 60 FPS anfordern und den WebRTC-Sender mit maxFramerate: 60 konfigurieren. Trotzdem muss die Quelle nicht tatsächlich 60 Frames pro Sekunde erzeugen.
In dieser Implementierung trat das Problem bei statischen oder nahezu statischen Inhalten in Chromium auf. Der native Capture-Track und der Encoder blieben teilweise in der Nähe der früheren 30-FPS-Grenze, obwohl 60 FPS eingestellt waren. Belegt ist damit ein variabler, inhaltsabhängiger Takt. Die Erklärung, Chromium spare dadurch Ressourcen, ist plausibel, wurde für die konkrete Produktionssitzung aber nicht separat gemessen.
Chromium kann unveränderte Bilder auslassen und den Capture-Aufwand begrenzen, um unnötige CPU-Arbeit zu vermeiden. Bei einem Dokument oder einer Präsentation ist das sinnvoll. Erwartet die nachgelagerte Pipeline jedoch einen konstanten 60-FPS-Takt, wird diese Optimierung sichtbar.
An welcher Stelle Frames verloren gehen können
Screen-Sharing besteht aus einer Kette von Komponenten:
Jede Stufe kann eine andere Framerate melden. Für eine saubere Diagnose sollten mindestens folgende Signale getrennt betrachtet werden:
| Stufe | Messwert | Bedeutung eines niedrigen Werts |
|---|---|---|
| Track-Konfiguration | track.getSettings().frameRate | Ausgehandelter Wert, aber kein Beleg für den tatsächlichen Takt |
| Capture-Quelle | media-source.framesPerSecond | Chromium führt der Pipeline zu wenige Frames zu |
| Encoder-Ausgang | outbound-rtp.framesPerSecond | Frames werden vor dem Versand begrenzt oder verworfen |
| Wiedergabe | getVideoPlaybackQuality() | Engpass bei Übertragung, Decoding oder Rendering |
Die Implementierung liest die reale Capture-Framerate aus dem RTC-Bericht media-source und die Ausgangsframerate aus outbound-rtp. Das ist wichtig, weil getSettings().frameRate weiterhin den angeforderten oder ausgehandelten Wert anzeigen kann, obwohl tatsächlich weniger Frames fließen.
Warum Constraints und contentHint nicht ausreichen
Die grundlegende Konfiguration sah bereits korrekt aus:
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' signalisiert WebRTC, Bewegung und Framerate gegenüber maximaler Detailtreue zu bevorzugen. ideal: 60 beschreibt einen Wunsch, max: 60 eine Obergrenze. Keiner dieser Werte zwingt eine statische Quelle dazu, alle 16,7 Millisekunden ein neues Bild zu liefern.
Nach der Quellenauswahl wird für das High-FPS-Preset zusätzlich ein strengerer Constraint gesetzt:
await track.applyConstraints({
frameRate: { min: 60, ideal: 60, max: 60 },
});
Dieser Schritt erfolgt erst nach dem Auswahlfenster. Die W3C-Spezifikation für Screen Capture verbietet min und exact im ursprünglichen getDisplayMedia()-Aufruf. Kann die gewählte Quelle die Vorgabe nicht erfüllen, fällt der Code auf das unverbindliche max: 60 zurück.
Auch auf Publishing-Ebene müssen degradationPreference: 'maintain-framerate', maxFramerate: 60 und der Verzicht auf unnötige Simulcast-Layer stimmen. Sie erzeugen keine Frames, verhindern aber, dass eine spätere Stufe erneut auf 30 FPS begrenzt oder CPU für mehrere Encodings verbraucht.
Der CFR-Fix trennt Bildänderung und Ausgabetakt
Bei einer nativen Quelle mit variabler Framerate lässt sich ein separater Track mit konstantem Takt erzeugen. getDisplayMedia() bleibt für den Bildinhalt zuständig; ein Canvas-Track steuert, wann LiveKit einen Frame erhält.
Der native Track wird über srcObject an ein verborgenes <video> gebunden. requestVideoFrameCallback() läuft nur bei einem tatsächlich neuen Quellframe. Erst dann wird das vollständige Bild auf das Canvas kopiert. Dadurch entfällt ein unnötiges 1920×1080-Redraw bei jedem Ausgabetakt.
Der Canvas-Track startet im manuellen Modus:
const outputStream = canvas.captureStream(0);
const outputTrack = outputStream.getVideoTracks()[0];
Die Null überlässt die Zeitsteuerung der Anwendung. Bei 60 FPS wird ungefähr alle 1000 / 60, also alle 16,7 Millisekunden, ein Frame angefordert:
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);
Es wird also nicht ein Frame pro Sekunde ausgegeben, sondern ein Frame pro 1/60 Sekunde angefordert.
Warum ein einziges Pixel geändert wird
Nach den Regeln für CanvasCaptureMediaStreamTrack entsteht ein neuer Frame, wenn er angefordert wurde und das Canvas neu gezeichneten Inhalt enthält. Ein bloßes requestFrame() auf einem vollständig unveränderten Canvas muss daher keinen sichtbar neuen Frame erzeugen.
Das gesamte 1080p-Bild 60-mal pro Sekunde neu zu zeichnen würde erhebliche CPU- und Rasterisierungsarbeit verursachen. Stattdessen wechselt bei jedem Timer-Tick ein 1×1-Pixel zwischen rgb(0, 0, 0) und rgb(1, 1, 1). Die Änderung ist praktisch unsichtbar, der Canvas-Inhalt ist technisch aber nicht identisch.
Damit wird der Ausgangstakt stabilisiert, fehlende Bewegung jedoch nicht rekonstruiert. Liefert die native Quelle 30 inhaltlich unterschiedliche Frames, kann der Ausgang 60 Frames senden; ein Teil davon wiederholt das letzte Quellbild mit der Ein-Pixel-Änderung. Es handelt sich um einen CFR-Adapter, nicht um Frame-Interpolation.
Wann der Workaround sinnvoll ist
Der Canvas-Frame-Pump wird nur für Presets oberhalb von 30 FPS aktiviert. Bei 1080p30 bleibt der native Track erhalten, weil zusätzliches Canvas, Timer und Kopieren CPU kosten würden, ohne den Zieltakt zu verbessern.
Ebenso wichtig ist ein sauberer Fallback. Fehlen canvas.captureStream, ein 2D-Kontext oder die Wiedergabe des versteckten Videos, wird der native Track publiziert. Beim Beenden müssen Timer und requestVideoFrameCallback entfernt sowie Ausgangs- und Display-Capture-Track gestoppt werden. Andernfalls könnte die Aufnahme nach Ende des Streams weiterlaufen.
setInterval() ist kein Echtzeit-Scheduler. Unter hoher CPU-Last oder bei gedrosselten Hintergrund-Tabs kann auch dieser Takt absinken. Der Workaround ergänzt daher das RTC-Monitoring, ersetzt es aber nicht.
Wie die Regression geprüft wird
Der E2E-Test speist eine synthetische 30-FPS-Quelle in das Preset 1080p60. Vor dem Fix sollte dieses Szenario eine Framerate-Regression erkennen. Mit dem CFR-Track verlangt der Test, dass der Ausgang deutlich oberhalb der alten 30-FPS-Grenze bleibt.
Im Repository gelten dafür folgende Schwellenwerte:
- aktuelle Ausgangsframerate mindestens 48 FPS;
- durchschnittliche Ausgangsframerate mindestens 54 FPS;
- Zielwert des Profils 60 FPS.
Das sind Regressionsgrenzen, kein gespeicherter Produktionsbenchmark. Sie belegen, dass die kontrollierte Pipeline nicht mehr bei 30 FPS hängen bleibt. Sie garantieren jedoch weder auf jedem Gerät noch beim Zuschauer durchgehend 60 FPS.
Eine belastbare Prüfung sollte Quell-FPS, Ausgangs-FPS, CPU limitation reason, verworfene Frames und die Wiedergabe-FPS beim Zuschauer erfassen. Nur so lassen sich inhaltsabhängiges Capture, Encoder-Überlastung und Übertragungsprobleme auseinanderhalten.
Praktische Schlussfolgerung
Fällt eine Browser-Bildschirmfreigabe unter 60 FPS, muss zuerst die Stufe mit dem Frame-Verlust gefunden werden. ideal: 60 und maxFramerate: 60 formulieren das Ziel, garantieren aber keinen konstanten Takt einer statischen Quelle.
Ist ein Constant-Frame-Rate-Ausgang wirklich erforderlich, ist ein gezielter Kompromiss möglich: Native Capture liefert das Bild, vollständige Canvas-Updates erfolgen nur bei echten Quellframes, und dazwischen fordert canvas.captureStream(0) über requestFrame() weitere Ausgangsframes an. Die Änderung eines Pixels macht sie erkennbar neu, ohne 60 vollständige 1080p-Redraws pro Sekunde zu erzwingen.