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:
@@ -18,7 +18,14 @@ void _showLiveKitQualityMenu(BuildContext context, VoiceChannelService call, Off
|
|||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)),
|
||||||
items: <PopupMenuEntry>[
|
items: <PopupMenuEntry>[
|
||||||
const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
|
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)),
|
PopupMenuItem(onTap: () => call.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.currentQualityKey == q.$1, pt: pt)),
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(enabled: false, height: 28, child: Text('EMPFANGEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
|
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 = {
|
static final Map<String, VideoParameters> qualityPresets = {
|
||||||
'sd': VideoParametersPresets.h360_169,
|
'sd': VideoParametersPresets.h360_169,
|
||||||
'hd': VideoParametersPresets.h720_169,
|
'hd': VideoParametersPresets.h720_169,
|
||||||
|
'hd60': VideoParametersPresets.h720_169,
|
||||||
'fhd': VideoParametersPresets.h1080_169,
|
'fhd': VideoParametersPresets.h1080_169,
|
||||||
|
'fhd60': VideoParametersPresets.h1080_169,
|
||||||
'4k': VideoParametersPresets.h2160_169,
|
'4k': VideoParametersPresets.h2160_169,
|
||||||
};
|
};
|
||||||
|
|
||||||
static const Map<String, VideoEncoding> _screenShareEncodings = {
|
/// 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),
|
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
|
||||||
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
|
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
|
||||||
|
'hd60': VideoEncoding(maxBitrate: 5_000_000, maxFramerate: 60),
|
||||||
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
|
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
|
||||||
|
'fhd60': VideoEncoding(maxBitrate: 8_000_000, maxFramerate: 60),
|
||||||
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
|
'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;
|
Room? _room;
|
||||||
EventsListener<RoomEvent>? _roomEventListener;
|
EventsListener<RoomEvent>? _roomEventListener;
|
||||||
String _displayName = '';
|
String _displayName = '';
|
||||||
@@ -445,7 +458,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VideoEncoding _screenShareEncoding() {
|
VideoEncoding _screenShareEncoding() {
|
||||||
return _screenShareEncodings[_localQualityKey] ??
|
return screenShareEncodings[_localQualityKey] ??
|
||||||
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
|
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,10 +467,18 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
if (isScreenSharing) return;
|
if (isScreenSharing) return;
|
||||||
try {
|
try {
|
||||||
final encoding = _screenShareEncoding();
|
final encoding = _screenShareEncoding();
|
||||||
|
final highFps = isHighFpsPreset(_localQualityKey);
|
||||||
final track = await LocalVideoTrack.createScreenShareTrack(
|
final track = await LocalVideoTrack.createScreenShareTrack(
|
||||||
ScreenShareCaptureOptions(
|
ScreenShareCaptureOptions(
|
||||||
sourceId: sourceId,
|
sourceId: sourceId,
|
||||||
maxFrameRate: encoding.maxFramerate.toDouble(),
|
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(
|
await _room!.localParticipant!.publishVideoTrack(
|
||||||
track,
|
track,
|
||||||
publishOptions: VideoPublishOptions(
|
publishOptions: VideoPublishOptions(
|
||||||
simulcast: true,
|
// 60-FPS-Presets: EIN Layer mit voller Bitrate statt drei
|
||||||
degradationPreference: DegradationPreference.balanced,
|
// 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,
|
screenShareEncoding: encoding,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ abstract class VoiceChannelService extends ChangeNotifier {
|
|||||||
String? get currentRoomId;
|
String? get currentRoomId;
|
||||||
String get displayName;
|
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;
|
String get currentQualityKey;
|
||||||
|
|
||||||
/// Empfangs-Qualität ('auto' | 'low' | 'medium' | 'high').
|
/// Empfangs-Qualität ('auto' | 'low' | 'medium' | 'high').
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user