feat: 60-FPS-Streaming-Presets (hd60/fhd60) fuer Bildschirm-Streams (M4)

Nach docs/STREAMING_60FPS.md: neue Presets 720p/1080p mit 60 FPS und
hoher Bitrate (5/8 Mbit/s), Preset-Aufloesung+FPS werden jetzt bis in
die Capture-Constraints durchgereicht (vorher blieb der LiveKit-Default
1080p/15 aktiv), 60-FPS-Presets publishen ohne Simulcast mit
DegradationPreference.maintainFramerate (unter Last Aufloesung statt
Frames opfern). contentHint 'motion' ist in flutter_webrtc 1.4.1 aus
Dart nicht setzbar (geprueft) - im Code dokumentiert.
4 neue Vertragstests fuer die Preset-Tabellen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-06 22:19:25 +02:00
parent 9f797279eb
commit ffd70caffd
4 changed files with 101 additions and 10 deletions
+8 -1
View File
@@ -18,7 +18,14 @@ void _showLiveKitQualityMenu(BuildContext context, VoiceChannelService call, Off
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)),
items: <PopupMenuEntry>[
const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')])
for (final q in [
('sd', 'SD', '360p'),
('hd', 'HD', '720p'),
('hd60', 'HD 60fps', '720p·60'),
('fhd', 'Full HD', '1080p'),
('fhd60', 'Full HD 60fps', '1080p·60'),
('4k', '4K', '2160p'),
])
PopupMenuItem(onTap: () => call.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.currentQualityKey == q.$1, pt: pt)),
const PopupMenuDivider(),
const PopupMenuItem(enabled: false, height: 28, child: Text('EMPFANGEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
@@ -21,17 +21,30 @@ class LiveKitCallManager extends VoiceChannelService {
static final Map<String, VideoParameters> qualityPresets = {
'sd': VideoParametersPresets.h360_169,
'hd': VideoParametersPresets.h720_169,
'hd60': VideoParametersPresets.h720_169,
'fhd': VideoParametersPresets.h1080_169,
'fhd60': VideoParametersPresets.h1080_169,
'4k': VideoParametersPresets.h2160_169,
};
static const Map<String, VideoEncoding> _screenShareEncodings = {
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
/// FPS + Bitrate pro Screenshare-Preset. Die 60-FPS-Presets (M4,
/// `docs/STREAMING_60FPS.md`) brauchen deutlich mehr Bitrate, sonst
/// verwirft der Encoder Frames und es ruckelt trotz hoher Framerate.
@visibleForTesting
static const Map<String, VideoEncoding> screenShareEncodings = {
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
'hd60': VideoEncoding(maxBitrate: 5_000_000, maxFramerate: 60),
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
'fhd60': VideoEncoding(maxBitrate: 8_000_000, maxFramerate: 60),
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
};
/// 60-FPS-Presets bekommen beim Publish eine andere Strategie
/// (kein Simulcast, Framerate vor Auflösung halten).
@visibleForTesting
static bool isHighFpsPreset(String key) => key.endsWith('60');
Room? _room;
EventsListener<RoomEvent>? _roomEventListener;
String _displayName = '';
@@ -445,7 +458,7 @@ class LiveKitCallManager extends VoiceChannelService {
}
VideoEncoding _screenShareEncoding() {
return _screenShareEncodings[_localQualityKey] ??
return screenShareEncodings[_localQualityKey] ??
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
}
@@ -454,10 +467,18 @@ class LiveKitCallManager extends VoiceChannelService {
if (isScreenSharing) return;
try {
final encoding = _screenShareEncoding();
final highFps = isHighFpsPreset(_localQualityKey);
final track = await LocalVideoTrack.createScreenShareTrack(
ScreenShareCaptureOptions(
sourceId: sourceId,
maxFrameRate: encoding.maxFramerate.toDouble(),
// Ohne explizite params bleibt die Capture-Auflösung beim
// LiveKit-Default (1080p/15) hängen das Preset (Auflösung UND
// FPS) muss bis in die Capture-Constraints durchgereicht werden.
params: VideoParameters(
dimensions: _currentQuality.dimensions,
encoding: encoding,
),
),
);
@@ -466,8 +487,15 @@ class LiveKitCallManager extends VoiceChannelService {
await _room!.localParticipant!.publishVideoTrack(
track,
publishOptions: VideoPublishOptions(
simulcast: true,
degradationPreference: DegradationPreference.balanced,
// 60-FPS-Presets: EIN Layer mit voller Bitrate statt drei
// Simulcast-Encodes (Software-Encoder!), und unter Last lieber
// Auflösung senken als Frames verlieren. contentHint 'motion'
// wäre ein weiterer Hebel, ist aber in flutter_webrtc 1.4.1 aus
// Dart nicht setzbar (Pub-Cache geprüft, 2026-07-06).
simulcast: !highFps,
degradationPreference: highFps
? DegradationPreference.maintainFramerate
: DegradationPreference.balanced,
screenShareEncoding: encoding,
),
);
@@ -34,7 +34,8 @@ abstract class VoiceChannelService extends ChangeNotifier {
String? get currentRoomId;
String get displayName;
/// Sende-Qualität ('sd' | 'hd' | 'fhd' | '4k').
/// Sende-Qualität ('sd' | 'hd' | 'hd60' | 'fhd' | 'fhd60' | '4k').
/// Die *60-Presets sind für flüssiges Bildschirm-Streaming (M4).
String get currentQualityKey;
/// Empfangs-Qualität ('auto' | 'low' | 'medium' | 'high').
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:pyramid/features/voice_channel/livekit_call_manager.dart';
/// Vertragstests für die Streaming-Qualitäts-Presets (M4,
/// `docs/STREAMING_60FPS.md`). Nagelt fest, was `startScreenShare` und das
/// SENDEN-Menü voraussetzen: jede Auflösung hat eine Encoding-Zeile, die
/// 60-FPS-Presets tragen wirklich 60 FPS und genug Bitrate dafür.
/// (Das echte Publish-Verhalten sender.parameters.encodings im Call
/// braucht zwei verbundene Clients und bleibt ein Gerätetest.)
void main() {
test('Jedes Auflösungs-Preset hat genau eine Encoding-Zeile (und umgekehrt)',
() {
expect(
LiveKitCallManager.qualityPresets.keys.toSet(),
LiveKitCallManager.screenShareEncodings.keys.toSet(),
reason: 'qualityPresets und screenShareEncodings müssen dieselben '
'Keys haben sonst fällt ein Menü-Eintrag auf den Fallback zurück',
);
});
test('60-FPS-Presets: wirklich 60 FPS, hohe Bitrate, richtige Auflösung',
() {
for (final key in ['hd60', 'fhd60']) {
final enc = LiveKitCallManager.screenShareEncodings[key]!;
expect(enc.maxFramerate, 60, reason: '$key muss 60 FPS tragen');
expect(enc.maxBitrate, greaterThanOrEqualTo(4_000_000),
reason: '$key: zu wenig Bitrate für 60 FPS → Encoder ruckelt');
expect(LiveKitCallManager.isHighFpsPreset(key), isTrue);
}
expect(LiveKitCallManager.qualityPresets['hd60']!.dimensions,
VideoParametersPresets.h720_169.dimensions);
expect(LiveKitCallManager.qualityPresets['fhd60']!.dimensions,
VideoParametersPresets.h1080_169.dimensions);
});
test('60-FPS-Preset hat mehr Bitrate als sein Standard-Pendant', () {
final enc = LiveKitCallManager.screenShareEncodings;
expect(enc['hd60']!.maxBitrate, greaterThan(enc['hd']!.maxBitrate));
expect(enc['fhd60']!.maxBitrate, greaterThan(enc['fhd']!.maxBitrate));
});
test('Bestands-Presets unverändert (kein Verhaltenssprung für Uta)', () {
final enc = LiveKitCallManager.screenShareEncodings;
expect(enc['sd'], const VideoEncoding(maxBitrate: 500_000, maxFramerate: 10));
expect(enc['hd'], const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15));
expect(enc['fhd'], const VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20));
expect(enc['4k'], const VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30));
for (final key in ['sd', 'hd', 'fhd', '4k']) {
expect(LiveKitCallManager.isHighFpsPreset(key), isFalse,
reason: '$key darf NICHT als 60-FPS-Preset behandelt werden '
'(sonst verliert es Simulcast)');
}
});
}