feat(linux): HDR video on a native Wayland plane

Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry
HDR at all, and which forced a whole-window Flutter recomposite for every video
frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with
mpv rendering into an EGL window surface on it through the libmpv render API. The
subsurface is desynchronized, so video and UI now present independently.

With the plane in place HDR follows: the surface is described to the compositor
through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG,
BT.2020 - carrying whatever HDR10 static metadata the stream actually declares.
The description and the buffer it describes land on the same commit, staged and
validated before mpv is switched, so a PQ frame is never presented labelled sRGB.
A five-second watchdog bounds the one wait a compositor could otherwise leave
hanging. A session that cannot host the plane - X11, or a compositor without
wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the
reason: the texture path is gone, and refusing by name beats degrading to
something the user cannot see. An SDR output, a missing capability or an 8-bit
config keep the plane and simply leave it undescribed.

The output's colour state is trusted only when it has been earned. Every landed
property step records itself as it lands; a reset or sequence that cannot
finish downgrades its result to unknown and marks the applied-output cache
untrusted until a clean apply earns it back. A plane whose output state cannot
be named is quarantined - hidden, its description withdrawn - and the
quarantine is recorded state: an unrelated visibility change cannot put a
mislabelled plane back on screen, and only a commit that resolves to a nameable
outcome lifts it. A rect collapsing to zero detaches the buffer exactly as
hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the
next layout pass retries for free, and a refused tone-mapping pick tells the
user instead of dying in a log.

NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window
configs, so the plane takes half-float as the tier between 10-bit unorm and
8-bit, declares the whole surface opaque so the compositor never reads the
alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie.
Whether the output is in HDR is read from luminance headroom above its own
reference white rather than from the preferred transfer function, which current
KWin no longer answers PQ for; the margin is half a stop, because KWin reports
an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090
(driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs.

Who tone-maps is a user choice. The default is the compositor: photographed on a
400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and
separated where the player leg flattens them, because the player path drives
mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the
renderer, not the wiring.

The decision itself - what the source carries, what the output supports, what to
tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of
Wayland and GTK so its luminance validation can be tested without a display
server. Sending an incoherent luminance set is a protocol error that disconnects
the client, so the rules are worth a unit test.

The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL:
the plane links them directly and bundle-libs.sh deliberately never bundles them,
since they are coupled to the running compositor and GPU driver.

lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware -
it drives one clip with scripted mpv properties and reports the colour state mpv
actually settled on. Nothing imports it, so it is tree-shaken out of the app.

Verified on a Steam Deck against an external 400-nit HDR display: the compositor
reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv
vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall -
closer to the reference HDR player than to its own SDR fallback.
This commit is contained in:
edde746
2026-08-10 08:48:13 +02:00
parent c27dc0a1a7
commit bcd6fe9906
76 changed files with 8025 additions and 1833 deletions
+2 -1
View File
@@ -300,7 +300,8 @@ jobs:
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
mpv_player_lifecycle_test \
mpv_property_result_contract_test \
mpv_gpu_bootstrap_test
hdr_metadata_test \
plane_geometry_test
- name: Run Linux native reliability tests
run: |
+1 -1
View File
@@ -132,7 +132,7 @@ Package managers:
[^mb]: Jellyfin and Emby only.
[^plex]: Plex only.
[^connect]: Requires connecting the service under Settings > Services.
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, and tvOS. Dolby Vision on Android and Apple TV.
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, tvOS, and Linux — Linux needs a colour-managed Wayland compositor. Dolby Vision on Android and Apple TV.
[^pass]: Desktop, Android TV, and Apple TV.
[^mpv]: Requires the mpv player backend — unavailable on iOS and tvOS, and Android defaults to ExoPlayer.
[^pip]: Android, iOS, and macOS — not on Android TV or Apple TV.
+308
View File
@@ -0,0 +1,308 @@
// Measurement harness entrypoint. NOT part of the app.
//
// Drives the real PlayerLinux/mpv/Video rendering path with a local file so
// the compositing cost can be measured without a Plex server. Build with:
// flutter build linux --target=lib/dev/harness_main.dart
// Run with:
// PLEZY_HARNESS_MEDIA=/path/to/file.mp4 PLEZY_HARNESS_SECONDS=40 ./plezy
//
// Optional knobs:
// PLEZY_HARNESS_MPV_LOG=v|debug mpv's own log stream
// PLEZY_HARNESS_MPV_PROPS=name=value,... arbitrary mpv properties, so an
// option can be swept without a
// rebuild for each value
// PLEZY_HARNESS_HDR=1 request HDR passthrough
// PLEZY_HARNESS_TONEMAP=compositor|player which side tone-maps; the A/B leg
// PLEZY_HARNESS_INSET=<px> toggles a padding every 6s, so the
// plane has to move, not just resize
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../mpv/models.dart';
import '../mpv/player/player.dart';
import '../mpv/video.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const _HarnessApp());
}
class _HarnessApp extends StatefulWidget {
const _HarnessApp();
@override
State<_HarnessApp> createState() => _HarnessAppState();
}
class _HarnessAppState extends State<_HarnessApp> {
Player? _player;
String _status = 'starting';
int _frames = 0;
int _buildUs = 0;
int _rasterUs = 0;
final Stopwatch _clock = Stopwatch()..start();
Timer? _reportTimer;
Timer? _probeTimer;
Timer? _quitTimer;
Timer? _insetTimer;
double _inset = 0;
@override
void initState() {
super.initState();
SchedulerBinding.instance.addTimingsCallback(_onFrames);
_reportTimer = Timer.periodic(const Duration(seconds: 2), (_) => _report());
_probeTimer = Timer.periodic(const Duration(seconds: 2), (_) => _probe());
final seconds = int.tryParse(Platform.environment['PLEZY_HARNESS_SECONDS'] ?? '');
if (seconds != null && seconds > 0) {
_quitTimer = Timer(Duration(seconds: seconds), () {
_report();
stdout.writeln('HARNESS_DONE');
exit(0);
});
}
final inset = double.tryParse(Platform.environment['PLEZY_HARNESS_INSET'] ?? '');
if (inset != null && inset > 0) {
_insetTimer = Timer.periodic(const Duration(seconds: 6), (_) {
setState(() => _inset = _inset == 0 ? inset : 0);
stdout.writeln('HARNESS_INSET $_inset');
});
}
_start();
}
void _onFrames(List<FrameTiming> timings) {
for (final t in timings) {
_frames++;
_buildUs += t.buildDuration.inMicroseconds;
_rasterUs += t.rasterDuration.inMicroseconds;
}
}
void _report() {
final n = _frames;
final secs = _clock.elapsedMilliseconds / 1000.0;
final fps = secs > 0 ? n / secs : 0.0;
final build = n > 0 ? (_buildUs / n / 1000.0) : 0.0;
final raster = n > 0 ? (_rasterUs / n / 1000.0) : 0.0;
stdout.writeln(
'FRAMESTAT t=${secs.toStringAsFixed(1)} frames=$n '
'fps=${fps.toStringAsFixed(2)} build_ms=${build.toStringAsFixed(2)} '
'raster_ms=${raster.toStringAsFixed(2)}',
);
_frames = 0;
_buildUs = 0;
_rasterUs = 0;
_clock.reset();
}
// Independent of Flutter's frame loop: tells us whether mpv is actually
// advancing even when nothing is being composited.
Future<void> _probe() async {
final player = _player;
if (player == null) return;
try {
final pos = await player.getProperty('time-pos');
final paused = await player.getProperty('pause');
final dropped = await player.getProperty('frame-drop-count');
final decoded = await player.getProperty('decoder-frame-drop-count');
stdout.writeln('PROBE time-pos=$pos pause=$paused drops=$dropped dec_drops=$decoded');
} catch (e) {
stdout.writeln('PROBE_ERROR $e');
}
}
// Which side tone-maps, and against what peak, is decided by mpv options that
// leave no trace on screen: two very different curves look like "the video
// plane works". Reading the effective values back is the only way to tell a
// deliberate target from a default nobody chose - and the render API cannot
// discover the display for itself the way a windowed mpv does, so the answer
// here is not the answer `mpv` alone would give.
//
// Read after open() because the source-dependent ones are unset until a
// format is known.
Future<void> _reportColourState(Player player, String when) async {
const names = <String>[
'target-peak',
'target-trc',
'target-prim',
'tone-mapping',
'hdr-compute-peak',
'video-params/gamma',
'video-params/primaries',
'video-params/sig-peak',
'video-params/max-luma',
];
final parts = <String>[];
for (final name in names) {
try {
parts.add('$name=${await player.getProperty(name) ?? "-"}');
} catch (_) {
// The message is dropped rather than interpolated: this line is parsed
// as space-separated name=value pairs, and an exception string carries
// spaces of its own.
parts.add('$name=<error>');
}
}
stdout.writeln('HARNESS_COLOUR[$when] ${parts.join(' ')}');
}
// build() only shows _status while there is no player, and by this point one
// has usually been assigned. Without clearing it a deliberate abort renders as
// an empty transparent window, i.e. indistinguishable from a hang.
Future<void> _abort(Player? player, String status) async {
await player?.dispose();
if (!mounted) return;
setState(() {
_player = null;
_status = status;
});
}
Future<void> _start() async {
final media = Platform.environment['PLEZY_HARNESS_MEDIA'];
if (media == null || media.isEmpty) {
setState(() => _status = 'set PLEZY_HARNESS_MEDIA');
return;
}
final uri = media.startsWith('/') ? 'file://$media' : media;
try {
final player = Player();
setState(() => _player = player);
final level = Platform.environment['PLEZY_HARNESS_MPV_LOG'];
if (level != null && level.isNotEmpty) {
await player.setLogLevel(level);
stdout.writeln('HARNESS_MPV_LOG $level');
}
// Example: PLEZY_HARNESS_MPV_PROPS='tone-mapping=mobius,target-peak=200'.
// Names the plugin intercepts (hdr-enabled, hdr-tone-mapping) also have
// dedicated knobs, and those are applied *after* this block, so the
// dedicated one wins if both name the same thing. Both writes are logged
// and HARNESS_COLOUR[settled] reads the effective state back, so a capture
// cannot be silently mislabelled either way.
//
// Applied before open(), so the first configured frame already has them.
//
// That timing is also the catch, and it has already produced a wrong
// answer: the native side re-applies the whole output description on
// playback-restart and on every seek, so anything it manages -
// target-peak, target-prim, target-trc, tone-mapping - is overwritten
// moments later. A sweep of those reads back as the shipped value while
// looking perfectly plausible. Use this for properties the runner does not
// set itself; for the ones it does, change the code.
//
// A malformed or rejected override aborts the leg, for the same reason
// PLEZY_HARNESS_TONEMAP does: these captures get labelled with the value
// that was asked for, and carrying on would file the default curve under
// whatever was requested. A silently wrong label is worse than no capture.
final props = Platform.environment['PLEZY_HARNESS_MPV_PROPS'];
if (props != null && props.isNotEmpty) {
for (final pair in props.split(',')) {
final split = pair.indexOf('=');
final name = split > 0 ? pair.substring(0, split).trim() : '';
final value = split > 0 ? pair.substring(split + 1).trim() : '';
String? failure;
if (name.isEmpty || value.isEmpty) {
failure = 'not name=value';
} else {
try {
await player.setProperty(name, value);
stdout.writeln('HARNESS_MPV_PROP $name=$value');
} catch (e) {
failure = '$e';
}
}
if (failure != null) {
stdout.writeln('HARNESS_MPV_PROP_ERROR $pair: $failure');
await _abort(player, 'bad PLEZY_HARNESS_MPV_PROPS: $pair');
return;
}
}
}
// Selected before hdr-enabled so the first description built already uses
// the requested mode; the native side re-applies either way.
//
// A rejected value aborts instead of carrying on. This harness exists to
// produce A/B photographs, and continuing in whatever mode happened to be
// active would label the result with a leg that was never shown.
final toneMapping = Platform.environment['PLEZY_HARNESS_TONEMAP'];
if (toneMapping != null && toneMapping.isNotEmpty) {
try {
await player.setProperty('hdr-tone-mapping', toneMapping);
stdout.writeln('HARNESS_TONEMAP $toneMapping');
} catch (e) {
stdout.writeln('HARNESS_TONEMAP_ERROR $e');
await _abort(player, 'bad PLEZY_HARNESS_TONEMAP: $toneMapping');
return;
}
}
if (Platform.environment['PLEZY_HARNESS_HDR'] == '1') {
try {
await player.setProperty('hdr-enabled', 'yes');
stdout.writeln('HARNESS_HDR requested');
} catch (e) {
stdout.writeln('HARNESS_HDR_ERROR $e');
}
}
await player.open(Media(uri));
stdout.writeln('HARNESS_OPENED $uri');
// Twice, and the second one is the one that means anything. The native
// side applies the output description from playback-restart, which has
// not fired yet: reading only here reports the pre-HDR defaults and makes
// a working transaction look like it never ran. The delayed read is the
// independent check that mpv actually holds what the transaction logged
// asking for - our own log says what was requested, not what landed.
await _reportColourState(player, 'open');
Timer(const Duration(seconds: 6), () async {
if (!mounted || _player != player) return;
await _reportColourState(player, 'settled');
});
} catch (e, st) {
stdout.writeln('HARNESS_ERROR $e\n$st');
await _abort(_player, 'error: $e');
}
}
@override
void dispose() {
_reportTimer?.cancel();
_probeTimer?.cancel();
_quitTimer?.cancel();
_insetTimer?.cancel();
SchedulerBinding.instance.removeTimingsCallback(_onFrames);
// Reported, not ignored: this harness exists to say what mpv did, and a
// teardown that failed is part of that.
if (_player case final player?) {
unawaited(player.dispose().catchError((Object e) => stdout.writeln('HARNESS_DISPOSE_FAILED $e')));
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final player = _player;
return MaterialApp(
debugShowCheckedModeBanner: false,
// Transparent all the way down: in plane mode the video is a Wayland
// subsurface *below* this surface, so anything opaque here hides it.
home: Scaffold(
backgroundColor: Colors.transparent,
body: player == null
? Center(
child: Text(_status, style: const TextStyle(color: Colors.white)),
)
: Padding(
// The inset moves the plane to a non-zero origin, which a plain
// window resize would never exercise.
padding: EdgeInsets.all(_inset),
child: Video(player: player, backgroundColor: Colors.transparent),
),
),
);
}
}
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Səs sinxronizasiyası",
"subtitleSync": "Altyazı sinxronizasiyası",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Səs çıxışı",
"performanceOverlay": "Məhsuldarlıq paneli",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Синхронизация на аудио",
"subtitleSync": "Синхронизация на субтитри",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Аудио изход",
"performanceOverlay": "Оверлей за производителност",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Lydsynkronisering",
"subtitleSync": "Undertekstsynkronisering",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Lydoutput",
"performanceOverlay": "Ydelsesoverlay",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Audio-Synchronisation",
"subtitleSync": "Untertitel-Synchronisation",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Audioausgabe",
"performanceOverlay": "Leistungsanzeige",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Audio Sync",
"subtitleSync": "Subtitle Sync",
"hdr": "HDR",
"hdrUnsupported": "HDR isn't available here — this desktop compositor or video output can't carry it.",
"hdrToneMapping": "HDR Tone Mapping",
"hdrToneMappingCompositor": "Compositor",
"hdrToneMappingCompositorDescription": "Pass the source's HDR metadata through and let the desktop compositor map it.",
"hdrToneMappingPlayer": "Player",
"hdrToneMappingPlayerDescription": "Map to the display's peak brightness in the player, then tell the compositor the result.",
"hdrToneMappingFailed": "Couldn't change HDR tone mapping \u2014 the previous mode is still active.",
"audioOutput": "Audio Output",
"performanceOverlay": "Performance Overlay",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Sincronización de audio",
"subtitleSync": "Sincronización de subtítulos",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Salida de audio",
"performanceOverlay": "Indicador de rendimiento",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Synchronisation audio",
"subtitleSync": "Synchronisation des sous-titres",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Sortie audio",
"performanceOverlay": "Données de performance",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Hang szinkronizálása",
"subtitleSync": "Felirat szinkronizálása",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Hangkimenet",
"performanceOverlay": "Teljesítményadatok",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Sincronizzazione audio",
"subtitleSync": "Sincronizzazione sottotitoli",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Uscita audio",
"performanceOverlay": "Overlay prestazioni",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1635,6 +1635,13 @@
"audioSync": "音声同期",
"subtitleSync": "字幕同期",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "音声出力",
"performanceOverlay": "パフォーマンスオーバーレイ",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Аудио синхрондау",
"subtitleSync": "Субтитр синхрондау",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Аудио шығысы",
"performanceOverlay": "Өнімділік панелі",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1635,6 +1635,13 @@
"audioSync": "오디오 동기화",
"subtitleSync": "자막 동기화",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "오디오 출력",
"performanceOverlay": "성능 오버레이",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Lydsynkronisering",
"subtitleSync": "Undertekstsynkronisering",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Lydutgang",
"performanceOverlay": "Ytelsesoverlegg",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Audiosynchronisatie",
"subtitleSync": "Ondertitelsynchronisatie",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Audio-uitvoer",
"performanceOverlay": "Prestatie-overlay",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1662,6 +1662,13 @@
"audioSync": "Synchronizacja audio",
"subtitleSync": "Synchronizacja napisów",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Wyjście audio",
"performanceOverlay": "Nakładka wydajności",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Sincronia de áudio",
"subtitleSync": "Sincronia de legendas",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Saída de áudio",
"performanceOverlay": "Painel de desempenho",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1662,6 +1662,13 @@
"audioSync": "Синхронизация аудио",
"subtitleSync": "Синхронизация субтитров",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Аудиовыход",
"performanceOverlay": "Оверлей производительности",
"audioOutputDolbyAtmos": "Dolby Atmos",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 38338 (1742 per locale)
/// Strings: 38345 (1742 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+30 -2
View File
@@ -4115,6 +4115,27 @@ class Translations$videoSettings$en {
/// en: 'HDR'
String get hdr => 'HDR';
/// en: 'HDR isn't available here — this desktop compositor or video output can't carry it.'
String get hdrUnsupported => 'HDR isn\'t available here — this desktop compositor or video output can\'t carry it.';
/// en: 'HDR Tone Mapping'
String get hdrToneMapping => 'HDR Tone Mapping';
/// en: 'Compositor'
String get hdrToneMappingCompositor => 'Compositor';
/// en: 'Pass the source's HDR metadata through and let the desktop compositor map it.'
String get hdrToneMappingCompositorDescription => 'Pass the source\'s HDR metadata through and let the desktop compositor map it.';
/// en: 'Player'
String get hdrToneMappingPlayer => 'Player';
/// en: 'Map to the display's peak brightness in the player, then tell the compositor the result.'
String get hdrToneMappingPlayerDescription => 'Map to the display\'s peak brightness in the player, then tell the compositor the result.';
/// en: 'Couldn't change HDR tone mapping — the previous mode is still active.'
String get hdrToneMappingFailed => 'Couldn\'t change HDR tone mapping — the previous mode is still active.';
/// en: 'Audio Output'
String get audioOutput => 'Audio Output';
@@ -7594,6 +7615,13 @@ extension on Translations {
'videoSettings.audioSync' => 'Audio Sync',
'videoSettings.subtitleSync' => 'Subtitle Sync',
'videoSettings.hdr' => 'HDR',
'videoSettings.hdrUnsupported' => 'HDR isn\'t available here — this desktop compositor or video output can\'t carry it.',
'videoSettings.hdrToneMapping' => 'HDR Tone Mapping',
'videoSettings.hdrToneMappingCompositor' => 'Compositor',
'videoSettings.hdrToneMappingCompositorDescription' => 'Pass the source\'s HDR metadata through and let the desktop compositor map it.',
'videoSettings.hdrToneMappingPlayer' => 'Player',
'videoSettings.hdrToneMappingPlayerDescription' => 'Map to the display\'s peak brightness in the player, then tell the compositor the result.',
'videoSettings.hdrToneMappingFailed' => 'Couldn\'t change HDR tone mapping — the previous mode is still active.',
'videoSettings.audioOutput' => 'Audio Output',
'videoSettings.performanceOverlay' => 'Performance Overlay',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
@@ -7631,6 +7659,8 @@ extension on Translations {
'performanceOverlay.maxLuma' => 'Max Luma',
'performanceOverlay.minLuma' => 'Min Luma',
'performanceOverlay.maxCll' => 'MaxCLL',
_ => null,
} ?? switch (path) {
'performanceOverlay.maxFall' => 'MaxFALL',
'performanceOverlay.cacheUsed' => 'Cache Used',
'performanceOverlay.cacheLimit' => 'Cache Limit',
@@ -7638,8 +7668,6 @@ extension on Translations {
'performanceOverlay.player' => 'Player',
'performanceOverlay.memory' => 'Memory',
'performanceOverlay.uiFps' => 'UI FPS',
_ => null,
} ?? switch (path) {
'externalPlayer.title' => 'External Player',
'externalPlayer.useExternalPlayer' => 'Use External Player',
'externalPlayer.useExternalPlayerDescription' => 'Open videos in another app',
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Ljudsynkronisering",
"subtitleSync": "Undertextsynkronisering",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Ljudutgång",
"performanceOverlay": "Prestandaöverlägg",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Ses Senkronizasyonu",
"subtitleSync": "Altyazı Senkronizasyonu",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Ses Çıkışı",
"performanceOverlay": "Performans Katmanı",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Audio sinxronlash",
"subtitleSync": "Subtitr sinxronlash",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Audio chiqishi",
"performanceOverlay": "Unumdorlik paneli",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1635,6 +1635,13 @@
"audioSync": "音訊同步調整",
"subtitleSync": "字幕同步調整",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "音訊輸出",
"performanceOverlay": "效能監控",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1635,6 +1635,13 @@
"audioSync": "音频同步",
"subtitleSync": "字幕同步",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "音频输出",
"performanceOverlay": "性能监控",
"audioOutputDolbyAtmos": "Dolby Atmos",
+9 -3
View File
@@ -1,5 +1,11 @@
import '../player_native.dart';
import '../video_rect_support.dart';
/// Uses libmpv with FlTextureGL — video rendered to an offscreen FBO
/// and composited GPU-side via Flutter's Texture widget.
class PlayerLinux extends PlayerNative {}
/// Uses libmpv on a native Wayland video plane — Linux's only render path.
///
/// Video goes to a `wl_subsurface` stacked below the Flutter surface, the same
/// shape Windows gets from a child HWND: geometry arrives via
/// [VideoRectSupport.setVideoRect] and Flutter never composites the frames.
/// A session that cannot host the plane fails `initialize` outright rather than
/// degrading, so the user sees the reason instead of a black rectangle.
class PlayerLinux extends PlayerNative with VideoRectSupport {}
+4 -22
View File
@@ -2,25 +2,7 @@ import '../player_native.dart';
import '../video_rect_support.dart';
/// Uses libmpv with native window embedding behind the Flutter window.
class PlayerWindows extends PlayerNative with VideoRectSupport {
// Native window embedding, not a Flutter texture.
@override
int? get textureId => null;
@override
Future<void> setVideoRect({
required int left,
required int top,
required int right,
required int bottom,
required double devicePixelRatio,
}) async {
await invoke('setVideoRect', {
'left': left,
'top': top,
'right': right,
'bottom': bottom,
'devicePixelRatio': devicePixelRatio,
});
}
}
///
/// mpv renders into a child HWND, so Flutter only ever tells it where to sit
/// (see [VideoRectSupport]).
class PlayerWindows extends PlayerNative with VideoRectSupport {}
+9 -7
View File
@@ -67,12 +67,6 @@ abstract class Player {
/// this first.
bool get audioPassthroughActive;
/// Texture ID for Flutter's Texture widget (video rendering).
///
/// This is set by the platform implementation when video
/// rendering is initialized. Returns null if not ready.
int? get textureId;
/// The type of player backend being used (e.g., 'mpv', 'exoplayer').
String get playerType;
@@ -267,6 +261,14 @@ abstract class Player {
/// On other platforms, this is a no-op.
Future<void> updateFrame();
/// Whether this player's video output can currently carry HDR.
///
/// A query rather than a constant because on Linux it genuinely varies: the
/// native side needs a 10-bit plane, a compositor advertising the source's
/// transfer function and BT.2020, and an output the compositor reports as
/// being in HDR. Moving the window to an SDR monitor changes the answer.
Future<bool> isHdrOutputSupported();
/// Set the video frame rate for display refresh rate matching.
///
/// On Android, this hints the system to adjust the display refresh rate
@@ -374,7 +376,7 @@ abstract class Player {
/// - macOS/iOS: [PlayerNative] using MPVKit/libmpv with Metal rendering
/// - Android: [PlayerAndroid] using ExoPlayer (default) or [PlayerNative] using MPV (fallback)
/// - Windows: [PlayerWindows] using libmpv with native window embedding
/// - Linux: [PlayerLinux] using libmpv with OpenGL rendering via GtkGLArea
/// - Linux: [PlayerLinux] using libmpv on a native Wayland video plane
///
/// On Android, pass [useExoPlayer] to override the default:
/// - true: Use ExoPlayer (default, better hardware support)
+8 -15
View File
@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart' show ValueListenable, ValueNotifier, protected, visibleForTesting;
import 'package:flutter/foundation.dart' show protected, visibleForTesting;
import 'package:flutter/services.dart';
import '../../media/media_display_criteria.dart';
@@ -49,18 +49,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
@override
PlayerStreams get streams => _streams;
final ValueNotifier<int?> _textureId = ValueNotifier<int?>(null);
@override
int? get textureId => _textureId.value;
ValueListenable<int?> get textureIdListenable => _textureId;
@protected
void setTextureId(int? value) {
if (!_disposed) _textureId.value = value;
}
StreamSubscription? _eventSubscription;
StreamSubscription? _logSubscription;
bool _disposed = false;
@@ -558,6 +546,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
playbackRestartController.add(null);
break;
case 'hdr-output-changed':
hdrOutputChangedController.add(null);
break;
case 'log-message':
final rawPrefix = data?['prefix'];
final rawLevel = data?['level'];
@@ -1072,6 +1064,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> updateFrame() async {}
@override
Future<bool> isHdrOutputSupported() async => false;
@override
Future<bool> setVideoFrameRate(
double fps,
@@ -1384,7 +1379,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
Future<void> dispose({bool preserveDisplayMode = false}) async {
if (_disposed) return;
_disposed = true;
_textureId.value = null;
final channelName = eventChannel.name;
if (identical(_eventChannelOwners[channelName], this)) {
@@ -1434,7 +1428,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
);
}
await closeStreamControllers();
_textureId.dispose();
}
}
+30 -21
View File
@@ -56,9 +56,16 @@ class PlayerNative extends PlayerBase {
@visibleForTesting
static bool debugForceContentFdConversion = false;
/// Overrides the Linux-only video readiness handshake in host tests.
/// Overrides the Linux video-plane detection in host tests.
@visibleForTesting
static bool? debugUseLinuxVideoBootstrap;
static bool? debugUseLinuxVideoPlane;
/// Whether this process drives video through the Linux Wayland plane, which
/// is `Platform.isLinux` and nothing finer — Linux has no other render path.
///
/// The one place the test override is resolved, so production code and host
/// tests agree on which path is live without reading a test-only field.
static bool get usesLinuxVideoPlane => debugUseLinuxVideoPlane ?? Platform.isLinux;
// Set by open() and consumed by that load's file-loaded event, so it is
// not mistaken for a gapless advance (see _handleAudioFileLoaded).
@@ -181,10 +188,6 @@ class PlayerNative extends PlayerBase {
);
}
/// Whether the UI must mount the provisional texture before initialization
/// can complete its first render/bootstrap handshake.
bool get requiresProvisionalTextureSurface => !audioOnly && (debugUseLinuxVideoBootstrap ?? Platform.isLinux);
// Memoizes the in-flight init Future so concurrent callers (e.g. the
// parallel `requestAudioFocus()` and `setProperty()` paths kicked off in
// VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`.
@@ -213,20 +216,7 @@ class PlayerNative extends PlayerBase {
Future<void> _doInitialize() async {
try {
final result = await invoke<Object>('initialize');
final bool ok;
if (result is int) {
// Linux publishes a provisional texture so Flutter can invoke
// FlTextureGL::populate. Playback stays gated until native GPU
// bootstrap reports that the texture is usable.
setTextureId(result);
if (debugUseLinuxVideoBootstrap ?? Platform.isLinux) {
await invoke('waitForVideoReady');
}
ok = true;
} else {
ok = result == true;
}
if (!ok) {
if (result != true) {
throw Exception('Failed to initialize player');
}
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
@@ -255,7 +245,6 @@ class PlayerNative extends PlayerBase {
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
initialized = true;
} catch (e) {
setTextureId(null);
_initFuture = null;
if (!_nativeCoreUnavailable) {
errorController.add(PlayerError('Initialization failed: $e'));
@@ -1062,4 +1051,24 @@ class PlayerNative extends PlayerBase {
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return;
await invoke('abandonAudioFocus');
}
/// See [Player.isHdrOutputSupported] for why this is a query and not a constant.
///
/// Only Linux delegates to the native side, because only there does the answer
/// move: it folds in the output the plane currently sits on. Everywhere else it
/// is a platform constant. Windows has a native query of its own, but nothing
/// consults this method there - the settings sheet offers HDR on Windows
/// unconditionally - so asking would only let the two disagree about the same
/// platform. Nothing is cached on either path.
@override
Future<bool> isHdrOutputSupported() async {
// No video plane without video, on any platform, so this precedes the
// platform question rather than sitting inside one branch of it.
if (_nativeCoreUnavailable || audioOnly) return false;
// Asked through usesLinuxVideoPlane, not Platform.isLinux, so this and the
// settings sheet's _probesHdrSupport resolve the same way under the test
// override; on a real Linux host the two are the same answer.
if (usesLinuxVideoPlane) return await invoke<bool>('isHDRSupported') ?? false;
return Platform.isIOS || Platform.isMacOS || Platform.isWindows;
}
}
@@ -26,6 +26,7 @@ mixin PlayerStreamControllersMixin {
final fileStartedController = StreamController<void>.broadcast();
final fileLoadFailedController = StreamController<void>.broadcast();
final primaryMediaReadyController = StreamController<void>.broadcast();
final hdrOutputChangedController = StreamController<void>.broadcast();
final backendSwitchedController = StreamController<void>.broadcast();
final trackTransitionController = StreamController<String>.broadcast();
@@ -54,6 +55,7 @@ mixin PlayerStreamControllersMixin {
fileLoadFailed: fileLoadFailedController.stream,
primaryMediaReady: primaryMediaReadyController.stream,
backendSwitched: backendSwitchedController.stream,
hdrOutputChanged: hdrOutputChangedController.stream,
trackTransition: trackTransitionController.stream,
);
}
@@ -82,6 +84,7 @@ mixin PlayerStreamControllersMixin {
await fileLoadFailedController.close();
await primaryMediaReadyController.close();
await backendSwitchedController.close();
await hdrOutputChangedController.close();
await trackTransitionController.close();
}
}
+8
View File
@@ -95,6 +95,13 @@ class PlayerStreams {
/// subtitle sidecars finish opening.
final Stream<void> primaryMediaReady;
/// Emits when the compositor's preferred colour description for the video
/// plane changes: the window moved to another output, or an output's HDR
/// state was toggled under it. Linux only, where it is the only notice that
/// [Player.isHdrOutputSupported] may now answer differently - dragging a
/// window between monitors raises no app lifecycle event on Wayland.
final Stream<void> hdrOutputChanged;
/// Stream of seekable buffer ranges from the demuxer cache.
final Stream<List<BufferRange>> bufferRanges;
@@ -131,6 +138,7 @@ class PlayerStreams {
this.fileStarted = const Stream<void>.empty(),
this.fileLoadFailed = const Stream<void>.empty(),
this.primaryMediaReady = const Stream<void>.empty(),
this.hdrOutputChanged = const Stream<void>.empty(),
required this.backendSwitched,
this.trackTransition = const Stream<String>.empty(),
});
+18 -2
View File
@@ -1,11 +1,27 @@
import 'player.dart';
import 'player_native.dart';
mixin VideoRectSupport on Player {
/// Players whose video lives in a native surface behind the Flutter window,
/// and so must be told where to put it.
///
/// Constrained to [PlayerNative] because the request is the same on every such
/// platform — a Windows child HWND and a Wayland subsurface take identical
/// geometry — so the mixin carries the call instead of each platform repeating
/// it. `Video` keys its layout reporting off this type: mixing it in is what
/// says the player has a surface worth positioning.
mixin VideoRectSupport on PlayerNative {
Future<void> setVideoRect({
required int left,
required int top,
required int right,
required int bottom,
required double devicePixelRatio,
}) async {
await invoke('setVideoRect', {
'left': left,
'top': top,
'right': right,
'bottom': bottom,
'devicePixelRatio': devicePixelRatio,
});
}
}
+78 -30
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'models.dart';
import 'player/player.dart';
import 'player/video_rect_support.dart';
@@ -39,7 +40,16 @@ class Video extends StatefulWidget {
}
class _VideoState extends State<Video> {
Rect? _lastRect;
// The integer physical bounds last handed to the native side, and the scale
// that went with them. Cached as what was *sent*, not as the logical rect it
// was derived from, because the rounding in _updateVideoRect is what decides
// whether a layout change is visible to the plane at all.
bool _hasSentRect = false;
int _sentLeft = 0;
int _sentTop = 0;
int _sentRight = 0;
int _sentBottom = 0;
double _sentDevicePixelRatio = 0;
bool _hasFirstFrame = false;
StreamSubscription<void>? _playbackRestartSubscription;
@@ -63,6 +73,11 @@ class _VideoState extends State<Video> {
_playbackRestartSubscription?.cancel();
_listenForPlaybackRestart();
_syncExternalFirstFrame();
// The cache describes the old player's native surface. Keeping it would
// let the next frame short-circuit as "geometry unchanged", and the
// replacement surface stays sizeless — invisible, with no Texture
// fallback left to cover for it.
_hasSentRect = false;
}
}
@@ -108,21 +123,6 @@ class _VideoState extends State<Video> {
}
Widget _buildVideoSurface() {
final player = widget.player;
if (player is PlayerBase) {
return ValueListenableBuilder<int?>(
valueListenable: player.textureIdListenable,
builder: (context, textureId, _) => _buildVideoSurfaceForId(textureId),
);
}
return _buildVideoSurfaceForId(player.textureId);
}
Widget _buildVideoSurfaceForId(int? textureId) {
if (textureId != null) {
return Texture(textureId: textureId);
}
if (widget.player is VideoRectSupport) {
return LayoutBuilder(
builder: (context, constraints) {
@@ -144,24 +144,72 @@ class _VideoState extends State<Video> {
final size = renderBox.size;
final dpr = MediaQuery.devicePixelRatioOf(context);
final newRect = Rect.fromLTWH(position.dx, position.dy, size.width, size.height);
// Rounded outward, the same way the native SetRect biases: it floors the
// position and rounds the buffer size up so the plane always covers at
// least the region Flutter cut out for it. Truncating the far edges here
// would undo that a layer earlier - at a fractional layout position the
// plane comes up a physical pixel short and the desktop shows through the
// seam, where the point of the plane is that the seam is black. Ceil and
// floor are identity on an already-integral value, so an integral layout
// sends exactly the numbers it sent before.
final left = (position.dx * dpr).floor();
final top = (position.dy * dpr).floor();
final right = ((position.dx + size.width) * dpr).ceil();
final bottom = ((position.dy + size.height) * dpr).ceil();
if (_lastRect != null &&
(newRect.left - _lastRect!.left).abs() < 1 &&
(newRect.top - _lastRect!.top).abs() < 1 &&
(newRect.width - _lastRect!.width).abs() < 1 &&
(newRect.height - _lastRect!.height).abs() < 1) {
// Keyed on the four integers actually sent rather than on a logical-pixel
// tolerance. A sub-logical-pixel move is a real move at scale 2 or 3 -
// worth up to three physical pixels of stale placement - while anything too
// small to change one of these numbers cannot reach the plane at all and is
// not worth the channel round-trip.
//
// The scale is part of what the native side is being told, so it has to be
// part of what decides whether to tell it. Moving a window between a
// scale-1 and a scale-2 output can leave all four bounds identical while
// devicePixelRatio changes, and dropping that call leaves the native
// surface at the old buffer resolution: soft at half resolution after
// docking to a HiDPI output, overdrawn at double after undocking. The Steam
// Deck's dock is exactly this.
if (_hasSentRect &&
_sentLeft == left &&
_sentTop == top &&
_sentRight == right &&
_sentBottom == bottom &&
_sentDevicePixelRatio == dpr) {
return;
}
_lastRect = newRect;
_hasSentRect = true;
_sentLeft = left;
_sentTop = top;
_sentRight = right;
_sentBottom = bottom;
_sentDevicePixelRatio = dpr;
(widget.player as VideoRectSupport).setVideoRect(
left: (position.dx * dpr).toInt(),
top: (position.dy * dpr).toInt(),
right: ((position.dx + size.width) * dpr).toInt(),
bottom: ((position.dy + size.height) * dpr).toInt(),
devicePixelRatio: dpr,
);
final player = widget.player as VideoRectSupport;
player.setVideoRect(left: left, top: top, right: right, bottom: bottom, devicePixelRatio: dpr).catchError((
Object e,
) {
// Geometry is the only thing that makes the native surface visible,
// so a rejected rect is a black video area, not a cosmetic glitch.
// Post-frame callbacks have nobody to rethrow to, so route it to the
// player's error stream rather than leaving an unhandled async error.
//
// Drop the sent-rect cache too: it was recorded before the call
// resolved, and keeping it would short-circuit every later identical
// layout pass, freezing the failure in place. Cleared, the next layout
// or resize retries for free.
if (mounted &&
_sentLeft == left &&
_sentTop == top &&
_sentRight == right &&
_sentBottom == bottom &&
_sentDevicePixelRatio == dpr) {
_hasSentRect = false;
}
if (!player.errorController.isClosed) {
player.errorController.add(PlayerError('Failed to set video rect: $e'));
}
});
}
}
-17
View File
@@ -82,23 +82,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
);
}
Widget _buildPlayerInitializationSurface() {
final bootstrapPlayer = _bootstrapPlayer;
if (bootstrapPlayer == null) return _buildLoadingSpinner();
// Linux creates the texture before its EGL/mpv render bootstrap can be
// proven. Mount the provisional surface so Flutter drives one texture
// copy, while retaining the black loading cover until playback itself
// reports its first frame.
return Stack(
fit: StackFit.expand,
children: [
Video(player: bootstrapPlayer, hasFirstFrame: _hasFirstFrame),
const Center(child: PlayerLoadingIndicator()),
],
);
}
Widget _buildInitializationError(String message) {
return Scaffold(
backgroundColor: Colors.black,
+143 -16
View File
@@ -129,6 +129,47 @@ part 'video_player/parts/watch_together.dart';
final WakelockController _wakelockController = WakelockController();
/// Property names the free-form mpv config is not allowed to write.
///
/// Neither is an mpv property. The Linux plugin intercepts both by name and
/// moves its own persistent HDR state instead (linux/runner/mpv/mpv_plugin.cc),
/// and the custom config is applied *after* startup has pushed the stored
/// preferences, so a `hdr-enabled=yes` or `hdr-tone-mapping=player` line would
/// change the live plane without anything writing it back to [SettingsService].
/// The settings sheet renders its HDR switch and tone-mapping row straight off
/// those preferences with no native readback, so the UI would report one state
/// while the plane held another - for the whole session, and again after a
/// restart, since the next startup replays the same order rather than
/// reconciling.
///
/// Filtered rather than reordered: reordering would still leave the config as a
/// second writer of state the app owns, silently discarded on every startup
/// instead of silently winning. Nothing legitimate is lost - both names are
/// settings the player's own HDR controls already expose, and mean nothing to
/// mpv itself, so no platform is losing a real mpv property here.
const _appInterceptedMpvProperties = {'hdr-enabled', 'hdr-tone-mapping'};
/// The above, plus the four real mpv properties the Linux video plane owns.
///
/// It writes all four as one unit and caches what it last applied so it can skip
/// a transaction that would change nothing. A config line writing one of them
/// moves mpv without moving that cache, and the next transaction then compares
/// against a value mpv no longer holds and skips the write it needed to make -
/// leaving mpv encoding one colour space while the surface is described as
/// another, the single state the two-phase apply exists to prevent.
///
/// Scoped to the plane deliberately. These are ordinary mpv properties
/// everywhere else, nothing caches them there, and no other platform exposes a
/// UI control for them - so withholding them off Linux would remove the user's
/// only way to set them and point the log at a control they do not have.
const _appOwnedMpvProperties = {
..._appInterceptedMpvProperties,
'target-trc',
'target-prim',
'target-peak',
'tone-mapping',
};
/// Whether an in-place source reload may start the replacement media.
///
/// Reloading a paused player must not manufacture a new play intent. Watch
@@ -410,7 +451,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
static bool isNavigationActive(VideoPlayerLaunchIdentity identity) => _activeRouteGuard.blocks(identity);
Player? player;
Player? _bootstrapPlayer;
VideoVolumeController? _volumeController;
bool _isPlayerInitialized = false;
String? _playerInitializationError;
@@ -1149,9 +1189,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (identical(player, attemptPlayer)) {
player = null;
}
if (identical(_bootstrapPlayer, attemptPlayer)) {
_bootstrapPlayer = null;
}
try {
await _tearDownFailedPlayerAttempt(attemptPlayer);
} catch (e, st) {
@@ -1223,9 +1260,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final currentPlayer = Player(useExoPlayer: useExoPlayer);
attemptPlayer = currentPlayer;
if (!mounted || generation != _playerInitializationGeneration) return;
if (currentPlayer is PlayerNative && currentPlayer.requiresProvisionalTextureSurface) {
setState(() => _bootstrapPlayer = currentPlayer);
}
if (Platform.isAndroid && useExoPlayer) {
await currentPlayer.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
if (!mounted || generation != _playerInitializationGeneration) return;
@@ -1409,10 +1443,90 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
await currentPlayer.setAudioPassthrough(settingsService.read(SettingsService.audioPassthrough));
}
// HDR is controlled via custom hdr-enabled property on iOS/macOS/Windows
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows) {
// Set before hdr-enabled so the first image description is already built
// for the chosen mode. Unlike hdr-enabled below, every failure here is
// swallowed: an older libmpv rejects it as an unknown property, with no
// code to tell that apart, and a tone-mapping preference is never a reason
// to fail playback.
if (PlayerNative.usesLinuxVideoPlane) {
final toneMapping = settingsService.read(SettingsService.hdrToneMapping);
try {
await currentPlayer.setProperty('hdr-tone-mapping', toneMapping.name);
} catch (e) {
appLogger.d('VideoPlayerScreen: HDR tone-mapping mode not applied', error: e);
// A refused transaction leaves the plugin on the mode it last accepted,
// and nothing has moved it off the compositor default this session -
// the only writer is this push, plus the sheet, which persists solely
// on success. Storing that back keeps the sheet from offering "Player"
// as the current mode while the plane tone-maps in the compositor,
// a disagreement no later write would correct on its own.
// Contained on its own, for the same reason as the hdr-enabled block
// below: the refusal is deliberately tolerated, so a preference store
// that then throws must not turn "carry on with compositor tone
// mapping" into a failed player initialization.
if (toneMapping != HdrToneMapping.compositor) {
try {
await settingsService.write(SettingsService.hdrToneMapping, HdrToneMapping.compositor);
} catch (writeError) {
appLogger.w('VideoPlayerScreen: could not reconcile the stored tone-mapping mode', error: writeError);
}
}
}
}
// HDR is controlled via the custom hdr-enabled property. On Linux it means
// "allow passthrough": the native side only describes the plane as HDR
// when the compositor, the output and the source all agree, so pushing the
// preference here is safe even when it cannot be honoured.
//
// Linux swallows every refusal, because on Linux a refusal is a statement
// about the *plane*, not about the media: HDR_UNSUPPORTED means this
// session's plane can never carry HDR - an 8-bit EGL config, or a
// compositor without the colour-management pieces - and a failed colour
// transaction means mpv would not take the output properties. Neither is a
// reason not to play the video in SDR, so rethrowing would turn "this
// session cannot do HDR" into "this session cannot play video": the
// initialization error screen, with a Retry that fails the same way.
//
// Two earlier reasons given here no longer hold and are recorded as gone
// so they are not reinstated: the packages no longer link a distro libmpv
// (each ships the pinned build), and the plugin intercepts hdr-enabled
// whenever a video surface exists, so the old fall-through to mpv's
// target-colorspace-hint - and its mpv 0.40 version floor - is unreachable.
//
// The tolerance is Linux-only rather than "every platform, for this one
// error code". HDR_UNSUPPORTED is produced by the Linux plugin and nothing
// else, so tolerating it elsewhere would be an inert branch no test on any
// runner can reach, and a silent change to what the other platforms did
// before this feature existed.
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
final enableHDR = settingsService.read(SettingsService.enableHDR);
try {
await currentPlayer.setProperty('hdr-enabled', enableHDR ? 'yes' : 'no');
} catch (e) {
if (!PlayerNative.usesLinuxVideoPlane) rethrow;
appLogger.d('VideoPlayerScreen: HDR passthrough not applied', error: e);
// Same hazard as the tone-mapping block above. A refused transaction
// hands hdr_wanted back to whatever it held before this write, and
// nothing has moved it this session: the plugin is freshly created and
// zero-initialised, so it is off. Storing that back keeps the settings
// switch - which renders straight off this preference - from reading
// on while the plane is SDR, a disagreement no later write corrects
// because every internal re-apply reads the native side instead.
// Contained on its own. The refusal above is deliberately tolerated -
// this session simply plays SDR - so a preference store that then
// throws must not escalate that into the initialization error screen,
// which is where an escape from this catch lands. Worst case the
// preference stays out of step, which is the situation before this
// reconciliation existed.
if (enableHDR) {
try {
await settingsService.write(SettingsService.enableHDR, false);
} catch (writeError) {
appLogger.w('VideoPlayerScreen: could not reconcile the stored HDR preference', error: writeError);
}
}
}
}
final audioSyncOffset = settingsService.read(SettingsService.audioSyncOffset);
@@ -1446,7 +1560,24 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
final customMpvConfig = SettingsService.parseMpvConfigText(settingsService.read(SettingsService.mpvConfigText));
// Only the Linux video plane owns the four real mpv properties, so only
// there are they withheld. Elsewhere nothing caches them and a config line
// is the user's single way to reach them - dropping it would take away
// something that worked, and point at a control that platform does not
// show. The two intercepted names are not mpv properties anywhere, so
// those stay withheld everywhere.
final ownedHere = PlayerNative.usesLinuxVideoPlane ? _appOwnedMpvProperties : _appInterceptedMpvProperties;
for (final entry in customMpvConfig.entries) {
// Not silently dropped: the user typed this line, so say which one went
// unapplied and where to set it instead, at the same level as the other
// skipped or failed startup writes below.
if (ownedHere.contains(entry.key)) {
appLogger.w(
'Skipped custom MPV property ${entry.key}=${entry.value}: the app owns it, '
'set it in the player HDR settings instead',
);
continue;
}
try {
await currentPlayer.setProperty(entry.key, entry.value);
appLogger.d('Applied custom MPV property: ${entry.key}=${entry.value}');
@@ -1479,10 +1610,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return;
if (mounted) {
setState(() {
_isPlayerInitialized = true;
_bootstrapPlayer = null;
});
setState(() => _isPlayerInitialized = true);
// Restart sleep timer if we're starting a new playback session
SleepTimerService().restartIfNeeded(() => unawaited(_pauseWithPlaybackIntent(currentPlayer)));
@@ -1852,9 +1980,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final volumeController = _volumeController;
_volumeController = null;
volumeController?.dispose();
final playerToDispose = player ?? _bootstrapPlayer;
final playerToDispose = player;
player = null;
_bootstrapPlayer = null;
if (playerToDispose != null) {
// Keep the native display mode (tvOS HDMI criteria) across a
// player→player handoff; the replacement screen primes its own.
@@ -2261,7 +2388,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
? _buildVideoPlayer(sheetContext)
: (_playerInitializationError != null
? _buildInitializationError(_playerInitializationError!)
: _buildPlayerInitializationSurface()),
: _buildLoadingSpinner()),
),
),
);
+21
View File
@@ -59,6 +59,15 @@ enum SubAssOverride { no, yes, scale, force, strip }
/// [quarter] trade sharpness for raster throughput on render-bound low-end TVs.
enum SubtitleRenderResolution { screen, video, threeQuarter, half, third, quarter }
/// Who reduces HDR content to what the display can actually show, on the Linux
/// native video plane.
///
/// [compositor] hands the compositor the source's own metadata and lets its tone
/// curve do the work — simple, and what Kodi does. [player] tone-maps in mpv to
/// the display's real peak and declares that peak instead, which is mpv's own
/// default and leaves the compositor an identity transform.
enum HdrToneMapping { compositor, player }
extension SubtitleRenderScale on SubtitleRenderResolution {
/// Android libass overlay render scale (fraction of the surface resolution).
/// Only Android reads this; the iOS-only [video] basis maps to full scale here.
@@ -341,6 +350,17 @@ class SettingsService extends BaseSharedPreferencesService {
static const crashReporting = BoolPref('crash_reporting', defaultValue: true);
static const enableHardwareDecoding = BoolPref('enable_hardware_decoding', defaultValue: true);
static const enableHDR = BoolPref('enable_hdr', defaultValue: true);
// Linux native video plane only. Defaults to the compositor: photographed on a
// 400-nit HDR output against a PQ chart, the compositor keeps 400 -> 1000 nits
// monotonic and separated while the player leg flattens it. The player path
// drives mpv's legacy vo_gpu, whose own standalone output scores the same, so
// the gap is the renderer rather than the wiring. Compositor also needs no
// knowledge of the display.
static const hdrToneMapping = EnumPref<HdrToneMapping>(
'hdr_tone_mapping',
values: HdrToneMapping.values,
defaultValue: HdrToneMapping.compositor,
);
static const preferredVideoCodec = StringPref('preferred_video_codec', defaultValue: 'auto');
static const preferredAudioCodec = StringPref('preferred_audio_codec', defaultValue: 'auto');
static const viewMode = EnumPref<ViewMode>('view_mode', values: ViewMode.values, defaultValue: ViewMode.grid);
@@ -889,6 +909,7 @@ class SettingsService extends BaseSharedPreferencesService {
enableDebugLogging,
enableHardwareDecoding,
enableHDR,
hdrToneMapping,
preferredVideoCodec,
preferredAudioCodec,
viewMode,
@@ -14,6 +14,7 @@ import 'package:provider/provider.dart';
import '../../../models/shader_preset.dart';
import '../../../media/playback_rate.dart';
import '../../../mpv/mpv.dart';
import '../../../mpv/player/player_native.dart';
import '../../../providers/shader_provider.dart';
import '../../../services/file_picker_service.dart';
import '../../../services/settings_service.dart';
@@ -48,6 +49,7 @@ enum _SettingsView {
audioDevice,
shader,
dvConversion,
hdrToneMapping,
}
class _SettingsMenuItem extends StatelessWidget {
@@ -92,6 +94,76 @@ class _SettingsMenuItem extends StatelessWidget {
}
}
/// Ordering for the sheet's asynchronous pref writes, keyed on the pref key.
///
/// Shared by the toggle rows and the tone-mapping picker rather than owned by
/// either. A pick closes the sheet, so anything scoped to a widget cannot rank
/// a write against one started by a *later* sheet - which is exactly the race
/// here, since reopening and picking again is one tap. Keys are distinct per
/// pref and [LatestAsyncWrite] keeps a generation and tail per key, so the two
/// users never rank against each other.
final LatestAsyncWrite<String> _prefWrites = LatestAsyncWrite<String>();
/// Moves a setting on the device, records it, and puts both halves back when the
/// recording fails.
///
/// Device first is a decision, not an accident: a refusal is a validation. A
/// rejected `hdr-enabled` is how the plane reports that this session can never
/// carry HDR, so nothing may be recorded for a value the device would not take.
/// The price is a window in which the device leads the store, and closing that
/// window is what [_undoSettingWrite] is for - without it a rejected storage
/// write leaves the plane on the newly chosen policy while the switch and the
/// stored preference both still name the old one, for the rest of the session,
/// until the next player initialisation happens to replay the stored value.
///
/// Call this inside [LatestAsyncWrite.commitIfLatest] so a newer intent for the
/// same key cannot land between the failed write and the undo.
///
/// Rethrows whichever half failed, carrying its own stack: the caller owns what
/// the user sees, and for HDR that is a specific message about a surface no
/// retry can fix.
Future<void> _applyThenPersist<T>(Pref<T> pref, T value, FutureOr<void> Function(T value)? apply) async {
if (apply == null) return SettingsService.instance.write(pref, value);
// Read before the store is touched: SharedPreferencesWithCache moves its
// in-process copy ahead of the platform write it may then fail, so asking
// afterwards would answer with the value that did not persist.
final restore = SettingsService.instance.read(pref);
await apply(value);
try {
await SettingsService.instance.write(pref, value);
} catch (error, stackTrace) {
await _undoSettingWrite(pref, restore, apply);
Error.throwWithStackTrace(error, stackTrace);
}
}
/// Puts the store and the device back on [restore] after the store refused a
/// value the device had already taken.
///
/// Each half is attempted independently, the preference first: it is what the
/// next player initialisation replays into the device, so getting it right
/// salvages the session even when the device half then fails too. Failures are
/// logged and swallowed because the caller is already rethrowing the refusal
/// that started this, which is the one carrying a cause worth reporting.
Future<void> _undoSettingWrite<T>(Pref<T> pref, T restore, FutureOr<void> Function(T value) apply) async {
try {
// Rewriting the value already in the store looks redundant and is not: the
// refused write moved SharedPreferencesWithCache's in-process copy before
// the platform call it failed, and that copy is what SettingsService.read
// answers with for the rest of the session. It is moved back here before
// the platform call too, so it is repaired even if this write is refused
// as well.
await SettingsService.instance.write(pref, restore);
} catch (error, stackTrace) {
appLogger.w('Failed to restore the stored "${pref.key}"', error: error, stackTrace: stackTrace);
}
try {
await apply(restore);
} catch (error, stackTrace) {
appLogger.w('Failed to restore "${pref.key}" on the device', error: error, stackTrace: stackTrace);
}
}
class _SettingsToggleItem extends StatefulWidget {
final Pref<bool> pref;
final IconData icon;
@@ -105,8 +177,6 @@ class _SettingsToggleItem extends StatefulWidget {
}
class _SettingsToggleItemState extends State<_SettingsToggleItem> {
static final LatestAsyncWrite<String> _writes = LatestAsyncWrite<String>();
bool? _pendingValue;
int _writeGeneration = 0;
@@ -129,7 +199,7 @@ class _SettingsToggleItemState extends State<_SettingsToggleItem> {
final pref = widget.pref;
final callback = widget.onAfterWrite;
final generation = ++_writeGeneration;
final writeToken = _writes.begin(pref.key);
final writeToken = _prefWrites.begin(pref.key);
setState(() {
_pendingValue = next;
});
@@ -144,10 +214,11 @@ class _SettingsToggleItemState extends State<_SettingsToggleItem> {
int writeToken,
) async {
try {
final committed = await _writes.commitIfLatest(pref.key, writeToken, () async {
if (callback != null) await callback(next);
await SettingsService.instance.write(pref, next);
});
final committed = await _prefWrites.commitIfLatest(
pref.key,
writeToken,
() => _applyThenPersist(pref, next, callback),
);
if (!committed || !mounted || generation != _writeGeneration) return;
setState(() {
_pendingValue = null;
@@ -274,11 +345,32 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
late double _zoomScale;
String _dvConversionMode = 'auto';
int _dvConversionWriteGeneration = 0;
// Linux only, and answered by the native side. Starts false so the toggle
// never flashes into view on an output that cannot carry HDR.
bool _linuxHdrSupported = false;
// Re-probes that answer when the app is shown or resumed. Null wherever the
// capability is a constant and there is nothing to re-probe.
AppLifecycleListener? _hdrSupportLifecycle;
// The lifecycle hooks miss the case that matters most here: dragging the
// window to another monitor changes the answer without the app ever being
// hidden. Only the plane sees that, so it says so.
StreamSubscription<void>? _hdrOutputChanged;
late HdrToneMapping _hdrToneMapping;
TrackControlsState get _state => widget.trackControlsState;
// An explicit value from the caller wins. Otherwise the capability is static
// per platform, except on the Linux video plane where it depends on the
// compositor, the output and the plane's bit depth, so it has to be asked for.
bool get _supportsHdrControl =>
widget.supportsHdrControl ?? (Platform.isIOS || Platform.isMacOS || Platform.isWindows);
widget.supportsHdrControl ??
(_probesHdrSupport ? _linuxHdrSupported : Platform.isIOS || Platform.isMacOS || Platform.isWindows);
// The Linux plane is the only path whose answer moves, and an explicit value
// from the caller replaces the question altogether. Asked through
// PlayerNative.usesLinuxVideoPlane, not Platform.isLinux, so the probe and the
// tone-mapping row it gates resolve the same way under the test override.
bool get _probesHdrSupport => PlayerNative.usesLinuxVideoPlane && widget.supportsHdrControl == null;
bool get _showDebugDvConversionMode {
if (!kDebugMode) return false;
@@ -292,7 +384,20 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_audioSyncOffset = _state.audioSyncOffset;
_subtitleSyncOffset = _state.subtitleSyncOffset;
_zoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale);
_hdrToneMapping = SettingsService.instance.read(SettingsService.hdrToneMapping);
_loadDebugDvConversionMode();
if (_probesHdrSupport) {
_hdrSupportLifecycle = AppLifecycleListener(onResume: _refreshLinuxHdrSupport, onShow: _refreshLinuxHdrSupport);
_hdrOutputChanged = widget.player.streams.hdrOutputChanged.listen((_) => _refreshLinuxHdrSupport());
}
_refreshLinuxHdrSupport();
}
@override
void dispose() {
_hdrSupportLifecycle?.dispose();
_hdrOutputChanged?.cancel();
super.dispose();
}
@override
@@ -313,6 +418,79 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
});
}
// Asked again rather than cached for the sheet's lifetime. The native answer
// is "this client can describe HDR *and* the output is in HDR right now", and
// the second half moves when the window changes monitor or the display's HDR
// mode is switched under us. The plane handles that internally and sends
// nothing to Dart, so this rides the hooks that do exist: the app being shown
// or resumed, and the menu coming back into view.
Future<void> _refreshLinuxHdrSupport() async {
if (!_probesHdrSupport) return;
final player = widget.player;
final supported = await player.isHdrOutputSupported();
if (!mounted || player != widget.player || supported == _linuxHdrSupported) return;
setState(() {
_linuxHdrSupported = supported;
});
}
// What the native side answers when the plane can never carry HDR - an 8-bit
// EGL config, or a compositor without the colour-management protocol. Fixed
// for the session, unlike a merely-SDR output, which is now accepted and
// honoured once an HDR output is reached.
static const String _hdrUnsupportedCode = 'HDR_UNSUPPORTED';
// Rethrown so the switch still springs back. The message is what keeps that
// from reading as a lost tap: retrying cannot help for the rest of the session.
Future<void> _setHdrEnabled(bool enabled) async {
try {
await widget.player.setProperty('hdr-enabled', enabled ? 'yes' : 'no');
} on PlatformException catch (error) {
if (mounted && error.code == _hdrUnsupportedCode) showErrorSnackBar(context, t.videoSettings.hdrUnsupported);
rethrow;
}
}
void _setHdrToneMapping(HdrToneMapping mode) {
final targetPlayer = widget.player;
// The tiles stay tappable until close() runs, so two picks can be in flight.
// The native side happens to queue HDR transactions in order, but that is not
// an invariant this file can see: last write wins locally instead.
//
// Deliberately not gated on `mounted`: dismissing the sheet mid-write would
// otherwise leave mpv in the new mode while the stored setting still named
// the old one, and the next playback start would push the old one back.
// A superseded pick can still persist transiently - the staleness check is
// before the body, not inside it - but the winner is serialized behind it
// and overwrites it, so the settled value is the last pick.
final key = SettingsService.hdrToneMapping.key;
final writeToken = _prefWrites.begin(key);
unawaited(() async {
try {
final committed = await _prefWrites.commitIfLatest(
key,
writeToken,
() => _applyThenPersist(
SettingsService.hdrToneMapping,
mode,
(value) => targetPlayer.setProperty('hdr-tone-mapping', value.name),
),
);
if (!committed || !mounted || targetPlayer != widget.player) return;
setState(() {
_hdrToneMapping = mode;
});
OverlaySheetController.of(context).close();
} catch (error, stackTrace) {
appLogger.w('Failed to set the HDR tone-mapping mode', error: error, stackTrace: stackTrace);
// The tick stays on the old mode by design (the stored setting was
// left alone), but a dead tap needs saying so - the HDR toggle's
// refusal shows a snackbar, and this is the same shape of refusal.
if (mounted) showErrorSnackBar(context, t.videoSettings.hdrToneMappingFailed);
}
}());
}
void _setDebugDvConversionMode(String mode) {
final targetPlayer = widget.player;
final generation = ++_dvConversionWriteGeneration;
@@ -397,6 +575,9 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
setState(() {
_currentView = _SettingsView.menu;
});
// The HDR rows live on the menu only, so this is the moment a stale answer
// becomes visible again.
_refreshLinuxHdrSupport();
OverlaySheetController.maybeOf(context)?.refocus();
}
@@ -422,6 +603,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return t.shaders.title;
case _SettingsView.dvConversion:
return 'DV Conversion Mode';
case _SettingsView.hdrToneMapping:
return t.videoSettings.hdrToneMapping;
}
}
@@ -447,6 +630,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return Symbols.auto_fix_high_rounded;
case _SettingsView.dvConversion:
return Symbols.hdr_strong_rounded;
case _SettingsView.hdrToneMapping:
return Symbols.tonality_rounded;
}
}
@@ -468,6 +653,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
};
}
String _formatHdrToneMapping(HdrToneMapping mode) => switch (mode) {
HdrToneMapping.compositor => t.videoSettings.hdrToneMappingCompositor,
HdrToneMapping.player => t.videoSettings.hdrToneMappingPlayer,
};
String _formatSleepTimer(SleepTimerService sleepTimer) {
if (!sleepTimer.isActive) return t.common.off;
final remaining = sleepTimer.remainingTime;
@@ -604,7 +794,18 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
pref: SettingsService.enableHDR,
icon: Symbols.hdr_strong_rounded,
title: t.videoSettings.hdr,
onAfterWrite: (value) => widget.player.setProperty('hdr-enabled', value ? 'yes' : 'no'),
onAfterWrite: _setHdrEnabled,
),
// Only meaningful where the plane can actually carry HDR, and only the
// Linux plane lets us pick the curve: elsewhere the platform decides who
// tone-maps.
if (_supportsHdrControl && PlayerNative.usesLinuxVideoPlane)
_SettingsMenuItem(
icon: Symbols.tonality_rounded,
title: t.videoSettings.hdrToneMapping,
valueText: _formatHdrToneMapping(_hdrToneMapping),
onTap: () => _navigateTo(_SettingsView.hdrToneMapping),
),
// Auto-Play Next Episode Toggle
@@ -770,6 +971,29 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
);
}
Widget _buildHdrToneMappingView() {
final modes = [
(value: HdrToneMapping.compositor, subtitle: t.videoSettings.hdrToneMappingCompositorDescription),
(value: HdrToneMapping.player, subtitle: t.videoSettings.hdrToneMappingPlayerDescription),
];
final primary = Theme.of(context).colorScheme.primary;
return ListView(
children: [
for (final mode in modes)
FocusableListTile(
title: Text(
_formatHdrToneMapping(mode.value),
style: TextStyle(color: _hdrToneMapping == mode.value ? primary : null),
),
subtitle: Text(mode.subtitle, style: TextStyle(color: tokens(context).textMuted, fontSize: 12)),
trailing: _hdrToneMapping == mode.value ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null,
onTap: () => _setHdrToneMapping(mode.value),
),
],
);
}
Widget _buildSpeedView() {
return StreamBuilder<double>(
stream: widget.player.streams.rate,
@@ -1168,6 +1392,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return _buildShaderView();
case _SettingsView.dvConversion:
return _buildDvConversionView();
case _SettingsView.hdrToneMapping:
return _buildHdrToneMappingView();
}
}(),
);
+8 -4
View File
@@ -162,7 +162,7 @@ main() {
--enable-filter=aformat,aresample,format,null,scale \
--enable-gnutls \
--enable-vaapi \
--enable-vdpau \
--disable-vdpau \
--disable-debug \
--disable-stripping
@@ -226,6 +226,10 @@ main() {
tar -xzf "$srcdir/mpv.tar.gz"
cd "mpv-${MPV_VERSION}"
# The runner's only video path is a Wayland subsurface, and it hands mpv
# MPV_RENDER_PARAM_WL_DISPLAY so VAAPI can find the device instead of falling
# back to software decoding. A libmpv built without Wayland cannot use that.
# VDPAU goes with X11 - it has no Wayland backend at all.
meson setup build \
--prefix="$prefix" \
-Dlibmpv=true \
@@ -240,12 +244,12 @@ main() {
-Dd3d11=disabled \
-Dgl=enabled \
-Dvaapi=enabled \
-Dvdpau=enabled \
-Dalsa=enabled \
-Dpulse=enabled \
-Dpipewire=enabled \
-Dwayland=disabled \
-Dx11=enabled
-Dvdpau=disabled \
-Dwayland=enabled \
-Dx11=disabled
ninja -C build -j"$jobs"
ninja -C build install
+13
View File
@@ -38,6 +38,9 @@ DISTROS = {
"category": "video",
"ext": "deb",
"compression": ["--deb-compression", "xz", "--deb-priority", "optional"],
# The native video plane links wayland-client, wayland-egl and EGL, and
# bundle-libs.sh deliberately never bundles those: they are coupled to
# the running compositor and GPU driver. So they have to be declared.
"depends": [
"libgtk-3-0",
"libmpv2 | libmpv1",
@@ -45,6 +48,9 @@ DISTROS = {
"libasound2",
"libevdev2",
"libglib2.0-0",
"libwayland-client0",
"libwayland-egl1",
"libegl1",
],
},
"rpm": {
@@ -59,6 +65,9 @@ DISTROS = {
"alsa-lib",
"libevdev",
"glib2",
"libwayland-client",
"libwayland-egl",
"libglvnd-egl",
],
},
"pacman": {
@@ -66,6 +75,8 @@ DISTROS = {
"category": None,
"ext": "pkg.tar.zst",
"compression": ["--pacman-compression", "zstd"],
# Arch ships every libwayland-* in the one `wayland` package, and
# libglvnd is what provides libEGL.so.1.
"depends": [
"gtk3",
"mpv",
@@ -73,6 +84,8 @@ DISTROS = {
"alsa-lib",
"libevdev",
"glib2",
"wayland",
"libglvnd",
],
},
}
+98 -16
View File
@@ -10,9 +10,8 @@ add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"mpv/mpv_player.cc"
"mpv/mpv_gpu_bootstrap.cc"
"mpv/mpv_plugin.cc"
"mpv/mpv_texture.cc"
"mpv/wayland_video_surface.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
@@ -20,6 +19,12 @@ add_executable(${BINARY_NAME}
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# apply_standard_settings asks for cxx_std_14, which current compilers satisfy
# with a gnu++17 default and so add no -std flag for. mpv_plugin.cc uses
# std::optional, so say 17 rather than inherit it from whatever the host
# compiler happens to default to.
target_compile_features(${BINARY_NAME} PRIVATE cxx_std_17)
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
@@ -29,6 +34,37 @@ pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv)
# Find epoxy (OpenGL loader).
pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy)
# Wayland client + EGL back the native video plane (a wl_subsurface below the
# Flutter surface). GTK's Wayland backend already links these, but the runner
# calls them directly, so depend on them explicitly.
pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl)
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)
# Vendored wayland-scanner output for color-management-v1 (staging), which backs
# HDR on the native video plane. Built separately because it is C (the runner is
# C++ only) and generated, so it must not be held to -Wall -Werror.
#
# Committed rather than generated at build time: the protocol only appeared in
# wayland-protocols 1.41, newer than the version the distributions this app is
# built for ship, so vendoring keeps the build working regardless of the host and
# adds no build dependency on wayland-scanner. The checked-in files came from
# wayland-protocols 1.49 via wayland-scanner 1.25.0; the interface is at version
# 3 and the client binds min(advertised, 3).
#
# To refresh, from a host with a new enough wayland-protocols - and do not
# hand-edit the results:
#
# xml=/usr/share/wayland-protocols/staging/color-management/color-management-v1.xml
# cp "$xml" linux/runner/wayland/
# wayland-scanner client-header "$xml" linux/runner/wayland/color-management-v1-client-protocol.h
# wayland-scanner private-code "$xml" linux/runner/wayland/color-management-v1-protocol.c
enable_language(C)
add_library(wayland_protocols STATIC "wayland/color-management-v1-protocol.c")
target_include_directories(wayland_protocols PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/wayland")
target_link_libraries(wayland_protocols PUBLIC PkgConfig::WAYLAND_CLIENT)
target_compile_options(wayland_protocols PRIVATE -w)
# Build simdutf as a static library from the single-header amalgamation.
add_library(simdutf STATIC "${simdutf_SOURCE_DIR}/simdutf.cpp")
target_include_directories(simdutf PUBLIC "${simdutf_SOURCE_DIR}")
@@ -41,6 +77,10 @@ target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::MPV)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EPOXY)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_EGL)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EGL)
target_link_libraries(${BINARY_NAME} PRIVATE wayland_protocols)
target_link_libraries(${BINARY_NAME} PRIVATE simdutf)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
@@ -58,14 +98,12 @@ function(check_mpv_sanitizer_support SANITIZER_FLAG RESULT_VARIABLE)
endfunction()
option(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS
"Build the focused Linux mpv callback lifecycle test" OFF)
"Build the focused Linux mpv callback lifecycle and output colour space tests" OFF)
if(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS)
find_package(Threads REQUIRED)
add_executable(mpv_player_lifecycle_test
"mpv/mpv_player.cc"
"mpv/mpv_gpu_bootstrap.cc"
"mpv/mpv_texture.cc"
"mpv/mpv_player_lifecycle_test.cc"
)
apply_standard_settings(mpv_player_lifecycle_test)
@@ -79,25 +117,50 @@ if(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS)
target_include_directories(mpv_player_lifecycle_test PRIVATE "${CMAKE_SOURCE_DIR}")
target_include_directories(mpv_player_lifecycle_test PRIVATE "${CMAKE_SOURCE_DIR}/../shared/cpp")
# The output-colour-space transaction reuses PLEZY_MPV_PLAYER_LIFECYCLE_TEST
# rather than defining its own: that define already means "mpv_player.cc is
# being built for a focused test and exposes its injection seams", and the
# transaction's seam is one more of those, in the same translation unit. A
# second spelling would be two names for one condition and would leave each
# test binary compiling the other's seam out for no reason.
add_executable(mpv_player_hdr_output_test
"mpv/mpv_player.cc"
"mpv/mpv_player_hdr_output_test.cc"
)
apply_standard_settings(mpv_player_hdr_output_test)
target_compile_definitions(mpv_player_hdr_output_test PRIVATE PLEZY_MPV_PLAYER_LIFECYCLE_TEST=1)
target_link_libraries(mpv_player_hdr_output_test PRIVATE flutter)
target_link_libraries(mpv_player_hdr_output_test PRIVATE PkgConfig::GTK)
target_link_libraries(mpv_player_hdr_output_test PRIVATE PkgConfig::MPV)
target_link_libraries(mpv_player_hdr_output_test PRIVATE PkgConfig::EPOXY)
target_link_libraries(mpv_player_hdr_output_test PRIVATE simdutf)
target_link_libraries(mpv_player_hdr_output_test PRIVATE Threads::Threads)
target_include_directories(mpv_player_hdr_output_test PRIVATE "${CMAKE_SOURCE_DIR}")
target_include_directories(mpv_player_hdr_output_test PRIVATE "${CMAKE_SOURCE_DIR}/../shared/cpp")
option(PLEZY_MPV_LIFECYCLE_SANITIZERS
"Enable ASan and UBSan for the focused mpv lifecycle test" ON)
"Enable ASan and UBSan for the focused mpv_player.cc tests" ON)
if(PLEZY_MPV_LIFECYCLE_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
check_mpv_sanitizer_support(
"-fsanitize=address,undefined" MPV_LIFECYCLE_SANITIZERS_SUPPORTED)
if(MPV_LIFECYCLE_SANITIZERS_SUPPORTED)
target_compile_options(mpv_player_lifecycle_test PRIVATE -fno-omit-frame-pointer -fsanitize=address,undefined)
target_link_options(mpv_player_lifecycle_test PRIVATE -fsanitize=address,undefined)
target_compile_options(mpv_player_hdr_output_test PRIVATE -fno-omit-frame-pointer -fsanitize=address,undefined)
target_link_options(mpv_player_hdr_output_test PRIVATE -fsanitize=address,undefined)
endif()
endif()
add_test(NAME mpv_player_lifecycle_test COMMAND mpv_player_lifecycle_test)
set_tests_properties(mpv_player_lifecycle_test PROPERTIES TIMEOUT 30)
add_test(NAME mpv_player_hdr_output_test COMMAND mpv_player_hdr_output_test)
set_tests_properties(mpv_player_hdr_output_test PROPERTIES TIMEOUT 30)
endif()
option(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS
"Build the focused desktop mpv property-result contract test" OFF)
option(PLEZY_BUILD_MPV_RELIABILITY_TESTS
"Build focused Linux mpv registry and GPU bootstrap tests" OFF)
"Build focused Linux HDR metadata, plane geometry and video params tests" OFF)
set(PLEZY_MPV_RELIABILITY_SANITIZER "none" CACHE STRING
"Sanitizer for focused mpv reliability tests: none, address, or thread")
set_property(CACHE PLEZY_MPV_RELIABILITY_SANITIZER PROPERTY STRINGS none address thread)
@@ -140,14 +203,33 @@ if(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS OR PLEZY_BUILD_MPV_RELIABILITY_TESTS)
endif()
if(PLEZY_BUILD_MPV_RELIABILITY_TESTS)
add_executable(mpv_gpu_bootstrap_test
"mpv/mpv_gpu_bootstrap.cc"
"mpv/mpv_gpu_bootstrap_test.cc"
add_executable(hdr_metadata_test
"mpv/hdr_metadata_test.cc"
)
apply_standard_settings(mpv_gpu_bootstrap_test)
target_compile_features(mpv_gpu_bootstrap_test PRIVATE cxx_std_14)
target_link_libraries(mpv_gpu_bootstrap_test PRIVATE PkgConfig::EPOXY)
target_include_directories(mpv_gpu_bootstrap_test PRIVATE "mpv")
apply_mpv_reliability_sanitizer(mpv_gpu_bootstrap_test)
add_test(NAME mpv_gpu_bootstrap_test COMMAND mpv_gpu_bootstrap_test)
apply_standard_settings(hdr_metadata_test)
target_compile_features(hdr_metadata_test PRIVATE cxx_std_14)
target_include_directories(hdr_metadata_test PRIVATE "mpv")
apply_mpv_reliability_sanitizer(hdr_metadata_test)
add_test(NAME hdr_metadata_test COMMAND hdr_metadata_test)
add_executable(plane_geometry_test
"mpv/plane_geometry_test.cc"
)
apply_standard_settings(plane_geometry_test)
target_compile_features(plane_geometry_test PRIVATE cxx_std_14)
target_include_directories(plane_geometry_test PRIVATE "mpv")
apply_mpv_reliability_sanitizer(plane_geometry_test)
add_test(NAME plane_geometry_test COMMAND plane_geometry_test)
# Unlike the other two pure headers, this one parses libmpv's own node type,
# so it needs mpv's headers - and nothing else: the parse links no symbol.
add_executable(video_params_test
"mpv/video_params_test.cc"
)
apply_standard_settings(video_params_test)
target_compile_features(video_params_test PRIVATE cxx_std_14)
target_include_directories(video_params_test PRIVATE "mpv")
target_link_libraries(video_params_test PRIVATE PkgConfig::MPV)
apply_mpv_reliability_sanitizer(video_params_test)
add_test(NAME video_params_test COMMAND video_params_test)
endif()
+419
View File
@@ -0,0 +1,419 @@
#ifndef PLEZY_LINUX_MPV_HDR_METADATA_H_
#define PLEZY_LINUX_MPV_HDR_METADATA_H_
#include <cstdint>
// Source HDR10 static metadata, and the rules for turning it into a set of
// colour-management-v1 luminance requests the compositor will accept.
//
// This header is deliberately free of Wayland and GTK: the interesting logic is
// the validation, the penalty for getting it wrong is severe, and neither
// deserves a display server to test. Header-only is deliberate as well: pure
// functions over plain structs, no dependencies, every one of them inline, and
// five translation units include it.
namespace mpv {
// The source's transfer function, so far as describing the plane cares. Every
// SDR curve collapses to kSdr: the plane is then left undescribed and mpv's
// normal output is already right, so there is nothing to distinguish.
enum class SourceTransfer { kSdr, kPq, kHlg };
// The source's container primaries. Only BT.2020 has a named counterpart worth
// describing for video; everything else is treated as "not a wide gamut" and
// leaves the plane undescribed.
enum class SourcePrimaries { kOther, kBt2020 };
// What the current source actually is, plus its HDR10 static metadata, as
// reported by mpv's video-params. A zero luminance field means the source did
// not carry it.
//
// The colorimetry fields matter as much as the luminances: describing a plane as
// PQ / BT.2020 because a *setting* is on, rather than because the stream is,
// tells the compositor to undo a transform that was never applied.
struct HdrMetadata {
SourceTransfer transfer = SourceTransfer::kSdr;
SourcePrimaries primaries = SourcePrimaries::kOther;
uint32_t max_cll = 0; // nits, maximum content light level
uint32_t max_fall = 0; // nits, maximum frame-average light level
uint32_t max_luminance = 0; // nits, mastering display maximum
double min_luminance = 0.0; // nits, mastering display minimum
};
// Whether two snapshots describe the same source. Both the plane's
// no-op-transition check and the plugin's log-on-change need this, and they must
// agree on what "the same" means or one will act on a change the other ignored.
inline bool operator==(const HdrMetadata& a, const HdrMetadata& b) {
return a.transfer == b.transfer && a.primaries == b.primaries && a.max_cll == b.max_cll && a.max_fall == b.max_fall &&
a.max_luminance == b.max_luminance && a.min_luminance == b.min_luminance;
}
inline bool operator!=(const HdrMetadata& a, const HdrMetadata& b) { return !(a == b); }
// True when the source carries an HDR transfer function, i.e. when there is
// anything to pass through at all.
inline bool SourceIsHdr(const HdrMetadata& metadata) { return metadata.transfer != SourceTransfer::kSdr; }
// Who reduces the source's dynamic range to what the display can show.
//
// kCompositor is passthrough: the source's own metadata is declared and the
// compositor's tone curve does the work. Simplest, adapts to monitor changes
// with no re-render, and is what Kodi does — but its quality is entirely the
// compositor's, and a source that declares no metadata is assumed to reach the
// curve's maximum, which makes the roll-off far harsher than the content needs.
//
// kPlayer tone-maps in mpv to the display's real peak (learned from the
// compositor's preferred description) and then declares *that* peak, leaving the
// compositor an identity transform. This is mpv's own default behaviour and what
// the compositor developers recommend.
enum class HdrToneMapping { kCompositor, kPlayer };
// The primary colour volume maxima the protocol attaches to each named transfer
// function. These are not interchangeable: PQ's EOTF swings to 10000 cd/m²,
// while HLG is a *relative* signal whose absolute luminances are all defined
// against a 1000 cd/m² peak display. Getting this wrong is not cosmetic — an
// HLG stream declaring a 4000-nit MaxCLL with no mastering range passes a
// PQ-shaped check and then trips a fatal invalid_luminance at create().
constexpr uint32_t kPqMaxLuminanceNits = 10000;
constexpr uint32_t kHlgMaxLuminanceNits = 1000;
// The protocol carries the mastering minimum scaled by this to keep four
// decimals of a value that is normally a small fraction of a nit.
constexpr uint32_t kMinLuminanceScale = 10000;
// Both PQ and HLG declare the same primary colour volume *floor*, 0.005 cd/m²,
// already in the protocol's scaled units. Containment is two-sided: a mastering
// range reaching below this leaves the primary colour volume just as surely as
// one reaching above its maximum, and needs the same extended_target_volume
// feature. Sources routinely declare 0.0001 or nothing at all, so this is the
// common case rather than the exotic one.
constexpr uint32_t kPrimaryVolumeMinScaled = 50;
// The implied primary colour volume maximum for a transfer function. This is
// also the range light levels are bounded by when no mastering luminance is
// sent, because the protocol says an unset mastering range takes the primary
// colour volume's own range.
inline uint32_t PrimaryVolumeMaxNits(SourceTransfer transfer) {
switch (transfer) {
case SourceTransfer::kHlg:
return kHlgMaxLuminanceNits;
case SourceTransfer::kPq:
case SourceTransfer::kSdr:
break;
}
return kPqMaxLuminanceNits;
}
// What the compositor told us it can accept, which decides how much of the
// source's metadata may legally be forwarded.
struct CompositorLuminanceSupport {
// feature.set_mastering_display_primaries. Without it, set_mastering_luminance
// raises unsupported_feature.
bool mastering = false;
// feature.extended_target_volume. Without it, the mastering advertisement
// only promises target volumes *fully contained* within the primary colour
// volume; exceeding it is implementation-defined and may fail the description.
bool extended_target_volume = false;
// Bound wp_color_manager_v1 version. What the versions differ about is spelled
// out at the branch that acts on it, in PlanHdrLuminance.
uint32_t interface_version = 1;
};
// Which luminance requests to actually emit. A false flag means the field is
// left unset so the compositor applies its own default, which is always safer
// than a value the protocol would reject.
struct HdrLuminancePlan {
bool send_mastering = false;
uint32_t mastering_min_scaled = 0;
uint32_t mastering_max = 0;
bool send_max_cll = false;
uint32_t max_cll = 0;
bool send_max_fall = false;
uint32_t max_fall = 0;
};
// Converts a mastering minimum in nits to the protocol's scaled units.
inline uint32_t ScaleMinLuminance(double nits) {
if (!(nits > 0.0)) return 0;
const double scaled = nits * static_cast<double>(kMinLuminanceScale) + 0.5;
if (scaled >= static_cast<double>(UINT32_MAX)) return UINT32_MAX;
return static_cast<uint32_t>(scaled);
}
// True when `value_nits` sits inside the mastering range, which version 1
// spells as strictly greater than min L and less than or equal to max L. The
// comparison against the minimum happens in scaled units and in 64 bits, since
// a corrupt max-luma would otherwise overflow the multiply.
inline bool LuminanceInMasteringRange(uint32_t value_nits, uint32_t min_lum_scaled, uint32_t max_lum_nits) {
if (value_nits > max_lum_nits) return false;
return static_cast<uint64_t>(value_nits) * kMinLuminanceScale > min_lum_scaled;
}
// Decides which of set_mastering_luminance / set_max_cll / set_max_fall may be
// sent for `metadata`, given what the compositor advertised.
//
// Every constraint enforced here is a *protocol error* on create(), not a
// failed image description: the compositor disconnects the client, taking the
// whole app down rather than just HDR. Badly authored HDR content does violate
// these — a MaxCLL above the mastering display's own peak is common, and MaxFALL
// above MaxCLL happens — so the stream is never trusted.
inline HdrLuminancePlan PlanHdrLuminance(const HdrMetadata& metadata, const CompositorLuminanceSupport& support) {
HdrLuminancePlan plan;
// The ceiling everything is judged against.
const uint32_t volume_max = PrimaryVolumeMaxNits(metadata.transfer);
// Mastering luminance carries two error cases: unsupported_feature unless the
// compositor advertised set_mastering_display_primaries, and invalid_luminance
// unless max L is strictly greater than min L.
//
// Beyond those, the mastering advertisement only promises target volumes
// *fully contained* within the primary colour volume, and containment is
// two-sided. Both ends are therefore clamped into it unless
// extended_target_volume was advertised:
//
// - The maximum down to the curve's own ceiling. For HLG that is also
// semantically right, since its absolute luminances are defined against a
// 1000-nit display and a larger figure is outside the model. The clamp
// doubles as overflow protection for the scaled comparison below.
// - The minimum up to the 0.005-nit floor. Sources overwhelmingly declare
// 0.0001 or nothing at all, both of which sit below it.
//
// Clamping rather than dropping matters: the mastering maximum is the
// compositor's fallback peak when the source carries no MaxCLL, and dropping
// it there would leave the compositor assuming the curve's full range —
// exactly the over-compression this whole exercise is about avoiding.
const uint32_t mastering_ceiling = support.extended_target_volume ? kPqMaxLuminanceNits : volume_max;
const uint32_t mastering_floor_scaled = support.extended_target_volume ? 0 : kPrimaryVolumeMinScaled;
uint32_t mastering_max = metadata.max_luminance;
if (mastering_max > mastering_ceiling) mastering_max = mastering_ceiling;
uint32_t mastering_min_scaled = ScaleMinLuminance(metadata.min_luminance);
if (mastering_min_scaled < mastering_floor_scaled) mastering_min_scaled = mastering_floor_scaled;
if (support.mastering && mastering_max > 0 &&
static_cast<uint64_t>(mastering_max) * kMinLuminanceScale > mastering_min_scaled) {
plan.send_mastering = true;
plan.mastering_min_scaled = mastering_min_scaled;
plan.mastering_max = mastering_max;
}
plan.send_max_cll = metadata.max_cll > 0;
plan.max_cll = metadata.max_cll;
plan.send_max_fall = metadata.max_fall > 0;
plan.max_fall = metadata.max_fall;
// The range both light levels must sit inside. With no mastering request the
// primary colour volume applies, which is why volume_max is used and not PQ's
// ceiling: an HLG stream is bounded at 1000 either way.
const uint32_t range_max = plan.send_mastering ? plan.mastering_max : volume_max;
const uint32_t range_min_scaled = plan.send_mastering ? plan.mastering_min_scaled : 0;
// The curve has no code point above its own volume maximum, which is true of
// both interface versions: with extended_target_volume the mastering range may
// legally reach 10000 even for HLG, so range_max alone would let a v1
// compositor accept an HLG light level of 2000 that a v2 one refuses. Drop the
// offending light level rather than the mastering range: mastering metadata is
// the more trustworthy of the two, and dropping max_cll leaves the compositor
// falling back to the mastering maximum, which is the better answer anyway.
if (plan.send_max_cll && plan.max_cll > volume_max) plan.send_max_cll = false;
if (plan.send_max_fall && plan.max_fall > volume_max) plan.send_max_fall = false;
// Version 1 additionally requires both to sit inside the mastering range;
// version 2 dropped that.
if (support.interface_version < 2) {
if (plan.send_max_cll && !LuminanceInMasteringRange(plan.max_cll, range_min_scaled, range_max)) {
plan.send_max_cll = false;
}
if (plan.send_max_fall && !LuminanceInMasteringRange(plan.max_fall, range_min_scaled, range_max)) {
plan.send_max_fall = false;
}
}
// Every version requires max_fall <= max_cll, but only while *both* are set,
// so this has to be judged after the drops above. max_fall is the one to go:
// it is the less trustworthy field and no compositor tone curve consults it.
if (plan.send_max_cll && plan.send_max_fall && plan.max_fall > plan.max_cll) {
plan.send_max_fall = false;
}
return plan;
}
// Rewrites the metadata to describe a signal *we* tone-mapped to `peak_nits`,
// rather than the source's original range.
//
// This is the whole point of player-side tone mapping: once mpv has mapped the
// content down to the display's peak, telling the compositor the source's
// original 4000- or 10000-nit range would have it compress a signal that no
// longer contains those levels. The curve and gamut are unchanged — the pixels
// are still PQ or HLG over BT.2020 — but every luminance now describes what we
// produced. The mastering floor is kept: it did not move.
inline HdrMetadata DescribeTonemappedTo(const HdrMetadata& source, uint32_t peak_nits) {
HdrMetadata described = source;
if (peak_nits == 0) return described;
const uint32_t volume_max = PrimaryVolumeMaxNits(source.transfer);
if (peak_nits > volume_max) peak_nits = volume_max;
described.max_luminance = peak_nits;
described.max_cll = peak_nits;
// MaxFALL must stay at or below MaxCLL, and a frame average equal to the peak
// would be a claim about the content we have not measured. The source's own
// figure is kept when it still fits, since it remains the better estimate.
described.max_fall = (source.max_fall > 0 && source.max_fall <= peak_nits) ? source.max_fall : 0;
return described;
}
// Whether an output's reported luminances leave enough room above its own
// diffuse white to be worth passing HDR through instead of tone-mapping here.
//
// This is deliberately a headroom question rather than "is the HDR toggle on",
// because no colour-management-v1 signal answers the latter. The transfer
// function used to: KWin 6.4 preferred PQ for an HDR output. KWin 6.7 does not
// — a window's preferred description became the compositor's *blending* space,
// which is gamma 2.2 with an extended range whether or not HDR is on, and the
// output-scoped description followed it. Reading the curve there now reports
// SDR on every HDR output on current Plasma.
//
// Headroom survives that change because it describes the panel rather than the
// encoding. It is also the question that actually bears on the decision: if
// nothing can be shown above reference white, a PQ plane only invites the
// compositor to squash it back down, and mpv's own curve does that better.
//
// The margin is what keeps this honest. A bare `max > reference` is true for an
// SDR output too, because KWin dims SDR white in software and reports the
// undimmed maximum: at 80% brightness that is 200 over 161. Headroom that small
// is not worth switching pipelines for, so require half a stop. Every HDR
// output clears it comfortably — a 400-nit panel reports 400 over 203 — and
// dimming down to about 70% does not.
//
// Below roughly 60% the margin is met by an SDR output, and that is the right
// answer rather than a leak: KWin has genuinely dimmed white to 122 nits while
// the panel still reaches 200, so highlights really can go above white, and
// tone-mapping to 122 would throw that away. What the margin rejects is the
// case where the headroom is too slight to be worth the compositor squashing a
// 1000-nit source into it.
//
// Stated as 2*max >= 3*reference rather than max >= reference * 1.5, because
// these arrive unvalidated from the compositor: integer division would put the
// boundary half a nit low, and the addition form overflows on a reference white
// near the type's maximum, which would read as *no* headroom.
inline bool OutputHasHdrHeadroom(uint32_t max_luminance, uint32_t reference_luminance) {
if (reference_luminance == 0) return false;
return static_cast<uint64_t>(max_luminance) * 2 >= static_cast<uint64_t>(reference_luminance) * 3;
}
// What the compositor advertised it will accept, as named curves and primaries.
struct CompositorColorSupport {
bool bt2020 = false;
bool pq = false;
bool hlg = false;
};
// Whether this source can be described to the compositor at all.
//
// Getting this wrong is not a degraded picture: naming a curve the compositor
// never advertised is a fatal invalid_tf on create(), which disconnects the
// whole client rather than failing the description. So the rule lives here,
// beside the gate it feeds and away from the Wayland types, where it can be
// tested without a compositor.
inline bool SourceIsDescribable(const HdrMetadata& metadata, const CompositorColorSupport& support) {
if (!SourceIsHdr(metadata)) return false;
// A wide-gamut container is part of what makes this worth doing, and the named
// primaries have to be ones the compositor accepts.
if (metadata.primaries != SourcePrimaries::kBt2020 || !support.bt2020) return false;
switch (metadata.transfer) {
case SourceTransfer::kPq:
return support.pq;
case SourceTransfer::kHlg:
return support.hlg;
case SourceTransfer::kSdr:
break;
}
return false;
}
// Everything outside the source that bears on whether the plane carries HDR.
struct HdrInputs {
bool allowed = false; // the app's permission (the hdr-enabled setting)
bool client_can_describe = false; // 10-bit plane, colour-managed surface, advertised curve
bool output_is_hdr = false; // the output offers headroom above reference white
bool source_describable = false; // this source's curve and gamut are both advertised
HdrToneMapping requested = HdrToneMapping::kCompositor;
uint32_t display_peak_nits = 0; // the output's peak while in HDR; 0 means unknown
// The output's diffuse-white luminance, which is the most an SDR signal can
// reach on it. Distinct from display_peak_nits: this panel reports a 600-nit
// peak but 200-nit reference white, and only the latter is reachable without
// an HDR description attached. 0 means unknown.
uint32_t sdr_reference_nits = 0;
};
// What to do about it.
struct HdrDecision {
bool describe = false; // attach an image description at all
bool tone_map_in_player = false; // mpv reduces the range rather than the compositor
// The peak mpv aims at. While a description is attached it is also the peak
// declared to the compositor — deliberately one number, because the two
// disagreeing is what makes a compositor remap a signal twice. Zero means
// target-peak stays on auto.
uint32_t target_peak_nits = 0;
};
// mpv's target-peak option accepts 10..10000; outside that there is nothing
// sensible to aim at and auto is the honest answer.
inline uint32_t UsableTargetPeak(uint32_t nits, uint32_t volume_max) {
if (nits > volume_max) nits = volume_max;
return nits >= 10 ? nits : 0;
}
// The single gate. Four independent conditions must hold before a plane is
// described as HDR, and they come from four different places: the user's
// setting, the compositor's advertised capabilities, the output's current state,
// and the file. Any one of them failing means falling back to mpv's ordinary
// tone-mapped SDR output, which is always safe.
//
// Both branches tell mpv what it is mapping to, from different fields. Left on
// auto mpv does pick its own defaults for an SDR curve and does tone-map against
// them, so this is about naming the output's real terms rather than assumed
// ones, measurably so at the bottom of the range. It is not what fixes the
// roll-off; that is mpv's `tone-mapping` operator, set in
// MpvPlayer::SetHdrOutput, and naming the peak alone left the highlights exactly
// where they were.
//
// Which field is right depends on what the plane will carry. Described, the
// output is in HDR and its peak is reachable. Undescribed, the buffer is an
// ordinary SDR signal whose maximum is the output's diffuse white, and claiming
// the HDR peak there would ask for range the encoding cannot express.
//
// The undescribed target applies only to an HDR *source*. An ordinary BT.709 file
// has nothing to map down: naming a peak for it would change plain SDR playback,
// which this has no business touching.
inline HdrDecision DecideHdr(const HdrInputs& inputs, const HdrMetadata& source) {
HdrDecision decision;
decision.describe = inputs.allowed && inputs.client_can_describe && inputs.output_is_hdr &&
inputs.source_describable && SourceIsHdr(source);
if (!decision.describe) {
if (SourceIsHdr(source)) {
// No curve is being declared, so nothing constrains this to a primary
// colour volume; the only ceiling is what the option accepts.
decision.target_peak_nits = UsableTargetPeak(inputs.sdr_reference_nits, kPqMaxLuminanceNits);
// mpv is the one reducing the range here, which is exactly what this flag
// says. `describe` independently keeps any metadata off the surface, so
// recording it truthfully costs nothing and keeps the decision coherent.
decision.tone_map_in_player = decision.target_peak_nits > 0;
}
return decision;
}
if (inputs.requested == HdrToneMapping::kPlayer && inputs.display_peak_nits > 0) {
// Clamped to the curve's primary colour volume here rather than at the two
// call sites, so the peak handed to mpv and the peak in the description are
// the same number by construction.
const uint32_t peak = UsableTargetPeak(inputs.display_peak_nits, PrimaryVolumeMaxNits(source.transfer));
if (peak > 0) {
decision.tone_map_in_player = true;
decision.target_peak_nits = peak;
}
}
return decision;
}
} // namespace mpv
#endif // PLEZY_LINUX_MPV_HDR_METADATA_H_
+810
View File
@@ -0,0 +1,810 @@
#include "hdr_metadata.h"
#include <iostream>
#include <limits>
namespace {
int failures = 0;
void Expect(bool condition, const char* expression, int line) {
if (condition) return;
std::cerr << "line " << line << ": check failed: " << expression << '\n';
++failures;
}
#define EXPECT(condition) Expect(static_cast<bool>(condition), #condition, __LINE__)
// Defaults to PQ / BT.2020, since that is what the luminance rules are usually
// exercised against. HLG cases override the transfer explicitly.
mpv::HdrMetadata Metadata(
uint32_t max_cll, uint32_t max_fall, uint32_t max_luminance, double min_luminance,
mpv::SourceTransfer transfer = mpv::SourceTransfer::kPq) {
mpv::HdrMetadata metadata;
metadata.transfer = transfer;
metadata.primaries = mpv::SourcePrimaries::kBt2020;
metadata.max_cll = max_cll;
metadata.max_fall = max_fall;
metadata.max_luminance = max_luminance;
metadata.min_luminance = min_luminance;
return metadata;
}
mpv::CompositorLuminanceSupport Support(
bool mastering, uint32_t interface_version, bool extended_target_volume = false) {
mpv::CompositorLuminanceSupport support;
support.mastering = mastering;
support.interface_version = interface_version;
support.extended_target_volume = extended_target_volume;
return support;
}
// Well-formed HDR10 keeps every field: this is the common case and it must not
// be degraded by the validation.
void TestWellFormedMetadataSurvives() {
const auto plan = mpv::PlanHdrLuminance(Metadata(1000, 400, 1000, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 1000);
// 0.0001 nits sits below the primary colour volume's 0.005 floor, so it is
// clamped up to stay contained; see TestMasteringFloorClampedIntoPrimaryVolume.
EXPECT(plan.mastering_min_scaled == 50u);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 1000);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 400);
}
// The 10000-nit synthetic clip. The volume cap drops anything strictly above
// the curve's maximum, so max_cll sitting exactly on PQ's 10000 is the boundary
// case that must survive it.
void TestMaxCllAtPqCeilingIsKept() {
const auto plan = mpv::PlanHdrLuminance(Metadata(10000, 600, 10000, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 10000);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 10000);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 600);
}
// A MaxCLL above the mastering display's own peak is common in badly authored
// files and is a fatal invalid_luminance on version 1. The light level goes,
// not the mastering range.
void TestMaxCllAboveMasteringMaxIsDroppedOnV1() {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000, 400, 1000, 0.005), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 1000);
EXPECT(!plan.send_max_cll);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 400);
}
// Version 2 dropped that requirement, so the same metadata keeps max_cll.
void TestMaxCllAboveMasteringMaxIsKeptOnV2() {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000, 400, 1000, 0.005), Support(true, 2));
EXPECT(plan.send_mastering);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 4000);
EXPECT(plan.send_max_fall);
}
// The same version-1 range rule applies to MaxFALL independently, and this is
// the case where nothing else would catch a regression: MaxCLL is unset, so the
// pair rule cannot fire and drop MaxFALL for the wrong reason. Getting it wrong
// sends a request set that is a *fatal* invalid_luminance, which disconnects the
// whole client rather than just failing the description.
void TestMaxFallAboveMasteringMaxIsDroppedOnV1() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 1001, 1000, 0.005), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 1000);
EXPECT(!plan.send_max_cll);
EXPECT(!plan.send_max_fall);
}
// max_fall > max_cll is a protocol error in *every* version. max_fall is the
// one dropped.
void TestMaxFallAboveMaxCllIsDropped() {
for (uint32_t version = 1; version <= 3; ++version) {
const auto plan = mpv::PlanHdrLuminance(Metadata(600, 900, 1000, 0.0001), Support(true, version));
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 600);
EXPECT(!plan.send_max_fall);
}
}
// When max_cll is dropped for being outside the range, the pair rule no longer
// applies and a legal max_fall survives on its own.
void TestMaxFallSurvivesWhenMaxCllIsDropped() {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000, 900, 1000, 0.0001), Support(true, 1));
EXPECT(!plan.send_max_cll);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 900);
}
// Without the advertised feature the mastering request would be
// unsupported_feature, so it is never sent. The light levels are then bounded by
// PQ's ceiling instead of the stream's mastering range.
void TestMasteringSuppressedWithoutCompositorSupport() {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000, 400, 1000, 0.0001), Support(false, 1));
EXPECT(!plan.send_mastering);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 4000);
EXPECT(plan.send_max_fall);
}
// max L <= min L is invalid_luminance on set_mastering_luminance itself.
void TestInvertedMasteringRangeIsSuppressed() {
const auto plan = mpv::PlanHdrLuminance(Metadata(500, 100, 1, 5.0), Support(true, 1));
EXPECT(!plan.send_mastering);
// With no mastering range the bound is PQ's ceiling, so both survive.
EXPECT(plan.send_max_cll);
EXPECT(plan.send_max_fall);
}
// Equal min and max is also rejected: the protocol wants strictly greater.
void TestEqualMasteringRangeIsSuppressed() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 1, 1.0), Support(true, 1));
EXPECT(!plan.send_mastering);
}
// A corrupt mastering maximum must not overflow the scaled comparison, and must
// not describe a display brighter than PQ can encode.
void TestMasteringMaxIsCappedAtPqCeiling() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 4000000000u, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 10000u);
}
// A light level PQ has no code point for is dropped on every version.
void TestLightLevelsAbovePqCeilingAreDropped() {
for (uint32_t version = 1; version <= 3; ++version) {
const auto plan = mpv::PlanHdrLuminance(Metadata(4000000000u, 3000000000u, 0, 0.0), Support(true, version));
EXPECT(!plan.send_max_cll);
EXPECT(!plan.send_max_fall);
}
}
// A source that carried nothing sends nothing, leaving the compositor on its
// own defaults.
void TestEmptyMetadataSendsNothing() {
const auto plan = mpv::PlanHdrLuminance(mpv::HdrMetadata(), Support(true, 1));
EXPECT(!plan.send_mastering);
EXPECT(!plan.send_max_cll);
EXPECT(!plan.send_max_fall);
}
// A mastering minimum coarser than one scaled unit must still round to a
// non-zero floor rather than silently becoming "unset".
void TestMinLuminanceScaling() {
EXPECT(mpv::ScaleMinLuminance(0.0001) == 1);
// Half a scaled unit. Without the rounding term this truncates to 0, i.e. the
// floor silently becomes "unset" instead of the smallest expressible value.
EXPECT(mpv::ScaleMinLuminance(0.00005) == 1);
EXPECT(mpv::ScaleMinLuminance(0.005) == 50);
EXPECT(mpv::ScaleMinLuminance(1.0) == 10000);
EXPECT(mpv::ScaleMinLuminance(0.0) == 0);
EXPECT(mpv::ScaleMinLuminance(-1.0) == 0);
// An out-of-range float-to-uint32 conversion is undefined behaviour rather than
// a wrap, and mpv's video-params is untrusted input, so saturating is part of
// the contract rather than an implementation detail.
EXPECT(mpv::ScaleMinLuminance(1e30) == 4294967295u);
}
// The range predicate itself: strictly above min L, at or below max L.
void TestRangePredicateBoundaries() {
// min L = 0.0001 nits, so any whole nit clears it.
EXPECT(mpv::LuminanceInMasteringRange(1, 1, 1000));
EXPECT(mpv::LuminanceInMasteringRange(1000, 1, 1000));
EXPECT(!mpv::LuminanceInMasteringRange(1001, 1, 1000));
// min L = 5 nits: 5 is not strictly greater, 6 is.
EXPECT(!mpv::LuminanceInMasteringRange(5, 50000, 1000));
EXPECT(mpv::LuminanceInMasteringRange(6, 50000, 1000));
// An absurd value is rejected by the max bound, before the scaled multiply.
EXPECT(!mpv::LuminanceInMasteringRange(4000000000u, 1, 1000));
}
// The mastering floor is a bound in its own right: on version 1 a light level at
// or below min L is invalid_luminance just as surely as one above max L. This
// drives it through PlanHdrLuminance rather than the predicate alone, so it
// covers the wiring of mastering_min_scaled into the range test.
void TestLightLevelsBelowTheMasteringFloorAreDropped() {
const auto plan = mpv::PlanHdrLuminance(Metadata(3, 2, 1000, 5.0), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_min_scaled == 50000);
EXPECT(!plan.send_max_cll);
EXPECT(!plan.send_max_fall);
}
// HLG's primary colour volume tops out at 1000 nits, not PQ's 10000. A 4000-nit
// MaxCLL with no mastering range is inside PQ's volume but outside HLG's, and on
// version 1 that is a fatal invalid_luminance, so it must be dropped.
void TestHlgLightLevelsBoundedAtThousand() {
const auto hlg = mpv::PlanHdrLuminance(Metadata(4000, 400, 0, 0.0, mpv::SourceTransfer::kHlg), Support(false, 1));
EXPECT(!hlg.send_mastering);
EXPECT(!hlg.send_max_cll);
EXPECT(hlg.send_max_fall);
EXPECT(hlg.max_fall == 400);
// The identical numbers are legal under PQ, which is the whole point.
const auto pq = mpv::PlanHdrLuminance(Metadata(4000, 400, 0, 0.0, mpv::SourceTransfer::kPq), Support(false, 1));
EXPECT(pq.send_max_cll);
EXPECT(pq.max_cll == 4000);
}
// Both interface versions must agree about the same stream. extended_target_volume
// lets the mastering range reach 10000 even for HLG, so a version-1 range check
// alone would accept a 2000-nit HLG light level that version 2 refuses — the
// curve's own volume bound has to apply regardless of version.
void TestVolumeCapIsVersionIndependent() {
const auto metadata = Metadata(2000, 1500, 4000, 0.01, mpv::SourceTransfer::kHlg);
const auto v1 = mpv::PlanHdrLuminance(metadata, Support(true, 1, true));
const auto v2 = mpv::PlanHdrLuminance(metadata, Support(true, 2, true));
EXPECT(!v1.send_max_cll);
EXPECT(!v2.send_max_cll);
EXPECT(v1.send_max_cll == v2.send_max_cll);
EXPECT(v1.send_max_fall == v2.send_max_fall);
}
// Exactly 1000 is inside HLG's volume; 1001 is not.
void TestHlgVolumeBoundary() {
const auto inside = mpv::PlanHdrLuminance(Metadata(1000, 0, 0, 0.0, mpv::SourceTransfer::kHlg), Support(false, 1));
EXPECT(inside.send_max_cll);
const auto outside = mpv::PlanHdrLuminance(Metadata(1001, 0, 0, 0.0, mpv::SourceTransfer::kHlg), Support(false, 1));
EXPECT(!outside.send_max_cll);
EXPECT(mpv::PrimaryVolumeMaxNits(mpv::SourceTransfer::kHlg) == 1000);
EXPECT(mpv::PrimaryVolumeMaxNits(mpv::SourceTransfer::kPq) == 10000);
}
// An HLG mastering display brighter than 1000 nits exceeds the primary colour
// volume, which needs extended_target_volume. Without it the value is clamped
// down rather than sent as-is.
void TestHlgMasteringClampedWithoutExtendedVolume() {
const auto clamped = mpv::PlanHdrLuminance(Metadata(0, 0, 4000, 0.005, mpv::SourceTransfer::kHlg), Support(true, 1));
EXPECT(clamped.send_mastering);
EXPECT(clamped.mastering_max == 1000u);
// With the feature advertised the source's own figure is honoured.
const auto extended =
mpv::PlanHdrLuminance(Metadata(0, 0, 4000, 0.005, mpv::SourceTransfer::kHlg), Support(true, 1, true));
EXPECT(extended.send_mastering);
EXPECT(extended.mastering_max == 4000);
}
// PQ mastering is never clamped by the extended-volume gate, because 10000 is
// already its primary colour volume maximum.
void TestPqMasteringUnaffectedByExtendedVolumeGate() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 10000, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 10000);
}
// Player-side tone mapping: the description must claim the peak we produced, not
// the source's original range, or the compositor compresses levels that are no
// longer present.
void TestDescribeTonemappedTo() {
const auto source = Metadata(10000, 600, 10000, 0.0001);
const auto described = mpv::DescribeTonemappedTo(source, 600);
EXPECT(described.transfer == mpv::SourceTransfer::kPq);
EXPECT(described.primaries == mpv::SourcePrimaries::kBt2020);
EXPECT(described.max_cll == 600);
EXPECT(described.max_luminance == 600);
// The source's 600-nit MaxFALL still fits, so it survives.
EXPECT(described.max_fall == 600);
// The floor is untouched.
EXPECT(described.min_luminance == source.min_luminance);
// A MaxFALL above the produced peak would violate max_fall <= max_cll, so it
// is dropped rather than clamped to a figure we never measured.
const auto dropped = mpv::DescribeTonemappedTo(Metadata(10000, 900, 10000, 0.0001), 600);
EXPECT(dropped.max_fall == 0);
// Zero peak means "not known"; nothing is rewritten.
const auto untouched = mpv::DescribeTonemappedTo(source, 0);
EXPECT(untouched.max_cll == 10000);
// HLG is clamped to its own volume, not PQ's.
const auto hlg = mpv::DescribeTonemappedTo(Metadata(0, 0, 0, 0.005, mpv::SourceTransfer::kHlg), 4000);
EXPECT(hlg.max_cll == 1000u);
}
// The whole reason the rewrite exists: what it produces must itself survive the
// planner, on version 1, with no mastering support.
void TestTonemappedDescriptionIsSendable() {
const auto described = mpv::DescribeTonemappedTo(Metadata(10000, 600, 10000, 0.0001), 600);
const auto plan = mpv::PlanHdrLuminance(described, Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 600);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 600);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 600);
}
// Containment is two-sided. A 0.0001-nit mastering floor is below the primary
// colour volume's 0.005, so without extended_target_volume it is clamped up
// rather than sent as-is — and the maximum, which is the compositor's fallback
// peak, is preserved instead of dropping the whole request.
void TestMasteringFloorClampedIntoPrimaryVolume() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 1000, 0.0001), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_min_scaled == 50u);
EXPECT(plan.mastering_max == 1000);
// An absent floor reads as zero and is clamped the same way.
const auto absent = mpv::PlanHdrLuminance(Metadata(0, 0, 1000, 0.0), Support(true, 1));
EXPECT(absent.send_mastering);
EXPECT(absent.mastering_min_scaled == 50u);
// With extended_target_volume the source's true floor goes out untouched.
const auto extended = mpv::PlanHdrLuminance(Metadata(0, 0, 1000, 0.0001), Support(true, 1, true));
EXPECT(extended.send_mastering);
EXPECT(extended.mastering_min_scaled == 1);
}
// A floor already inside the volume is left exactly as the source stated it.
void TestMasteringFloorInsideVolumeIsUntouched() {
const auto plan = mpv::PlanHdrLuminance(Metadata(0, 0, 1000, 0.05), Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_min_scaled == 500);
}
// Both HDR curves, so every peak-clamping case is exercised against each.
const mpv::SourceTransfer kHdrTransfers[] = {mpv::SourceTransfer::kPq, mpv::SourceTransfer::kHlg};
// Every gate passing, in compositor mode with no peak or reference reported.
// Each case starts here and mutates only the field it is about.
mpv::HdrInputs AllGatesPass() {
mpv::HdrInputs inputs;
inputs.allowed = true;
inputs.client_can_describe = true;
inputs.output_is_hdr = true;
inputs.source_describable = true;
return inputs;
}
// Each of the four gates must be able to veto on its own, and an SDR source
// vetoes regardless of the rest.
void TestEachGateCanVeto() {
const auto pq = Metadata(1000, 400, 1000, 0.0001);
EXPECT(mpv::DecideHdr(AllGatesPass(), pq).describe);
auto not_allowed = AllGatesPass();
not_allowed.allowed = false;
EXPECT(!mpv::DecideHdr(not_allowed, pq).describe);
auto client_cannot_describe = AllGatesPass();
client_cannot_describe.client_can_describe = false;
EXPECT(!mpv::DecideHdr(client_cannot_describe, pq).describe);
auto output_is_sdr = AllGatesPass();
output_is_sdr.output_is_hdr = false;
EXPECT(!mpv::DecideHdr(output_is_sdr, pq).describe);
auto source_not_describable = AllGatesPass();
source_not_describable.source_describable = false;
EXPECT(!mpv::DecideHdr(source_not_describable, pq).describe);
const auto sdr = Metadata(0, 0, 0, 0.0, mpv::SourceTransfer::kSdr);
EXPECT(!mpv::DecideHdr(AllGatesPass(), sdr).describe);
}
// Compositor mode never sets a target peak, whatever the display reports.
void TestCompositorModeLeavesPeakAuto() {
auto inputs = AllGatesPass();
inputs.display_peak_nits = 600;
const auto decision = mpv::DecideHdr(inputs, Metadata(10000, 600, 10000, 0.0001));
EXPECT(decision.describe);
EXPECT(!decision.tone_map_in_player);
EXPECT(decision.target_peak_nits == 0);
}
void TestPlayerModeAdoptsDisplayPeak() {
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
inputs.display_peak_nits = 600;
const auto decision = mpv::DecideHdr(inputs, Metadata(10000, 600, 10000, 0.0001));
EXPECT(decision.describe);
EXPECT(decision.tone_map_in_player);
EXPECT(decision.target_peak_nits == 600);
}
// An HDR source on an SDR output is the fallback path, and mpv still has to be
// told what it is mapping to. Left on auto with no window it does not tone-map at
// all, so the peak has to come from the output's diffuse white - not from the
// HDR-mode peak, which an SDR signal cannot reach.
void TestUndescribedHdrSourceAdoptsSdrReference() {
for (const mpv::SourceTransfer transfer : kHdrTransfers) {
const auto source = Metadata(1000, 400, 1000, 0.0001, transfer);
auto inputs = AllGatesPass();
// The one gate that puts us on this path on an SDR panel.
inputs.output_is_hdr = false;
inputs.display_peak_nits = 600;
inputs.sdr_reference_nits = 200;
const auto decision = mpv::DecideHdr(inputs, source);
EXPECT(!decision.describe);
// mpv reduces the range here, so the flag says so; `describe` is what keeps
// metadata off the surface.
EXPECT(decision.tone_map_in_player);
EXPECT(decision.target_peak_nits == 200);
// Player mode changes nothing on this path: the undescribed branch never
// reads `requested` and never reads the HDR-mode peak, so the 600-nit peak
// does not displace the 200-nit reference.
auto player = inputs;
player.requested = mpv::HdrToneMapping::kPlayer;
const auto player_decision = mpv::DecideHdr(player, source);
EXPECT(!player_decision.describe);
EXPECT(player_decision.tone_map_in_player);
EXPECT(player_decision.target_peak_nits == 200);
}
}
// Without a reference white there is nothing to aim at, and inventing one would
// be worse than mpv's own default.
void TestUndescribedWithoutSdrReferenceStaysAuto() {
const auto source = Metadata(1000, 400, 1000, 0.0001);
auto inputs = AllGatesPass();
inputs.output_is_hdr = false;
inputs.display_peak_nits = 600;
EXPECT(mpv::DecideHdr(inputs, source).target_peak_nits == 0);
// Below the option's floor is the same as unknown.
inputs.sdr_reference_nits = 9;
EXPECT(mpv::DecideHdr(inputs, source).target_peak_nits == 0);
inputs.sdr_reference_nits = 10;
EXPECT(mpv::DecideHdr(inputs, source).target_peak_nits == 10);
}
// The regression this guard exists for: an ordinary BT.709 file has nothing to
// map down, so naming a peak would change plain SDR playback.
void TestUndescribedSdrSourceKeepsPeakAuto() {
const auto sdr = Metadata(0, 0, 0, 0.0, mpv::SourceTransfer::kSdr);
auto inputs = AllGatesPass();
inputs.output_is_hdr = false;
inputs.display_peak_nits = 600;
inputs.sdr_reference_nits = 200;
const auto decision = mpv::DecideHdr(inputs, sdr);
EXPECT(!decision.describe);
EXPECT(decision.target_peak_nits == 0);
// Also true when every other gate would have passed.
auto every_gate = AllGatesPass();
every_gate.requested = mpv::HdrToneMapping::kPlayer;
every_gate.display_peak_nits = 600;
every_gate.sdr_reference_nits = 200;
EXPECT(mpv::DecideHdr(every_gate, sdr).target_peak_nits == 0);
}
// On an HDR output the described peak still comes from the HDR-mode peak; the SDR
// reference must not displace it.
void TestDescribedPlayerModeIgnoresSdrReference() {
const auto source = Metadata(10000, 600, 10000, 0.0001);
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
inputs.display_peak_nits = 600;
inputs.sdr_reference_nits = 200;
const auto decision = mpv::DecideHdr(inputs, source);
EXPECT(decision.describe);
EXPECT(decision.tone_map_in_player);
EXPECT(decision.target_peak_nits == 600);
}
// Without a usable peak there is nothing to aim at, so player mode degrades to
// passthrough rather than inventing a target.
void TestPlayerModeWithoutPeakFallsBack() {
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
const auto absent = mpv::DecideHdr(inputs, Metadata(10000, 600, 10000, 0.0001));
EXPECT(absent.describe);
EXPECT(!absent.tone_map_in_player);
EXPECT(absent.target_peak_nits == 0);
// mpv's target-peak option starts at 10.
inputs.display_peak_nits = 5;
const auto tiny = mpv::DecideHdr(inputs, Metadata(10000, 600, 10000, 0.0001));
EXPECT(!tiny.tone_map_in_player);
}
// The invariant that keeps mpv's target equal to the declared peak: whatever
// DecideHdr returns must survive DescribeTonemappedTo unchanged.
void TestDecidedPeakMatchesDescribedPeak() {
const uint32_t reported[] = {600, 1000, 1500, 4000, 12000};
for (const uint32_t peak : reported) {
for (const mpv::SourceTransfer transfer : kHdrTransfers) {
const auto source = Metadata(0, 0, 0, 0.0001, transfer);
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
inputs.display_peak_nits = peak;
const auto decision = mpv::DecideHdr(inputs, source);
EXPECT(decision.tone_map_in_player);
EXPECT(decision.target_peak_nits <= mpv::PrimaryVolumeMaxNits(transfer));
const auto described = mpv::DescribeTonemappedTo(source, decision.target_peak_nits);
EXPECT(described.max_luminance == decision.target_peak_nits);
EXPECT(described.max_cll == decision.target_peak_nits);
}
}
// Both curves specifically, at a peak above their own ceiling. An inequality
// alone would accept a clamp to any lower value: this is simultaneously mpv's
// target-peak and the peak declared to the compositor, so the exact number is
// the contract, not merely "not too big".
auto clamped = AllGatesPass();
clamped.requested = mpv::HdrToneMapping::kPlayer;
clamped.display_peak_nits = 12000;
const auto pq = mpv::DecideHdr(clamped, Metadata(0, 0, 0, 0.0001, mpv::SourceTransfer::kPq));
EXPECT(pq.target_peak_nits == 10000u);
clamped.display_peak_nits = 1500;
const auto hlg = mpv::DecideHdr(clamped, Metadata(0, 0, 0, 0.0001, mpv::SourceTransfer::kHlg));
EXPECT(hlg.target_peak_nits == 1000u);
// The undescribed fallback takes its peak from the same clamp, so an absurd
// compositor reference white cannot reach mpv's target-peak either.
auto undescribed = AllGatesPass();
undescribed.output_is_hdr = false;
undescribed.sdr_reference_nits = 99999;
const auto fallback = mpv::DecideHdr(undescribed, Metadata(0, 0, 0, 0.0001, mpv::SourceTransfer::kPq));
EXPECT(!fallback.describe);
EXPECT(fallback.target_peak_nits == 10000u);
}
// And the decided peak, once described, must still be legal to send.
void TestDecidedPeakIsSendable() {
for (const mpv::SourceTransfer transfer : kHdrTransfers) {
const auto source = Metadata(4000, 2000, 4000, 0.0001, transfer);
auto inputs = AllGatesPass();
inputs.requested = mpv::HdrToneMapping::kPlayer;
inputs.display_peak_nits = 700;
const auto decision = mpv::DecideHdr(inputs, source);
const auto plan =
mpv::PlanHdrLuminance(mpv::DescribeTonemappedTo(source, decision.target_peak_nits), Support(true, 1));
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == decision.target_peak_nits);
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == decision.target_peak_nits);
EXPECT(!plan.send_max_fall || plan.max_fall <= plan.max_cll);
}
}
// The numbers here are what KWin actually reports, because the risk this guards
// against is a plausible-looking rule that mistakes one state for another.
void TestHdrOutputsAreRecognisedByHeadroom() {
// A 400-nit HDR panel over 203-nit reference white, measured on hardware.
EXPECT(mpv::OutputHasHdrHeadroom(400, 203));
// KWin's own default HDR peak when the EDID declares none.
EXPECT(mpv::OutputHasHdrHeadroom(800, 200));
// An HDR output whose reference white was raised by the brightness slider
// still clears the margin.
EXPECT(mpv::OutputHasHdrHeadroom(465, 208));
}
void TestSdrOutputsAreRejectedEvenWhenDimmed() {
// Undimmed SDR: the compositor reports its reference white as the maximum.
EXPECT(!mpv::OutputHasHdrHeadroom(200, 200));
// Dimmed SDR is the trap. KWin scales reference white in software and keeps
// reporting the undimmed maximum, so a bare `max > reference` reads as HDR
// on an output that is not: at 80% brightness reference white is
// 5 + (200 - 5) * 0.8 = 161.
EXPECT(!mpv::OutputHasHdrHeadroom(200, 161));
// The same at 70%, which is 1.46x and still under the margin.
EXPECT(!mpv::OutputHasHdrHeadroom(200, 137));
}
void TestUnknownLuminancesAreNotHdr() {
// Nothing reported at all, and a maximum without a reference to measure it
// against: neither is evidence of headroom.
EXPECT(!mpv::OutputHasHdrHeadroom(0, 0));
EXPECT(!mpv::OutputHasHdrHeadroom(800, 0));
// A reference white above the maximum is incoherent, not headroom.
EXPECT(!mpv::OutputHasHdrHeadroom(100, 203));
}
// The margin is exactly 1.5x, so both sides of it are worth pinning: truncating
// integer arithmetic would put the boundary half a nit low and let a 304-nit
// peak over 203-nit white read as headroom.
void TestHeadroomBoundaryIsExact() {
EXPECT(!mpv::OutputHasHdrHeadroom(304, 203)); // 1.4975x - just under
EXPECT(mpv::OutputHasHdrHeadroom(305, 203)); // 1.5025x - just over
EXPECT(mpv::OutputHasHdrHeadroom(300, 200)); // exactly 1.5x counts
EXPECT(!mpv::OutputHasHdrHeadroom(299, 200));
// Small values must not round their way into headroom either.
EXPECT(!mpv::OutputHasHdrHeadroom(1, 1));
EXPECT(!mpv::OutputHasHdrHeadroom(2, 2));
}
// Naming a curve the compositor never advertised is a *fatal* invalid_tf on
// create(), which disconnects the client. So each arm is pinned separately: a
// swap between the two, or one standing in for the other, would otherwise pass.
void TestOnlyAdvertisedCurvesAreDescribable() {
const mpv::CompositorColorSupport pq_only{true, true, false};
const mpv::CompositorColorSupport hlg_only{true, false, true};
const mpv::CompositorColorSupport both{true, true, true};
auto pq = Metadata(1000, 400, 1000, 0.005);
pq.transfer = mpv::SourceTransfer::kPq;
pq.primaries = mpv::SourcePrimaries::kBt2020;
auto hlg = pq;
hlg.transfer = mpv::SourceTransfer::kHlg;
EXPECT(mpv::SourceIsDescribable(pq, pq_only));
EXPECT(!mpv::SourceIsDescribable(hlg, pq_only));
EXPECT(mpv::SourceIsDescribable(hlg, hlg_only));
EXPECT(!mpv::SourceIsDescribable(pq, hlg_only));
EXPECT(mpv::SourceIsDescribable(pq, both));
EXPECT(mpv::SourceIsDescribable(hlg, both));
}
void TestSdrAndNarrowGamutSourcesAreNotDescribable() {
const mpv::CompositorColorSupport all{true, true, true};
// An SDR source has nothing to describe, whatever the compositor accepts.
auto sdr = Metadata(0, 0, 0, 0.0);
sdr.transfer = mpv::SourceTransfer::kSdr;
sdr.primaries = mpv::SourcePrimaries::kBt2020;
EXPECT(!mpv::SourceIsDescribable(sdr, all));
// An HDR curve in a non-BT.2020 container is not worth the switch, and the
// container primaries would be a claim we cannot make.
auto narrow = Metadata(1000, 400, 1000, 0.005);
narrow.transfer = mpv::SourceTransfer::kPq;
narrow.primaries = mpv::SourcePrimaries::kOther;
EXPECT(!mpv::SourceIsDescribable(narrow, all));
// And a compositor that never advertised BT.2020 cannot be told about it,
// however describable the curve is.
auto pq = Metadata(1000, 400, 1000, 0.005);
pq.transfer = mpv::SourceTransfer::kPq;
pq.primaries = mpv::SourcePrimaries::kBt2020;
EXPECT(!mpv::SourceIsDescribable(pq, mpv::CompositorColorSupport{false, true, true}));
}
// These arrive unvalidated from the compositor, so the comparison has to hold
// at the top of the range rather than wrapping into the wrong answer.
void TestHeadroomSurvivesExtremeLuminances() {
const uint32_t huge = 0xFFFFFFFFu;
EXPECT(!mpv::OutputHasHdrHeadroom(huge, huge));
EXPECT(mpv::OutputHasHdrHeadroom(huge, 1));
// A reference white so large that reference + reference/2 would overflow:
// the answer is still "no headroom", not an accidental yes.
EXPECT(!mpv::OutputHasHdrHeadroom(1000, huge));
}
// min_luminance is copied straight off mpv's video-params with no sanitising,
// so the guard has to be NaN-safe by construction. It is only safe because the
// comparison is negated - rewriting it as `nits <= 0` would let NaN through into
// an undefined double-to-uint32 conversion, with nothing else to catch it.
void TestNonFiniteMasteringMinimumIsRejected() {
const double nan = std::numeric_limits<double>::quiet_NaN();
const double infinity = std::numeric_limits<double>::infinity();
EXPECT(mpv::ScaleMinLuminance(nan) == 0);
EXPECT(mpv::ScaleMinLuminance(-infinity) == 0);
// Infinity is finite-clamped rather than wrapped: the scaled value saturates.
EXPECT(mpv::ScaleMinLuminance(infinity) == UINT32_MAX);
// And it reaches the plan as the primary volume's floor rather than as a
// nonsense minimum: a NaN scales to 0, which the floor then raises to 50.
auto metadata = Metadata(1000, 400, 1000, 0.0);
metadata.min_luminance = nan;
const auto plan = mpv::PlanHdrLuminance(metadata, Support(true, 1));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_min_scaled == 50u);
EXPECT(plan.mastering_max == 1000);
}
// Version 2 drops the mastering-range rule for *both* light levels. Only MaxCLL
// was proven; a regression that kept rejecting an out-of-range MaxFALL on v2
// would otherwise pass, silently dropping metadata the compositor would accept.
void TestVersionTwoKeepsBothLightLevelsOutsideTheMasteringRange() {
const auto plan = mpv::PlanHdrLuminance(Metadata(5000, 4000, 1000, 0.005), Support(true, 2));
EXPECT(plan.send_mastering);
EXPECT(plan.mastering_max == 1000);
EXPECT(plan.send_max_cll);
EXPECT(plan.max_cll == 5000);
EXPECT(plan.send_max_fall);
EXPECT(plan.max_fall == 4000);
}
// The undescribed-HDR fallback belongs to *any* veto, not just an SDR output.
// With the gate tests all leaving sdr_reference_nits at zero, a regression that
// applied it only when the output vetoed would leave mpv's target peak on auto
// whenever permission, client capability or describability was the reason -
// which is an HDR source rendered against no known white point.
void TestEveryVetoStillAdoptsTheSdrReference() {
for (int gate = 0; gate < 3; ++gate) {
mpv::HdrInputs inputs = AllGatesPass();
inputs.sdr_reference_nits = 203;
if (gate == 0) inputs.allowed = false;
if (gate == 1) inputs.client_can_describe = false;
if (gate == 2) inputs.source_describable = false;
const auto decision = mpv::DecideHdr(inputs, Metadata(1000, 400, 1000, 0.005));
EXPECT(!decision.describe);
EXPECT(decision.target_peak_nits == 203);
EXPECT(decision.tone_map_in_player);
}
}
// Every field, one at a time. This operator decides whether a colour transition
// is staged at all: the plane treats an equal snapshot as "nothing to do" and
// never re-describes, so a field dropped from the conjunction leaves the old
// image description attached to pixels it no longer describes. Dropping one, or
// replacing the whole body with `return true`, passes every other test here.
void TestMetadataEqualityComparesEveryField() {
const auto base = Metadata(1000, 400, 4000, 0.005);
EXPECT(base == Metadata(1000, 400, 4000, 0.005));
EXPECT(!(base != Metadata(1000, 400, 4000, 0.005)));
auto transfer = base;
transfer.transfer = mpv::SourceTransfer::kHlg;
EXPECT(base != transfer);
auto primaries = base;
primaries.primaries = mpv::SourcePrimaries::kOther;
EXPECT(base != primaries);
auto max_cll = base;
max_cll.max_cll = 999;
EXPECT(base != max_cll);
auto max_fall = base;
max_fall.max_fall = 399;
EXPECT(base != max_fall);
auto max_luminance = base;
max_luminance.max_luminance = 3999;
EXPECT(base != max_luminance);
auto min_luminance = base;
min_luminance.min_luminance = 0.0051;
EXPECT(base != min_luminance);
// != must stay the negation of ==, not a second opinion.
EXPECT(!(base == transfer) && (base != transfer));
}
} // namespace
int main() {
TestWellFormedMetadataSurvives();
TestMaxCllAtPqCeilingIsKept();
TestMaxCllAboveMasteringMaxIsDroppedOnV1();
TestMaxCllAboveMasteringMaxIsKeptOnV2();
TestMaxFallAboveMasteringMaxIsDroppedOnV1();
TestMaxFallAboveMaxCllIsDropped();
TestMaxFallSurvivesWhenMaxCllIsDropped();
TestMasteringSuppressedWithoutCompositorSupport();
TestInvertedMasteringRangeIsSuppressed();
TestEqualMasteringRangeIsSuppressed();
TestMasteringMaxIsCappedAtPqCeiling();
TestLightLevelsAbovePqCeilingAreDropped();
TestEmptyMetadataSendsNothing();
TestMinLuminanceScaling();
TestRangePredicateBoundaries();
TestLightLevelsBelowTheMasteringFloorAreDropped();
TestHlgLightLevelsBoundedAtThousand();
TestHlgVolumeBoundary();
TestVolumeCapIsVersionIndependent();
TestHlgMasteringClampedWithoutExtendedVolume();
TestPqMasteringUnaffectedByExtendedVolumeGate();
TestDescribeTonemappedTo();
TestTonemappedDescriptionIsSendable();
TestMasteringFloorClampedIntoPrimaryVolume();
TestMasteringFloorInsideVolumeIsUntouched();
TestEachGateCanVeto();
TestCompositorModeLeavesPeakAuto();
TestPlayerModeAdoptsDisplayPeak();
TestUndescribedHdrSourceAdoptsSdrReference();
TestUndescribedWithoutSdrReferenceStaysAuto();
TestUndescribedSdrSourceKeepsPeakAuto();
TestDescribedPlayerModeIgnoresSdrReference();
TestPlayerModeWithoutPeakFallsBack();
TestDecidedPeakMatchesDescribedPeak();
TestDecidedPeakIsSendable();
TestHdrOutputsAreRecognisedByHeadroom();
TestSdrOutputsAreRejectedEvenWhenDimmed();
TestUnknownLuminancesAreNotHdr();
TestHeadroomBoundaryIsExact();
TestHeadroomSurvivesExtremeLuminances();
TestOnlyAdvertisedCurvesAreDescribable();
TestSdrAndNarrowGamutSourcesAreNotDescribable();
TestNonFiniteMasteringMinimumIsRejected();
TestVersionTwoKeepsBothLightLevelsOutsideTheMasteringRange();
TestEveryVetoStillAdoptsTheSdrReference();
TestMetadataEqualityComparesEveryField();
return failures == 0 ? 0 : 1;
}
-129
View File
@@ -1,129 +0,0 @@
#include "mpv_gpu_bootstrap.h"
#include <cstdlib>
#include <cstring>
namespace mpv {
namespace {
bool HasExtension(const char* extensions, const char* requested) {
if (!extensions || !requested || requested[0] == '\0' || std::strchr(requested, ' ')) return false;
const size_t requested_length = std::strlen(requested);
const char* current = extensions;
while ((current = std::strstr(current, requested)) != nullptr) {
const bool starts_token = current == extensions || current[-1] == ' ';
const char following = current[requested_length];
if (starts_token && (following == '\0' || following == ' ')) return true;
current += requested_length;
}
return false;
}
bool ParseEglVersion(const char* version, int* major, int* minor) {
if (!version || !major || !minor) return false;
char* end = nullptr;
const long parsed_major = std::strtol(version, &end, 10);
if (end == version || *end != '.') return false;
const char* minor_start = end + 1;
const long parsed_minor = std::strtol(minor_start, &end, 10);
if (end == minor_start || parsed_major < 0 || parsed_minor < 0) return false;
*major = static_cast<int>(parsed_major);
*minor = static_cast<int>(parsed_minor);
return true;
}
bool AtLeastEgl15(const GpuBootstrapProbe& probe) {
return probe.egl_major > 1 || (probe.egl_major == 1 && probe.egl_minor >= 5);
}
bool Fail(std::string* error, const char* message) {
if (error) *error = message;
return false;
}
} // namespace
EGLImageKHR GpuImageDispatch::Create(EGLDisplay display, EGLContext context, EGLClientBuffer buffer) const {
if (uses_core) {
if (!create_image_core) return EGL_NO_IMAGE_KHR;
const EGLAttrib attributes[] = {EGL_NONE};
return reinterpret_cast<EGLImageKHR>(
create_image_core(display, context, EGL_GL_TEXTURE_2D_KHR, buffer, attributes));
}
if (!create_image_khr) return EGL_NO_IMAGE_KHR;
const EGLint attributes[] = {EGL_NONE};
return create_image_khr(display, context, EGL_GL_TEXTURE_2D_KHR, buffer, attributes);
}
bool GpuImageDispatch::Destroy(EGLDisplay display, EGLImageKHR image) const {
if (image == EGL_NO_IMAGE_KHR) return true;
if (uses_core) {
return destroy_image_core && destroy_image_core(display, reinterpret_cast<EGLImage>(image)) == EGL_TRUE;
}
return destroy_image_khr && destroy_image_khr(display, image) == EGL_TRUE;
}
GpuImageDispatch::operator bool() const {
const bool image_functions =
uses_core ? create_image_core && destroy_image_core : create_image_khr && destroy_image_khr;
return image_functions && image_target_texture;
}
bool ValidateGpuBootstrapProbe(const GpuBootstrapProbe& probe, std::string* error) {
const bool egl15 = AtLeastEgl15(probe);
if (!egl15 && !HasExtension(probe.egl_extensions, "EGL_KHR_surfaceless_context")) {
return Fail(error, "EGL surfaceless contexts are unavailable");
}
const bool core_images = egl15 && probe.create_image_core && probe.destroy_image_core;
const bool has_khr_image_extension =
HasExtension(probe.egl_extensions, "EGL_KHR_image") || HasExtension(probe.egl_extensions, "EGL_KHR_image_base");
const bool khr_images = has_khr_image_extension && probe.create_image_khr && probe.destroy_image_khr;
if (!core_images && !khr_images) {
return Fail(error, "EGL image creation is unavailable");
}
if (!HasExtension(probe.gl_extensions, "GL_OES_EGL_image")) {
return Fail(error, "OpenGL EGL image binding is unavailable");
}
if (!probe.image_target_texture) {
return Fail(error, "OpenGL EGL image entry point is unavailable");
}
if (error) error->clear();
return true;
}
bool ResolveGpuImageDispatch(EGLDisplay display, GpuImageDispatch* dispatch, std::string* error) {
if (!dispatch || display == EGL_NO_DISPLAY || eglGetCurrentContext() == EGL_NO_CONTEXT) {
return Fail(error, "No current EGL context is available");
}
GpuBootstrapProbe probe;
if (!ParseEglVersion(eglQueryString(display, EGL_VERSION), &probe.egl_major, &probe.egl_minor)) {
return Fail(error, "EGL version is unavailable");
}
probe.egl_extensions = eglQueryString(display, EGL_EXTENSIONS);
probe.gl_extensions = reinterpret_cast<const char*>(glGetString(GL_EXTENSIONS));
probe.create_image_core = reinterpret_cast<void*>(eglGetProcAddress("eglCreateImage"));
probe.destroy_image_core = reinterpret_cast<void*>(eglGetProcAddress("eglDestroyImage"));
probe.create_image_khr = reinterpret_cast<void*>(eglGetProcAddress("eglCreateImageKHR"));
probe.destroy_image_khr = reinterpret_cast<void*>(eglGetProcAddress("eglDestroyImageKHR"));
probe.image_target_texture = reinterpret_cast<void*>(eglGetProcAddress("glEGLImageTargetTexture2DOES"));
if (!ValidateGpuBootstrapProbe(probe, error)) return false;
GpuImageDispatch resolved;
const bool egl15 = AtLeastEgl15(probe);
if (egl15 && probe.create_image_core && probe.destroy_image_core) {
resolved.uses_core = true;
resolved.create_image_core = reinterpret_cast<EglCreateImageCoreProc>(probe.create_image_core);
resolved.destroy_image_core = reinterpret_cast<EglDestroyImageCoreProc>(probe.destroy_image_core);
} else {
resolved.create_image_khr = reinterpret_cast<EglCreateImageKhrProc>(probe.create_image_khr);
resolved.destroy_image_khr = reinterpret_cast<EglDestroyImageKhrProc>(probe.destroy_image_khr);
}
resolved.image_target_texture = reinterpret_cast<GlImageTargetTextureProc>(probe.image_target_texture);
if (!resolved) return Fail(error, "GPU image dispatch is incomplete");
*dispatch = resolved;
return true;
}
} // namespace mpv
-47
View File
@@ -1,47 +0,0 @@
#ifndef MPV_GPU_BOOTSTRAP_H_
#define MPV_GPU_BOOTSTRAP_H_
#include <epoxy/egl.h>
#include <epoxy/gl.h>
#include <string>
namespace mpv {
using EglCreateImageCoreProc = EGLImage (*)(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLAttrib*);
using EglDestroyImageCoreProc = EGLBoolean (*)(EGLDisplay, EGLImage);
using EglCreateImageKhrProc = EGLImageKHR (*)(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*);
using EglDestroyImageKhrProc = EGLBoolean (*)(EGLDisplay, EGLImageKHR);
using GlImageTargetTextureProc = void (*)(GLenum, GLeglImageOES);
struct GpuBootstrapProbe {
int egl_major = 0;
int egl_minor = 0;
const char* egl_extensions = nullptr;
const char* gl_extensions = nullptr;
void* create_image_core = nullptr;
void* destroy_image_core = nullptr;
void* create_image_khr = nullptr;
void* destroy_image_khr = nullptr;
void* image_target_texture = nullptr;
};
struct GpuImageDispatch {
bool uses_core = false;
EglCreateImageCoreProc create_image_core = nullptr;
EglDestroyImageCoreProc destroy_image_core = nullptr;
EglCreateImageKhrProc create_image_khr = nullptr;
EglDestroyImageKhrProc destroy_image_khr = nullptr;
GlImageTargetTextureProc image_target_texture = nullptr;
EGLImageKHR Create(EGLDisplay display, EGLContext context, EGLClientBuffer buffer) const;
bool Destroy(EGLDisplay display, EGLImageKHR image) const;
explicit operator bool() const;
};
bool ValidateGpuBootstrapProbe(const GpuBootstrapProbe& probe, std::string* error);
bool ResolveGpuImageDispatch(EGLDisplay display, GpuImageDispatch* dispatch, std::string* error);
} // namespace mpv
#endif // MPV_GPU_BOOTSTRAP_H_
-129
View File
@@ -1,129 +0,0 @@
#include "mpv_gpu_bootstrap.h"
#include <iostream>
#include <string>
namespace {
int create_calls = 0;
int destroy_calls = 0;
int failures = 0;
void Expect(bool condition, const char* expression, int line) {
if (condition) return;
std::cerr << "line " << line << ": check failed: " << expression << '\n';
++failures;
}
#define EXPECT(condition) Expect(static_cast<bool>(condition), #condition, __LINE__)
EGLImageKHR CreateImageKhr(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*) {
++create_calls;
return reinterpret_cast<EGLImageKHR>(0x1234);
}
EGLBoolean DestroyImageKhr(EGLDisplay, EGLImageKHR image) {
EXPECT(image == reinterpret_cast<EGLImageKHR>(0x1234));
++destroy_calls;
return EGL_TRUE;
}
void BindImage(GLenum, GLeglImageOES) {}
template <typename Function>
void* Address(Function function) {
return reinterpret_cast<void*>(function);
}
mpv::GpuBootstrapProbe SupportedKhrProbe() {
mpv::GpuBootstrapProbe probe;
probe.egl_major = 1;
probe.egl_minor = 4;
probe.egl_extensions = "EGL_KHR_surfaceless_context EGL_KHR_image_base";
probe.gl_extensions = "GL_EXT_texture GL_OES_EGL_image";
probe.create_image_khr = Address(CreateImageKhr);
probe.destroy_image_khr = Address(DestroyImageKhr);
probe.image_target_texture = Address(BindImage);
return probe;
}
void TestKhrCapabilitiesFailClosed() {
std::string error;
auto probe = SupportedKhrProbe();
EXPECT(mpv::ValidateGpuBootstrapProbe(probe, &error));
EXPECT(error.empty());
probe.egl_extensions = "EGL_KHR_image_base";
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
probe = SupportedKhrProbe();
probe.egl_extensions = "EGL_KHR_surfaceless_context EGL_KHR_image";
EXPECT(mpv::ValidateGpuBootstrapProbe(probe, &error));
probe = SupportedKhrProbe();
probe.egl_extensions = "EGL_KHR_surfaceless_context EGL_KHR_image_suffix";
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
probe = SupportedKhrProbe();
probe.egl_extensions = "EGL_KHR_surfaceless_context";
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
probe = SupportedKhrProbe();
probe.gl_extensions = "GL_OES_EGL_image_external";
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
probe = SupportedKhrProbe();
probe.create_image_khr = nullptr;
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
probe = SupportedKhrProbe();
probe.destroy_image_khr = nullptr;
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
probe = SupportedKhrProbe();
probe.image_target_texture = nullptr;
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
}
void TestCoreCapabilities() {
std::string error;
auto probe = SupportedKhrProbe();
probe.egl_major = 1;
probe.egl_minor = 5;
probe.egl_extensions = "";
probe.create_image_khr = nullptr;
probe.destroy_image_khr = nullptr;
probe.create_image_core = Address(CreateImageKhr);
probe.destroy_image_core = Address(DestroyImageKhr);
EXPECT(mpv::ValidateGpuBootstrapProbe(probe, &error));
probe.create_image_core = nullptr;
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
probe = SupportedKhrProbe();
probe.egl_major = 0;
probe.egl_minor = 0;
probe.egl_extensions = "";
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
}
void TestDispatchChecksBeforeCalls() {
mpv::GpuImageDispatch dispatch;
EXPECT(!dispatch);
EXPECT(dispatch.Create(EGL_NO_DISPLAY, EGL_NO_CONTEXT, nullptr) == EGL_NO_IMAGE_KHR);
EXPECT(!dispatch.Destroy(EGL_NO_DISPLAY, reinterpret_cast<EGLImageKHR>(0x1234)));
EXPECT(create_calls == 0);
EXPECT(destroy_calls == 0);
dispatch.create_image_khr = CreateImageKhr;
dispatch.destroy_image_khr = DestroyImageKhr;
dispatch.image_target_texture = BindImage;
EXPECT(dispatch);
const auto image = dispatch.Create(EGL_NO_DISPLAY, EGL_NO_CONTEXT, nullptr);
EXPECT(image == reinterpret_cast<EGLImageKHR>(0x1234));
EXPECT(dispatch.Destroy(EGL_NO_DISPLAY, image));
EXPECT(create_calls == 1);
EXPECT(destroy_calls == 1);
}
} // namespace
int main() {
TestKhrCapabilitiesFailClosed();
TestCoreCapabilities();
TestDispatchChecksBeforeCalls();
return failures == 0 ? 0 : 1;
}
+585 -159
View File
@@ -4,15 +4,25 @@
#include <epoxy/gl.h>
#include <flutter_linux/flutter_linux.h>
#include <gdk/gdk.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#ifdef GDK_WINDOWING_WAYLAND
#include <gdk/gdkwayland.h>
#endif
#include <locale.h>
// EGL 1.5 names; EGL_KHR_create_context introduced the same values earlier.
// Declared here so the build does not depend on which EGL headers the distro
// ships - the runtime check is eglCreateContext refusing the attribute, which
// the caller already falls back from.
#ifndef EGL_CONTEXT_MAJOR_VERSION
#define EGL_CONTEXT_MAJOR_VERSION EGL_CONTEXT_CLIENT_VERSION
#endif
#ifndef EGL_CONTEXT_MINOR_VERSION
#define EGL_CONTEXT_MINOR_VERSION 0x30FB
#endif
#include <chrono>
#include <cstdint>
#include <cstring>
#include "sanitize_utf8.h"
@@ -27,6 +37,17 @@ bool EnsureProcessNumericLocale() {
return configured;
}
// Reply userdata for the runner's own `video-params` observation.
//
// Every Dart-facing observation takes its userdata from
// PropertyObservationRegistry, which hands out 1, 2, 3, … one per distinct
// property name and never resets the counter; the two audio observations below
// pass 0, which the registry also never hands out. UINT64_MAX is the one value
// the counter cannot reach without first wrapping — and a wrap would collide
// with those two just as surely, so the scheme already depends on it not
// happening.
constexpr uint64_t kVideoParamsUserdata = UINT64_MAX;
} // namespace
// Flutter on Linux uses EGL (OpenGL ES) for both X11 and Wayland.
@@ -193,15 +214,6 @@ bool TryReleaseNativeRenderTeardown(
return true;
}
bool TryReleaseRetainedNativeRenderContexts(
std::vector<NativeRenderTeardownResource>& resources, const NativeRenderTeardownOperations& operations) {
NativeRenderTeardownBatch batch;
batch.resources = std::move(resources);
const bool complete = TryReleaseNativeRenderTeardown(batch, operations);
resources = std::move(batch.resources);
return complete;
}
MpvPlayer::CallbackContext::Lease::Lease(CallbackContext* context, MpvPlayer* player)
: context_(context), player_(player) {}
@@ -272,21 +284,6 @@ MpvPlayer::MpvPlayer(bool audio_only)
MpvPlayer::~MpvPlayer() { Dispose(); }
bool MpvPlayer::HasRenderContext() const {
std::lock_guard<std::mutex> lock(native_mutex_);
return mpv_gl_ != nullptr;
}
EGLDisplay MpvPlayer::GetEglDisplay() const {
std::lock_guard<std::mutex> lock(native_mutex_);
return egl_display_;
}
EGLContext MpvPlayer::GetEglContext() const {
std::lock_guard<std::mutex> lock(native_mutex_);
return egl_context_;
}
bool MpvPlayer::IsInitialized() const {
std::lock_guard<std::mutex> lock(native_mutex_);
return mpv_ != nullptr && (audio_only_ || mpv_gl_ != nullptr);
@@ -336,10 +333,19 @@ bool MpvPlayer::Initialize() {
mpv_set_option_string(mpv_, "audio-fallback-to-null", "yes");
if (!audio_only_) {
// HDR tone mapping
mpv_set_option_string(mpv_, "tone-mapping", "auto");
mpv_set_option_string(mpv_, "target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(hdr_enabled_));
// hdr-compute-peak is nested under the same predicate as the tone-map pass -
// it runs exactly when the source's declared peak exceeds target-peak - so it
// costs nothing while the compositor owns tone mapping and gives
// content-adaptive peak detection once we own it.
//
// `tone-mapping` is deliberately *not* set here: it travels with the output
// description and is applied and withdrawn in RunPendingHdrOutput instead.
// See applied_tone_mapping_ in mpv_player.h for why it cannot be global.
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
// Declared by vo_gpu_next only, so inert for the render API this player
// runs. Set anyway so the startup value agrees with what a later
// `hdr-enabled` write puts here through SetHDREnabled.
mpv_set_option_string(mpv_, "target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(hdr_enabled_));
}
mpv_set_option_string(mpv_, "idle", "yes");
mpv_set_option_string(mpv_, "input-default-bindings", "no");
@@ -370,6 +376,17 @@ bool MpvPlayer::Initialize() {
mpv_set_wakeup_callback(mpv_, OnMpvWakeup, callback_context_.get());
mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING);
mpv_observe_property(mpv_, 0, "audio-device-list", MPV_FORMAT_NONE);
if (!audio_only_) {
// One node observation stands in for six blocking sub-property reads. The
// HDR decision runs on the GTK main thread and one of its callers fires on
// every seek, while libmpv's synchronous read hands the request to the core
// and waits for the playloop; every other property access here is async for
// exactly that reason.
//
// An audio-only core has no video-params to report, so it is not asked.
source_hdr_metadata_ = SourceHdrMetadata();
mpv_observe_property(mpv_, kVideoParamsUserdata, "video-params", MPV_FORMAT_NODE);
}
g_message("MPV: Initialization successful (%s)", audio_only_ ? "audio-only" : "render context deferred");
return true;
@@ -377,12 +394,12 @@ bool MpvPlayer::Initialize() {
void MpvPlayer::RetryPendingNativeTeardown() { NativeRenderTeardownQueue::Instance().Retry(); }
bool MpvPlayer::InitRenderContext() {
bool MpvPlayer::InitRenderContextForSurface(EGLDisplay display, EGLConfig config, EGLSurface surface, int depth_bits) {
RetryPendingNativeTeardown();
std::lock_guard<std::mutex> lock(native_mutex_);
if (audio_only_ || disposed_) {
g_warning("MPV: Render context requested for an unavailable player");
g_warning("MPV: Video-plane render context requested for an unavailable player");
return false;
}
if (mpv_gl_) return true;
@@ -390,47 +407,8 @@ bool MpvPlayer::InitRenderContext() {
g_warning("MPV: Cannot create render context - mpv not initialized");
return false;
}
const EGLDisplay flutter_display = eglGetCurrentDisplay();
const EGLContext flutter_context = eglGetCurrentContext();
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
const EGLenum previous_api = eglQueryAPI();
if (flutter_display == EGL_NO_DISPLAY || flutter_context == EGL_NO_CONTEXT || previous_api == EGL_NONE) {
g_warning("MPV: No EGL context available");
return false;
}
auto restore_flutter = [&]() {
const EGLBoolean api_restored = previous_api == EGL_NONE ? EGL_TRUE : eglBindAPI(previous_api);
const EGLBoolean restored = api_restored == EGL_TRUE
? eglMakeCurrent(flutter_display, flutter_draw, flutter_read, flutter_context)
: EGL_FALSE;
return restored == EGL_TRUE && api_restored == EGL_TRUE;
};
if (!retained_render_contexts_.empty()) {
const bool released =
TryReleaseRetainedNativeRenderContexts(retained_render_contexts_, ProductionTeardownOperations());
const bool flutter_restored = restore_flutter();
if (!released) {
g_warning("MPV: Retained render context still requires a later EGL teardown retry");
}
if (!flutter_restored) {
g_warning("MPV: Failed to restore Flutter EGL state after retained teardown: 0x%x", eglGetError());
}
if (!released || !flutter_restored) return false;
}
EGLint config_id = 0;
if (!eglQueryContext(flutter_display, flutter_context, EGL_CONFIG_ID, &config_id)) {
g_warning("MPV: Failed to query Flutter EGL config: 0x%x", eglGetError());
return false;
}
EGLConfig config = nullptr;
EGLint num_configs = 0;
const EGLint config_attribs[] = {EGL_CONFIG_ID, config_id, EGL_NONE};
if (!eglChooseConfig(flutter_display, config_attribs, &config, 1, &num_configs) || num_configs != 1) {
g_warning("MPV: Failed to select Flutter EGL config: 0x%x", eglGetError());
if (display == EGL_NO_DISPLAY || surface == EGL_NO_SURFACE) {
g_warning("MPV: Video plane provided no usable EGL display or surface");
return false;
}
if (!eglBindAPI(EGL_OPENGL_ES_API)) {
@@ -438,42 +416,104 @@ bool MpvPlayer::InitRenderContext() {
return false;
}
const EGLint context_attribs[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
EGLContext candidate_context = eglCreateContext(flutter_display, config, EGL_NO_CONTEXT, context_attribs);
if (candidate_context == EGL_NO_CONTEXT) {
g_warning("MPV: Failed to create isolated EGL context: 0x%x", eglGetError());
if (previous_api != EGL_NONE && !eglBindAPI(previous_api)) {
g_warning("MPV: Failed to restore EGL client API: 0x%x", eglGetError());
// Nothing is shared with Flutter here, so take the highest ES version the
// driver will give. EGL_CONTEXT_CLIENT_VERSION=3 asks for exactly 3.0, which
// reads as "ES 3" while being the oldest of them; 3.2 brings float render
// targets (mpv picks an rgba16f FBO on this path) and ES 3.1 semantics for
// the rest.
//
// It does **not** buy compute shaders. mpv refuses them on any GLES context at
// any version, by construction (`video/out/opengl/ra_gl.c:136-139` in v0.40.0):
//
// // While we can handle compute shaders on GLES the spec (intentionally)
// // does not support binding textures for writing, which all uses inside
// // mpv would require. So disable it unconditionally anyway.
// if (ra->glsl_es) ra->caps &= ~RA_CAP_COMPUTE;
//
// So `hdr-compute-peak` is unreachable through the render API here however
// new the context is - confirmed on hardware with an ES 3.2 context whose
// glDispatchCompute and glBindImageTexture both resolve, where mpv still
// logs "Disabling HDR peak computation (compute shaders=0)". The player-side
// tone map therefore aims at the peak the source declares rather than one
// measured from the frames. Reaching it needs a desktop-GL context on the
// plane, which is the same architectural door as libplacebo and belongs with
// it rather than in a version bump.
//
// EGL_CONTEXT_MINOR_VERSION needs EGL 1.5 or EGL_KHR_create_context. Where
// neither is present eglCreateContext rejects the attribute outright, so the
// legacy CLIENT_VERSION-only request stays as the floor rather than letting
// a missing extension fail context creation altogether.
struct EsVersion {
EGLint major;
EGLint minor;
};
static constexpr EsVersion kPreferredEsVersions[] = {{3, 2}, {3, 1}, {3, 0}, {2, 0}};
EGLContext candidate_context = EGL_NO_CONTEXT;
EGLint chosen_major = 0;
EGLint chosen_minor = 0;
for (const EsVersion& version : kPreferredEsVersions) {
const EGLint context_attribs[] = {
EGL_CONTEXT_MAJOR_VERSION, version.major, EGL_CONTEXT_MINOR_VERSION, version.minor, EGL_NONE};
candidate_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attribs);
if (candidate_context != EGL_NO_CONTEXT) {
chosen_major = version.major;
chosen_minor = version.minor;
break;
}
}
if (candidate_context == EGL_NO_CONTEXT) {
for (const EGLint client_version : {3, 2}) {
const EGLint context_attribs[] = {EGL_CONTEXT_CLIENT_VERSION, client_version, EGL_NONE};
candidate_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attribs);
if (candidate_context != EGL_NO_CONTEXT) {
chosen_major = client_version;
chosen_minor = 0;
break;
}
}
}
if (candidate_context == EGL_NO_CONTEXT) {
g_warning("MPV: Failed to create the video-plane EGL context: 0x%x", eglGetError());
return false;
}
// Report the requested version, not the delivered one - those are different
// claims, and the delivered one is logged below once a context is current.
g_message("MPV video plane: requested OpenGL ES %d.%d", chosen_major, chosen_minor);
auto destroy_candidate_context = [&]() {
const EGLenum api_before_cleanup = eglQueryAPI();
if (eglGetCurrentContext() == candidate_context) {
if (!eglBindAPI(EGL_OPENGL_ES_API) ||
!eglMakeCurrent(flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
g_warning("MPV: Failed to release rejected EGL context: 0x%x", eglGetError());
return;
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
}
}
if (!eglDestroyContext(flutter_display, candidate_context)) {
g_warning("MPV: Failed to destroy rejected EGL context: 0x%x", eglGetError());
}
if (api_before_cleanup != EGL_NONE && !eglBindAPI(api_before_cleanup)) {
g_warning("MPV: Failed to restore EGL API after context cleanup: 0x%x", eglGetError());
if (!eglDestroyContext(display, candidate_context)) {
g_warning("MPV: Failed to destroy rejected video-plane EGL context: 0x%x", eglGetError());
}
};
if (!eglMakeCurrent(flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, candidate_context)) {
g_warning("MPV: Failed to activate isolated EGL context: 0x%x", eglGetError());
if (!eglMakeCurrent(display, surface, surface, candidate_context)) {
g_warning("MPV: Failed to activate the video-plane EGL context: 0x%x", eglGetError());
destroy_candidate_context();
if (previous_api != EGL_NONE && !eglBindAPI(previous_api)) {
g_warning("MPV: Failed to restore EGL client API: 0x%x", eglGetError());
}
return false;
}
// What the driver actually gave, and whether mpv will find the entry points
// its compute path needs. Asking for a version is not the same as getting
// it, and mpv's own report of "compute shaders=0" says nothing about which
// half is missing. Both are cheap and both were needed to diagnose this.
const GLubyte* gl_version = glGetString(GL_VERSION);
g_message(
"MPV video plane: GL_VERSION='%s' dispatch_compute=%s image_load_store=%s",
gl_version ? reinterpret_cast<const char*>(gl_version) : "(null)",
eglGetProcAddress("glDispatchCompute") ? "yes" : "no", eglGetProcAddress("glBindImageTexture") ? "yes" : "no");
// Now that a context is current, the surface's swap interval can be set.
// eglSwapBuffers runs on the GTK main thread and must never block: at the
// default interval Mesa throttles it on the compositor's frame callback,
// which an occluded surface never receives. The plane paces itself with its
// own frame callback instead.
if (!eglSwapInterval(display, 0)) {
g_warning("MPV: could not disable EGL swap throttling on the video plane: 0x%x", eglGetError());
}
mpv_opengl_init_params gl_init_params{};
gl_init_params.get_proc_address = get_opengl_proc_address;
gl_init_params.get_proc_address_ctx = nullptr;
@@ -484,53 +524,71 @@ bool MpvPlayer::InitRenderContext() {
{MPV_RENDER_PARAM_INVALID, nullptr},
};
GdkDisplay* gdk_display = gdk_display_get_default();
// The plane only exists on Wayland, and hwdec interop wants the display handle:
// without it VAAPI has to find a device by other means and can quietly end up
// on software decoding, on the path that exists for performance.
#ifdef GDK_WINDOWING_WAYLAND
GdkDisplay* gdk_display = gdk_display_get_default();
if (GDK_IS_WAYLAND_DISPLAY(gdk_display)) {
params[2].type = MPV_RENDER_PARAM_WL_DISPLAY;
params[2].data = gdk_wayland_display_get_wl_display(gdk_display);
}
#endif
#ifdef GDK_WINDOWING_X11
if (GDK_IS_X11_DISPLAY(gdk_display)) {
params[2].type = MPV_RENDER_PARAM_X11_DISPLAY;
params[2].data = gdk_x11_display_get_xdisplay(gdk_display);
}
#endif
mpv_render_context* candidate_gl = nullptr;
const int error = mpv_render_context_create(&candidate_gl, mpv_, params);
const bool restored = restore_flutter();
if (error < 0 || candidate_gl == nullptr || !restored) {
if (error < 0) {
g_warning("MPV: mpv_render_context_create() failed: %s", mpv_error_string(error));
} else if (!restored) {
g_warning("MPV: Failed to restore Flutter EGL state: 0x%x", eglGetError());
} else {
g_warning("MPV: mpv returned a null render context");
}
bool retained_candidate = false;
if (candidate_gl) {
if (eglMakeCurrent(flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, candidate_context)) {
mpv_render_context_free(candidate_gl);
} else {
g_warning("MPV: Failed to reactivate rejected EGL context: 0x%x; retaining it for teardown", eglGetError());
retained_render_contexts_.push_back({candidate_gl, flutter_display, candidate_context});
retained_candidate = true;
}
if (!restore_flutter()) {
g_warning("MPV: Failed final Flutter EGL restoration: 0x%x", eglGetError());
}
}
if (!retained_candidate) destroy_candidate_context();
if (error < 0 || candidate_gl == nullptr) {
g_warning("MPV: mpv_render_context_create() failed for the video plane: %s", mpv_error_string(error));
if (candidate_gl) mpv_render_context_free(candidate_gl);
destroy_candidate_context();
return false;
}
egl_display_ = flutter_display;
egl_display_ = display;
egl_context_ = candidate_context;
surface_depth_bits_ = depth_bits > 0 ? depth_bits : 8;
mpv_gl_ = candidate_gl;
mpv_render_context_set_update_callback(mpv_gl_, OnMpvRenderUpdate, callback_context_.get());
g_message("MPV: Render context created with isolated EGL context");
g_message("MPV: Render context created on the Wayland video plane");
return true;
}
bool MpvPlayer::RenderToSurface(EGLSurface surface, int width, int height) {
std::lock_guard<std::mutex> lock(native_mutex_);
if (disposed_ || !mpv_gl_ || egl_context_ == EGL_NO_CONTEXT || surface == EGL_NO_SURFACE) return false;
if (width < 1 || height < 1) return false;
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(egl_display_, surface, surface, egl_context_)) {
g_warning("MPV: Failed to activate the video-plane EGL context for render: 0x%x", eglGetError());
return false;
}
// Consume the redraw latch before rendering: OnMpvRenderUpdate drops further
// notifications until it is cleared.
needs_redraw_.store(false);
mpv_opengl_fbo mpv_fbo{};
mpv_fbo.fbo = 0; // the window surface's default framebuffer
mpv_fbo.w = width;
mpv_fbo.h = height;
// Ignored by the render API's OpenGL backend, which reads the depth param
// instead, but it is what mpv#16818's gpu-next backend will read, so state
// it truthfully rather than leave a lie in place for that day.
mpv_fbo.internal_format = surface_depth_bits_ >= 16 ? GL_RGBA16F : surface_depth_bits_ >= 10 ? GL_RGB10_A2 : GL_RGBA8;
// The default framebuffer is bottom-up relative to mpv's image orientation,
// so this flips.
int flip_y = 1;
// Without this mpv assumes 8 bits and dithers a 10-bit PQ plane down to 8,
// which bands precisely in the dark ramp PQ spends most of its code space on.
int depth = surface_depth_bits_;
mpv_render_param params[] = {
{MPV_RENDER_PARAM_OPENGL_FBO, &mpv_fbo},
{MPV_RENDER_PARAM_FLIP_Y, &flip_y},
{MPV_RENDER_PARAM_DEPTH, &depth},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context_render(mpv_gl_, params);
return true;
}
@@ -563,6 +621,7 @@ void MpvPlayer::Dispose() {
std::lock_guard<std::mutex> lock(callback_mutex_);
redraw_callback_ = nullptr;
event_callback_ = nullptr;
source_metadata_callback_ = nullptr;
}
auto cancelled = pending_requests_.CancelAll();
@@ -575,13 +634,27 @@ void MpvPlayer::Dispose() {
RemoveTrackedSources();
// Transfer every render/context pair and the shared mpv handle to the
// managed teardown thread. A failed EGL bind leaves the complete pair in
// The plane's context is left current on this thread by RenderToSurface and
// nothing else releases it before the video surface is destroyed - which the
// plugin does *after* this call. An EGLContext can be current to at most one
// thread, so handing it to the teardown worker while it is still bound here
// makes the worker's eglMakeCurrent fail with EGL_BAD_ACCESS; the pair is then
// retained, and by the note below the mpv handle cannot be terminated until
// every pair drains. Repeated open/close would carry a whole stale mpv core
// across each gap. Only our own context is released: Flutter's must be left
// exactly where it is.
if (egl_context_ != EGL_NO_CONTEXT && eglGetCurrentContext() == egl_context_) {
if (!eglMakeCurrent(egl_display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
g_warning("MPV: Failed to release the video-plane EGL context before teardown: 0x%x", eglGetError());
}
}
// Transfer the render context, the EGL context and the shared mpv handle to
// the managed teardown thread. A failed EGL bind leaves the complete pair in
// the queue; the handle cannot be terminated until every pair is gone.
NativeRenderTeardownBatch teardown;
{
std::lock_guard<std::mutex> lock(native_mutex_);
teardown.resources = std::move(retained_render_contexts_);
if (mpv_gl_ || egl_context_ != EGL_NO_CONTEXT) {
teardown.resources.push_back({mpv_gl_, egl_display_, egl_context_});
}
@@ -591,31 +664,14 @@ void MpvPlayer::Dispose() {
mpv_ = nullptr;
egl_display_ = EGL_NO_DISPLAY;
egl_context_ = EGL_NO_CONTEXT;
// The next player must not decide against this one's colour space.
source_hdr_metadata_ = SourceHdrMetadata();
}
NativeRenderTeardownQueue::Instance().Enqueue(std::move(teardown));
observed_properties_.Clear();
}
void MpvPlayer::Render(int width, int height, int fbo) {
std::lock_guard<std::mutex> lock(native_mutex_);
if (disposed_ || !mpv_gl_) return;
mpv_opengl_fbo mpv_fbo{};
mpv_fbo.fbo = fbo;
mpv_fbo.w = width;
mpv_fbo.h = height;
mpv_fbo.internal_format = 0;
int flip_y = 0;
mpv_render_param params[] = {
{MPV_RENDER_PARAM_OPENGL_FBO, &mpv_fbo},
{MPV_RENDER_PARAM_FLIP_Y, &flip_y},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context_render(mpv_gl_, params);
}
void MpvPlayer::Command(const std::vector<std::string>& args) { CommandAsync(args, nullptr); }
void MpvPlayer::CommandAsync(const std::vector<std::string>& args, CommandCallback callback) {
@@ -632,6 +688,14 @@ void MpvPlayer::SetProperty(const std::string& name, const std::string& value) {
}
void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& value, StatusCallback callback) {
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
// Ahead of the handle check: a substituted writer stands in for the core, so
// the absence of a real one is not a reason to refuse the write.
if (test_property_write_) {
test_property_write_(name, value, std::move(callback));
return;
}
#endif
if (disposed_ || !mpv_) {
if (callback) callback(MPV_ERROR_UNINITIALIZED);
return;
@@ -644,6 +708,35 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val
plezy::mpv_common::SubmitSetPropertyAsync(mpv_, pending_requests_, name, value, std::move(callback));
}
bool MpvPlayer::ReadSourceHdrMetadata(SourceHdrMetadata* out) {
if (out == nullptr) return false;
std::lock_guard<std::mutex> lock(native_mutex_);
if (disposed_ || !mpv_) return false;
*out = source_hdr_metadata_;
return true;
}
void MpvPlayer::UpdateSourceHdrMetadata(const mpv_node* params) {
{
std::lock_guard<std::mutex> lock(native_mutex_);
source_hdr_metadata_ = ParseSourceHdrMetadata(params);
}
// Outside the lock on purpose: the callback's whole job is to re-run the HDR
// decision, which reads the cache straight back through ReadSourceHdrMetadata.
SourceMetadataCallback callback;
{
std::lock_guard<std::mutex> lock(callback_mutex_);
callback = source_metadata_callback_;
}
if (callback) callback();
}
void MpvPlayer::SetSourceMetadataCallback(SourceMetadataCallback callback) {
std::lock_guard<std::mutex> lock(callback_mutex_);
source_metadata_callback_ = std::move(callback);
}
void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) {
if (disposed_ || !mpv_) {
if (callback) callback(MPV_ERROR_UNINITIALIZED, "");
@@ -661,14 +754,6 @@ void MpvPlayer::ObserveProperty(const std::string& name, const std::string& form
mpv_observe_property(mpv_, request.userdata, name.c_str(), request.format);
}
void MpvPlayer::ReportMouseMove(int x, int y) {
if (disposed_ || !mpv_) return;
std::string x_str = std::to_string(x);
std::string y_str = std::to_string(y);
const char* args[] = {"mouse", x_str.c_str(), y_str.c_str(), nullptr};
mpv_command_async(mpv_, 0, args);
}
void MpvPlayer::SetEventCallback(EventCallback callback) {
std::lock_guard<std::mutex> lock(callback_mutex_);
event_callback_ = std::move(callback);
@@ -708,8 +793,8 @@ void MpvPlayer::OnMpvRenderUpdate(void* ctx) {
return;
}
// Flutter texture notification must run on the player's owning GLib
// context, never on mpv's render/VO thread.
// The redraw must run on the player's owning GLib context, never on mpv's
// render/VO thread.
player->ScheduleRedrawSource();
}
@@ -914,6 +999,15 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
if (!prop || !prop->name) break;
mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop);
// This runner's own observation, which nothing on the Dart side asked
// for and nothing there is waiting on. mpv delivers one event per
// observation, so a Dart-side observer of the same property still gets
// its own under its own userdata.
if (event->reply_userdata == kVideoParamsUserdata) {
UpdateSourceHdrMetadata(&node);
break;
}
const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop);
if (notice.message) LogRecovery(notice.message);
// Recovery runs off a GLib timer here, so newly queued work has to arm it.
@@ -924,6 +1018,15 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
}
case MPV_EVENT_END_FILE: {
audio_recovery_.SetFileLoaded(false);
// Whatever comes next is a different source until video-params says
// otherwise, and describing it against this one's colour space is the
// one failure worth a transient wrong answer to avoid. No re-apply is
// requested: the plane keeps the description it has until the next
// playback-restart or video-params change, exactly as before.
{
std::lock_guard<std::mutex> lock(native_mutex_);
source_hdr_metadata_ = SourceHdrMetadata();
}
auto* end = static_cast<mpv_event_end_file*>(event->data);
if (!end) break;
FlValue* data = fl_value_new_map();
@@ -1032,6 +1135,329 @@ void MpvPlayer::SendEvent(const std::string& name, FlValue* data) {
fl_value_unref(event_map);
}
void MpvPlayer::ApplyPropertySequence(
std::shared_ptr<std::vector<PropertyChange>> changes, size_t index, StatusCallback callback) {
if (changes == nullptr || index >= changes->size()) {
if (callback) callback(MPV_ERROR_SUCCESS);
return;
}
const PropertyChange& change = (*changes)[index];
SetPropertyAsync(change.name, change.value, [this, changes, index, cb = std::move(callback)](int error) mutable {
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
ApplyPropertySequence(changes, index + 1, std::move(cb));
return;
}
// `index` entries already landed and must come back, newest
// first, so a refused change leaves the previous state intact
// rather than a half-applied mixture of the two.
RollbackPropertySequence(changes, index, error, std::move(cb));
});
}
void MpvPlayer::RollbackPropertySequence(
std::shared_ptr<std::vector<PropertyChange>> changes, size_t undo_count, int failure, StatusCallback callback) {
if (changes == nullptr || undo_count == 0) {
// Only now is mpv genuinely back where it started, so only now may the caller
// hear about it. The original failure is what it needs, not the outcome of the
// unwinding.
if (callback) callback(failure);
return;
}
const size_t index = undo_count - 1;
const PropertyChange& change = (*changes)[index];
SetPropertyAsync(
change.name, change.rollback, [this, changes, index, failure, cb = std::move(callback)](int error) mutable {
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
RollbackPropertySequence(changes, index, failure, std::move(cb));
return;
}
// Carrying on would leave mpv in a state that is neither the
// old one nor the new, and *no* surface description is correct
// for a signal nobody can name. Escalate to the one state that
// is always describable and always accepts its value: SDR.
g_warning(
"MPV: could not restore %s while unwinding a refused output colour space; "
"forcing SDR",
(*changes)[index].name.c_str());
ForceSdrOutput(0, failure, std::move(cb));
});
}
// The one place the applied-output cache is written, so every path that moves a
// property records it the same way. Matched by name, not position: the order the
// sequences use is load-bearing and has to stay free to change without silently
// reassigning the wrong field.
void MpvPlayer::RecordAppliedOutputProperty(const std::string& name, const std::string& value) {
if (name == "target-peak") {
applied_target_peak_ = value;
} else if (name == "target-prim") {
applied_target_prim_ = value;
} else if (name == "target-trc") {
applied_target_trc_ = value;
} else if (name == "tone-mapping") {
applied_tone_mapping_ = value;
}
}
void MpvPlayer::ForceSdrOutput(size_t index, int failure, StatusCallback callback) {
// Same order the apply path uses: the transfer function stops asking for HDR
// before the primaries, operator and peak follow it back.
static const char* const kResetOrder[] = {"target-trc", "target-prim", "tone-mapping", "target-peak"};
constexpr size_t kResetCount = sizeof(kResetOrder) / sizeof(kResetOrder[0]);
if (index >= kResetCount) {
// mpv is SDR now, not back where it started, so any HDR description the
// caller has already committed is a lie about these pixels.
hdr_unwind_result_ = HdrOutputResult::kForcedSdr;
output_state_known_ = true;
if (callback) callback(failure);
return;
}
SetPropertyAsync(kResetOrder[index], "auto", [this, index, failure, cb = std::move(callback)](int error) mutable {
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
// Recorded as it lands, not once the whole reset is through. Recording
// only at the end would leave the cache naming the pre-reset curve for
// every property that did move if a later one is refused, and the no-op
// short-circuit would then answer a repeat request from it - committing an
// HDR description over pixels mpv had already reset to SDR.
RecordAppliedOutputProperty(kResetOrder[index], "auto");
ForceSdrOutput(index + 1, failure, std::move(cb));
return;
}
// `auto` is valid for every one of them, so this failing means mpv is
// no longer taking orders at all - usually because it is being
// disposed. Either way what it emits is now unknowable, and the
// caller must stop presenting the plane rather than guess.
hdr_unwind_result_ = HdrOutputResult::kUnknown;
// And the cache is now a record of what we *asked* for, not what mpv holds:
// some of the reset landed and some did not. Marking it untrusted is what
// stops the short-circuit skipping a later write on the strength of it. The
// strings are left alone deliberately - they are still the best rollback
// targets available if a later sequence gets that far.
output_state_known_ = false;
g_warning(
"MPV: output colour space is no longer commandable; what the plane emits "
"is unknown");
if (cb) cb(failure);
});
}
bool MpvPlayer::CanCommandOutputProperties() const {
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
// The substituted writer is the core here; see SetPropertyAsync, which routes
// to it ahead of the same handle check.
if (test_property_write_) return true;
#endif
return !disposed_ && mpv_ != nullptr;
}
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
void MpvPlayer::ConfigurePropertyWritesForTesting(PropertyWriteForTesting writer) {
test_property_write_ = std::move(writer);
}
MpvPlayer::AppliedOutputColourSpace MpvPlayer::AppliedOutputColourSpaceForTesting() const {
return {applied_target_trc_, applied_target_prim_, applied_tone_mapping_, applied_target_peak_};
}
#endif
void MpvPlayer::SetHdrOutput(SourceTransfer transfer, uint32_t target_peak_nits, HdrOutputCallback callback) {
if (!CanCommandOutputProperties()) {
// The third place a result is named, and it owes the same honesty as the
// other two: nothing was touched, so the previous state stands - which is
// only worth saying when that state is nameable. Otherwise a request that
// was already queued when the core went away is answered kUnknown by the
// drain while an identical one arriving a moment later hears kRestored.
if (callback) {
callback(output_state_known_ ? HdrOutputResult::kRestored : HdrOutputResult::kUnknown, MPV_ERROR_UNINITIALIZED);
}
return;
}
// Requests are serialized, and queued rather than coalesced.
//
// Playback restarts, preferred-description changes and the two settings can
// each ask for a new output colour space, and every step of a sequence
// completes asynchronously. Two overlapping sequences would interleave: a
// failure in the older one would issue rollbacks that overwrite properties the
// newer one had already set, while the newer one still reported success and
// recorded values mpv no longer holds. That is the divergence the sequencing
// exists to prevent.
//
// Each request keeps its own callback instead of being folded into the newest
// one, because callers commit their own state on success: telling a caller its
// change landed when a *different* request is what actually landed reintroduces
// the same divergence one level up. Strict ordering then makes the bookkeeping
// trivial — the last request to succeed is exactly what mpv holds, so nobody
// needs an epoch to work out whether their commit is still current.
hdr_queue_.push_back(HdrOutputRequest{transfer, target_peak_nits, std::move(callback)});
if (hdr_sequence_in_flight_) return;
RunPendingHdrOutput();
}
void MpvPlayer::RunPendingHdrOutput() {
if (!CanCommandOutputProperties()) {
hdr_sequence_in_flight_ = false;
auto orphaned = std::move(hdr_queue_);
hdr_queue_.clear();
for (auto& request : orphaned) {
// Nothing was touched, so the previous state - whatever it was - still
// stands as far as this request is concerned. Which is only worth telling
// the caller when that state is nameable; if the last unwind gave up
// halfway, "unchanged" describes a colour space nobody knows.
if (request.callback) {
request.callback(
output_state_known_ ? HdrOutputResult::kRestored : HdrOutputResult::kUnknown, MPV_ERROR_UNINITIALIZED);
}
}
return;
}
if (hdr_queue_.empty()) {
hdr_sequence_in_flight_ = false;
return;
}
HdrOutputRequest request = std::move(hdr_queue_.front());
hdr_queue_.pop_front();
hdr_sequence_in_flight_ = true;
// Assume a clean unwind; the escalation path in RollbackPropertySequence and
// ForceSdrOutput moves this on if it cannot manage one.
hdr_unwind_result_ = HdrOutputResult::kRestored;
// Four properties describe one output colour space, so they are applied as a
// unit. A plane whose primaries moved to BT.2020 while its transfer function
// stayed on gamma is neither SDR nor HDR, and the caller describes the surface
// to the compositor on success — a silently half-applied set would have the
// compositor told one thing and shown another.
//
// target-peak is what mpv maps to. Under PQ, left on auto it resolves to the
// format's nominal 10000 nits, so the renderer never tone-maps and the
// compositor owns the decision. Set to the display's real peak, mpv tone-maps
// to it and the caller declares that same peak, leaving the compositor nothing
// to do.
//
// On the SDR fallback the peak and curve are named for accuracy, not to make
// tone mapping happen: mpv 0.40 already resolves target-peak=auto to 203 nits
// and target-trc=auto to gamma 2.2 for an SDR curve, and measurement confirmed
// naming them changed the shadows but not the highlights. What they buy is the
// surface's real terms instead of assumed ones - the compositor's own reference
// white, and sRGB, which is what an undescribed Wayland surface is and what
// this compositor's preferred description for the output says. Hence no
// `enabled` in the peak condition below: an SDR peak is a real instruction, not
// a leftover from an HDR request.
const bool enabled = request.transfer != SourceTransfer::kSdr;
const char* primaries = enabled ? "bt.2020" : "auto";
// The option is an integer in [10, 10000]; anything outside means "auto".
const bool tone_map_here = request.peak_nits >= 10 && request.peak_nits <= kPqMaxLuminanceNits;
const std::string peak = tone_map_here ? std::to_string(request.peak_nits) : std::string("auto");
const char* curve = request.transfer == SourceTransfer::kHlg
? "hlg"
: (request.transfer == SourceTransfer::kPq ? "pq" : (tone_map_here ? "srgb" : "auto"));
// The operator only matters while a tone-map pass runs, and it must go back to
// auto when one does not; see applied_tone_mapping_ in mpv_player.h.
//
// Restricted to the undescribed SDR target, which is where it was measured.
// Player-side mapping onto an HDR output aims at a PQ target instead, and
// nothing has been measured there yet - that needs the external display - so
// it keeps mpv's own choice until it can be judged the same way.
//
// mobius's shape is governed by tone-mapping-param, its transition point: below
// it the curve is 1:1, above it rolls off. Left at mpv's default 0.3 because
// that measured best, not by omission. Raising it trades highlight shoulder for
// in-range luminance, and against libplacebo's rendering of the same chart
// (400/700/1000 -> 238.5/253.8/254.8, 100 nits -> 134.0) the default is closest
// on both counts, with higher values moving away on each:
//
// param 100 nits 400->1000 span
// 0.30 179.0 17.1
// 0.45 184.4 12.1
// 0.60 185.0 7.2
//
// It also does not touch the cost this operator carries. On real 1000-nit
// footage mobius sits 0.027 dxy and ~12% darker than BT.2390 whatever the
// transition point is (0.30/0.38/0.45 measured identical), because that
// difference is gamut handling rather than the tone curve, and a dark scene's
// pixels fall below the transition point in every case.
//
// Not pinned explicitly: the option has no accepted "unset" token - `default`
// is rejected - so writing it would leave a mobius-specific value applied to
// whatever operator runs next, including BT.2390 on the unmeasured HDR-output
// path. Recorded here instead so an upstream default change is diagnosable.
const char* operator_name = (tone_map_here && !enabled) ? "mobius" : "auto";
// playback-restart drives a re-apply and fires on every seek, so most calls
// here ask for the state mpv already holds. The plane's own half already
// short-circuits an identical request; this is the other half. Without it a
// seek costs four property round-trips and a log line saying nothing changed,
// and holds the sequence long enough to defer a real request behind it.
//
// kApplied, because that is the truth the caller acts on: these four values
// *are* in force, so a surface description committed against them stays
// honest. The queue has to keep draining from here exactly as it does on the
// applied path, or a coalesced request behind this one never runs. Unlike that
// path the call is a real recursion rather than a fresh stack, which is fine
// because the plugin coalesces reapplies into a single pending flag: the queue
// holds the one in flight plus at most one waiting.
// output_state_known_ first: the comparison is only meaningful while the cache
// is a record of what mpv holds. A forced-SDR reset that was itself refused
// partway leaves it a record of what was *asked* for, and skipping on that
// would report kApplied for a colour space mpv is not in - which the caller
// then commits an image description against.
if (output_state_known_ && applied_target_trc_ == curve && applied_target_prim_ == primaries &&
applied_tone_mapping_ == operator_name && applied_target_peak_ == peak) {
if (request.callback) request.callback(HdrOutputResult::kApplied, 0);
RunPendingHdrOutput();
return;
}
// The values that decide who tone-maps and against what, none of which is
// visible on screen: two very different curves both look like working video.
// Logged next to the plane's own decisions so a capture can be matched to the
// state that produced it.
g_message(
"MPV: output colour target peak=%s prim=%s trc=%s tone-mapping=%s", peak.c_str(), primaries, curve,
operator_name);
// Dependencies first, peak last, matching the order kResetOrder uses. The peak
// is what decides whether a tone-map pass runs at all, so everything that pass
// depends on is in place before it is named.
auto changes = std::make_shared<std::vector<PropertyChange>>();
changes->push_back({"target-trc", curve, applied_target_trc_});
changes->push_back({"target-prim", primaries, applied_target_prim_});
changes->push_back({"tone-mapping", operator_name, applied_tone_mapping_});
changes->push_back({"target-peak", peak, applied_target_peak_});
ApplyPropertySequence(changes, 0, [this, changes, callback = std::move(request.callback)](int error) {
const bool ok = plezy::mpv_common::SetPropertyStatusSucceeded(error);
// Only a fully applied set becomes the new rollback target; a failed one was
// already unwound, and the unwinding updated these itself if it had to force
// SDR.
if (ok && !disposed_) {
for (const PropertyChange& change : *changes) {
RecordAppliedOutputProperty(change.name, change.value);
}
// A clean apply is the one outcome that leaves mpv exactly where the cache
// says, so it is what re-earns the short-circuit's trust after an unwind
// gave up halfway.
output_state_known_ = true;
}
// This request's own outcome, to this request's own caller. The result names
// what mpv is actually in now, which is what decides whether the caller's
// committed surface description is still true.
//
// kRestored says "mpv is where it was", which only reassures the caller while
// where it was is known. After an unwind that gave up halfway it is not: a
// sequence refused on its very first write unwinds nothing, so it reports the
// untouched kRestored while mpv sits in the half-reset state nobody can name.
// The caller would read that as "your description still holds" and put an
// undescribed plane back on screen. Downgrading to kUnknown is the honest
// answer, and a clean apply - the one thing that re-earns the trust - is
// reported as kApplied above regardless.
const HdrOutputResult result =
ok ? HdrOutputResult::kApplied : (output_state_known_ ? hdr_unwind_result_ : HdrOutputResult::kUnknown);
if (callback) callback(result, error);
// Whatever arrived while this ran runs now — never alongside.
RunPendingHdrOutput();
});
}
void MpvPlayer::SetHDREnabled(bool enabled, StatusCallback callback) {
SetPropertyAsync(
"target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(enabled),
+213 -28
View File
@@ -10,6 +10,7 @@
#include <atomic>
#include <condition_variable>
#include <deque>
#include <functional>
#include <memory>
#include <mutex>
@@ -19,6 +20,8 @@
#include <vector>
#include "../../../shared/mpv/mpv_player_common.h"
#include "hdr_metadata.h"
#include "video_params.h"
// Forward declaration for Flutter types
struct _FlValue;
@@ -59,11 +62,6 @@ struct NativeRenderTeardownOperations {
// later retry, and |handle| is never terminated while any resource remains.
bool TryReleaseNativeRenderTeardown(NativeRenderTeardownBatch& batch, const NativeRenderTeardownOperations& operations);
// Releases render contexts retained by a failed initialization attempt. A
// false result must block another render-context creation on the same core.
bool TryReleaseRetainedNativeRenderContexts(
std::vector<NativeRenderTeardownResource>& resources, const NativeRenderTeardownOperations& operations);
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
// Focused-test boundary for exercising the process-lifetime teardown queue
// without invoking real EGL or libmpv resources.
@@ -76,31 +74,34 @@ void EnqueueNativeRenderTeardownForTesting(NativeRenderTeardownBatch batch);
class MpvPlayer {
public:
/// |audio_only| runs mpv as a music core with video disabled entirely:
/// no render context is ever created (InitRenderContext must not be
/// called) and no GL/EGL state is touched.
/// no render context is ever created (InitRenderContextForSurface must not
/// be called) and no GL/EGL state is touched.
explicit MpvPlayer(bool audio_only = false);
~MpvPlayer();
/// Initializes the mpv instance and configures options.
/// Does NOT create the render context — call InitRenderContext() later
/// when an OpenGL context is available.
/// Does NOT create the render context — call InitRenderContextForSurface()
/// once the video plane's EGL surface exists.
/// @return true if initialization succeeded.
bool Initialize();
/// Creates the mpv OpenGL render context.
/// Must be called with a valid GL context current (e.g., from FlTextureGL::populate).
/// Fails on audio-only players.
/// Creates the mpv render context bound to the app-owned EGL window surface
/// backing the Wayland video plane. This is the only render path: nothing
/// here is shared with or derived from Flutter's GL state, so the context is
/// free to be ES 3.x.
///
/// `depth_bits` is the plane's bits per colour channel. mpv takes the
/// target's precision from MPV_RENDER_PARAM_DEPTH and from nothing else —
/// the render API's OpenGL backend ignores mpv_opengl_fbo::internal_format —
/// and assumes 8 when it is absent, which would dither a PQ plane to 8 bits
/// and band it exactly where the 10-bit config was chosen to avoid that.
/// @return true if render context creation succeeded.
bool InitRenderContext();
bool InitRenderContextForSurface(EGLDisplay display, EGLConfig config, EGLSurface surface, int depth_bits);
/// Returns true if the render context has been created.
bool HasRenderContext() const;
/// Returns the isolated EGL display used for mpv rendering.
EGLDisplay GetEglDisplay() const;
/// Returns the isolated EGL context used for mpv rendering.
EGLContext GetEglContext() const;
/// Renders one frame into |surface|'s default framebuffer. The caller
/// presents it (eglSwapBuffers) once this returns.
/// @return true if the frame was rendered.
bool RenderToSurface(EGLSurface surface, int width, int height);
/// Disposes mpv and releases resources.
void Dispose();
@@ -132,18 +133,100 @@ class MpvPlayer {
/// Sets an mpv property asynchronously.
void SetPropertyAsync(const std::string& name, const std::string& value, StatusCallback callback);
/// What became of an HDR output request. The caller has to distinguish these,
/// because each implies a different truth about the surface description it may
/// already have committed.
enum class HdrOutputResult {
/// mpv is in the requested colour space; the caller's new description is true.
kApplied,
/// Refused, and mpv is back in the colour space it had; the previously
/// committed description is still true and must be left alone.
kRestored,
/// Refused, and could not be put back, so it was forced to SDR. Any committed
/// HDR description is now a lie about the pixels and must be unset.
kForcedSdr,
/// Refused, and mpv no longer accepts even `auto`. What it emits is unknowable,
/// so no description is correct and the plane should not be presented.
kUnknown,
};
using HdrOutputCallback = std::function<void(HdrOutputResult, int)>;
/// Switches mpv's output colour space between HDR passthrough and its normal
/// tone-mapped SDR output.
///
/// `target-colorspace-hint` is deliberately not used: it is declared by
/// vo_gpu_next only, so the render API — which runs the legacy gpu renderer —
/// ignores it entirely. target-trc/target-prim are what that renderer reads.
///
/// `transfer` is the curve mpv should emit, taken from the source rather than
/// assumed: HLG content described to the compositor as HLG must also be
/// *encoded* as HLG. SourceTransfer::kSdr restores the tone-mapped output.
///
/// The output-description properties are applied as a unit, and on failure the
/// ones that landed are unwound — awaited, not fired and forgotten — so that by
/// the time the callback runs mpv is in exactly the state the result names.
///
/// `target_peak_nits` decides who tone-maps. Zero (or anything outside mpv's
/// 10..10000 range) leaves `target-peak` on auto, which under PQ resolves to
/// the format's nominal 10000 nits so the renderer passes the source through
/// untouched and the compositor tone-maps. A real display peak makes mpv
/// tone-map to it instead, and the caller should then declare that peak to the
/// compositor so it has nothing left to do.
void SetHdrOutput(SourceTransfer transfer, uint32_t target_peak_nits, HdrOutputCallback callback);
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
/// Focused-test boundary for the output-colour-space transaction above.
///
/// The whole ladder — apply, unwind, force SDR — is control flow over one
/// primitive: "set this property to this string, then call back with an mpv
/// error code". Substituting that primitive is what lets the sequence, its
/// ordering and its escalation be observed without libmpv or a compositor,
/// exactly as NativeRenderTeardownOperations substitutes EGL above.
///
/// Installing a writer also makes the output properties commandable with no
/// core present: the writer *is* the core as far as the ladder can tell, so
/// the usual "no handle, nothing to command" short-circuit would otherwise
/// answer every request before its first step ran.
using PropertyWriteForTesting =
std::function<void(const std::string& name, const std::string& value, StatusCallback callback)>;
void ConfigurePropertyWritesForTesting(PropertyWriteForTesting writer);
/// The output colour space mpv last accepted in full — the rollback target,
/// and what a caller's committed surface description is measured against.
struct AppliedOutputColourSpace {
std::string target_trc;
std::string target_prim;
std::string tone_mapping;
std::string target_peak;
};
AppliedOutputColourSpace AppliedOutputColourSpaceForTesting() const;
#endif
/// Copies the current source's colour space and HDR10 static metadata out of
/// the cache `video-params` fills. Returns false only when there is no player
/// to ask; a source that carries no metadata still fills in the transfer and
/// primaries names.
///
/// Synchronous and free: nothing here reaches the core, which is the point.
/// Every caller is on the GTK main thread and one of them runs on every seek.
bool ReadSourceHdrMetadata(SourceHdrMetadata* out);
/// Called on the main context whenever `video-params` changes, i.e. whenever
/// the cache above has just been rewritten.
///
/// This exists because the change is not ordered against playback-restart: a
/// reconfigure that lands after the restart would otherwise leave the HDR
/// decision standing on the previous file's colour space. The caller re-runs
/// its decision from here, so a late parse still converges.
using SourceMetadataCallback = std::function<void()>;
void SetSourceMetadataCallback(SourceMetadataCallback callback);
/// Gets an mpv property value asynchronously.
void GetPropertyAsync(const std::string& name, GetPropertyCallback callback);
/// Observes an mpv property for changes.
void ObserveProperty(const std::string& name, const std::string& format, int id);
/// Renders a frame to the specified FBO.
void Render(int width, int height, int fbo = 0);
/// Reports that the mouse has moved.
void ReportMouseMove(int x, int y);
/// Sets the event callback for property changes and events.
void SetEventCallback(EventCallback callback);
@@ -234,6 +317,12 @@ class MpvPlayer {
/// Sends a property change notification.
void SendPropertyChange(const char* name, mpv_node* data);
/// Reparses the `video-params` payload into source_hdr_metadata_ and tells
/// the source-metadata callback that it moved. The parse happens under
/// native_mutex_; the callback runs outside it, because what it goes on to do
/// reads the cache straight back.
void UpdateSourceHdrMetadata(const mpv_node* params);
/// Sends an event notification.
void SendEvent(const std::string& name, ::_FlValue* data = nullptr);
void MaybeRunAudioRecovery();
@@ -242,6 +331,62 @@ class MpvPlayer {
void LogRecovery(const std::string& text);
void SetHDREnabled(bool enabled, StatusCallback callback = nullptr);
/// One step of an all-or-nothing property change: the value to set, and the
/// value to restore if a *later* step in the same sequence fails.
struct PropertyChange {
std::string name;
std::string value;
std::string rollback;
};
/// Applies `changes` in order, starting at `index`. On the first failure every
/// earlier change is rolled back, newest first, and the callback reports that
/// failure; otherwise the callback reports success once all of them landed.
///
/// Shared ownership because each step completes on an mpv thread after this
/// call has returned.
void ApplyPropertySequence(
std::shared_ptr<std::vector<PropertyChange>> changes, size_t index, StatusCallback callback);
/// Restores the first `undo_count` changes, newest first, awaiting each reply
/// before the next. `failure` is the error that triggered the unwinding and is
/// what the callback finally reports — the outcome of the rollback itself is not
/// what the caller needs to know.
///
/// Awaited rather than fired and forgotten: the caller releases the video
/// plane's present hold and starts the next request the moment it is told, so a
/// rollback still in flight would let a frame reach the screen in a colour space
/// that is neither the old one nor the new.
void RollbackPropertySequence(
std::shared_ptr<std::vector<PropertyChange>> changes, size_t undo_count, int failure, StatusCallback callback);
/// Drives every target property to `auto` — the one state that is always
/// describable and always accepts its value — after an unwinding step itself
/// failed. Reports `failure`, the original refusal, once mpv is settled.
void ForceSdrOutput(size_t index, int failure, StatusCallback callback);
/// The single writer of the applied-output cache, so every path that moves one
/// of the four colour properties records it the same way.
void RecordAppliedOutputProperty(const std::string& name, const std::string& value);
/// Runs the next queued HDR output request. One sequence at a time; the next
/// starts only after the previous has finished, rollbacks included.
void RunPendingHdrOutput();
/// Whether there is anything to send the output-colour-space properties to.
/// Named rather than spelled out at both entry points because the focused
/// test substitutes the write primitive and so answers this differently; see
/// ConfigurePropertyWritesForTesting.
bool CanCommandOutputProperties() const;
/// A desired output colour space, waiting its turn, with the callback that
/// asked for it. SetHdrOutput explains why each keeps its own callback.
struct HdrOutputRequest {
SourceTransfer transfer = SourceTransfer::kSdr;
uint32_t peak_nits = 0;
HdrOutputCallback callback;
};
/// Helper to convert mpv_node to FlValue, bounded by the shared node budget.
::_FlValue* NodeToFlValue(mpv_node* node);
::_FlValue* NodeToFlValue(mpv_node* node, plezy::mpv_common::NodeConversionBudget* budget);
@@ -253,13 +398,53 @@ class MpvPlayer {
// Isolated EGL context for mpv rendering (not shared with Flutter)
EGLDisplay egl_display_ = EGL_NO_DISPLAY;
EGLContext egl_context_ = EGL_NO_CONTEXT;
std::vector<NativeRenderTeardownResource> retained_render_contexts_;
// The output colour space mpv last accepted in full, so a refused change can
// be unwound to something real instead of a guess. mpv's own defaults.
std::string applied_target_peak_ = "auto";
std::string applied_target_prim_ = "auto";
std::string applied_target_trc_ = "auto";
// Carried with the output description rather than set once globally: it selects
// the tone-map operator, but in this mpv it also drives gamut reduction, so a
// global value would reach wide-gamut SDR content that has no tone mapping to
// do. It is applied and withdrawn again with the rest of the description,
// whenever a tone-map pass starts or stops running.
std::string applied_tone_mapping_ = "auto";
// Whether the four strings above still describe what mpv holds. False after a
// forced-SDR reset that was itself refused partway: some of it landed and some
// did not, so they record what was asked for rather than what is in force, and
// the no-op short-circuit must not answer from them. A clean apply, or a reset
// that completes, earns the trust back.
bool output_state_known_ = true;
// What the unwinding of a refused sequence achieved. Reset to kRestored before
// each sequence; the escalation path moves it to kForcedSdr or kUnknown, and
// RunPendingHdrOutput reports whichever applies.
HdrOutputResult hdr_unwind_result_ = HdrOutputResult::kRestored;
// Serialization for SetHdrOutput. Touched only from the GLib main context:
// requests arrive from the platform channel and from mpv event handling, and
// ProcessEvents runs on a main-context source, so replies land on that same
// thread rather than on an mpv worker.
bool hdr_sequence_in_flight_ = false;
std::deque<HdrOutputRequest> hdr_queue_;
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
// The substituted property-write primitive; empty in every build that has a
// real core to write to. See ConfigurePropertyWritesForTesting.
PropertyWriteForTesting test_property_write_;
#endif
// Bits per colour channel of the video plane, told to mpv on every render so
// it dithers to the plane's real precision instead of the assumed 8.
int surface_depth_bits_ = 8;
// What `video-params` last reported, parsed once on the change event instead
// of read back from the core on every HDR decision. Guarded by native_mutex_:
// written from event handling, read by ReadSourceHdrMetadata.
SourceHdrMetadata source_hdr_metadata_;
mutable std::mutex native_mutex_;
std::atomic<bool> needs_redraw_{false};
std::atomic<bool> disposed_{false};
EventCallback event_callback_;
RedrawCallback redraw_callback_;
SourceMetadataCallback source_metadata_callback_;
std::mutex callback_mutex_;
plezy::mpv_common::AudioRecoveryState audio_recovery_;
plezy::mpv_common::AsyncRequestRegistry pending_requests_;
@@ -0,0 +1,474 @@
// Focused test for MpvPlayer's output-colour-space transaction: the four
// properties that together say what mpv is emitting, applied as a unit, unwound
// as a unit, and escalated to SDR when they cannot be unwound.
//
// Nothing else covers it, and nothing about it is visible on screen. A renamed
// property, a reversed write order or a rollback that reports success without
// restoring all leave working-looking video behind while the plane's committed
// surface description says something the pixels do not — the single state the
// whole transaction exists to prevent. So the assertions here are on the
// recorded (name, value) stream, not only on the returned result.
#include <mpv/client.h>
#include <cstddef>
#include <iostream>
#include <set>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include "mpv_player.h"
namespace mpv {
namespace {
void Check(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
struct PropertyWrite {
std::string name;
std::string value;
};
// Stands in for the core: records every property write in order and answers
// each with a scripted mpv status.
//
// Replies synchronously, which real mpv does not. That is sound here because
// the ladder awaits every reply before issuing the next write — the property
// stream a synchronous core sees is the one an asynchronous core would see, and
// no main loop has to be pumped to observe it.
class ScriptedCore {
public:
void Install(MpvPlayer& player) {
player.ConfigurePropertyWritesForTesting(
[this](const std::string& name, const std::string& value, MpvPlayer::StatusCallback callback) {
const size_t position = writes_.size();
writes_.push_back(PropertyWrite{name, value});
const bool refused = refusals_.count(position) != 0;
if (callback) callback(refused ? MPV_ERROR_PROPERTY_ERROR : MPV_ERROR_SUCCESS);
});
}
// Drops the substituted writer, which is what makes the player look like a
// core that can no longer be commanded: with no writer, the real
// disposed_/mpv_ check underneath decides, and a player that never opened a
// handle fails it.
void Uninstall(MpvPlayer& player) { player.ConfigurePropertyWritesForTesting(nullptr); }
// Refuses the write that lands at |position| in the recorded stream, counting
// from the last Forget().
void RefuseWrite(size_t position) { refusals_.insert(position); }
void Forget() {
writes_.clear();
refusals_.clear();
}
const std::vector<PropertyWrite>& writes() const { return writes_; }
private:
std::vector<PropertyWrite> writes_;
std::set<size_t> refusals_;
};
// Both sides of the comparison are printed on a mismatch: the failure mode this
// test guards against is a stream that is *almost* right, and "expected 5 writes,
// got 5 writes" would say nothing about which one moved.
void CheckWrites(const ScriptedCore& core, const std::vector<PropertyWrite>& expected, const char* message) {
const std::vector<PropertyWrite>& actual = core.writes();
bool matched = actual.size() == expected.size();
for (size_t index = 0; matched && index < expected.size(); ++index) {
matched = actual[index].name == expected[index].name && actual[index].value == expected[index].value;
}
if (matched) return;
std::cerr << " expected writes:\n";
for (const PropertyWrite& write : expected) std::cerr << " " << write.name << '=' << write.value << '\n';
std::cerr << " recorded writes:\n";
for (const PropertyWrite& write : actual) std::cerr << " " << write.name << '=' << write.value << '\n';
Check(false, message);
}
void CheckApplied(
const MpvPlayer& player, const char* target_trc, const char* target_prim, const char* tone_mapping,
const char* target_peak, const char* message) {
const MpvPlayer::AppliedOutputColourSpace applied = player.AppliedOutputColourSpaceForTesting();
if (applied.target_trc == target_trc && applied.target_prim == target_prim && applied.tone_mapping == tone_mapping &&
applied.target_peak == target_peak) {
return;
}
std::cerr << " expected applied: trc=" << target_trc << " prim=" << target_prim << " tone-mapping=" << tone_mapping
<< " peak=" << target_peak << '\n';
std::cerr << " recorded applied: trc=" << applied.target_trc << " prim=" << applied.target_prim
<< " tone-mapping=" << applied.tone_mapping << " peak=" << applied.target_peak << '\n';
Check(false, message);
}
// One request, run to completion, with the outcome its caller would act on.
struct Outcome {
int callbacks = 0;
MpvPlayer::HdrOutputResult result = MpvPlayer::HdrOutputResult::kUnknown;
int error = MPV_ERROR_SUCCESS;
};
Outcome Request(MpvPlayer& player, SourceTransfer transfer, uint32_t peak_nits) {
Outcome outcome;
player.SetHdrOutput(transfer, peak_nits, [&outcome](MpvPlayer::HdrOutputResult result, int error) {
++outcome.callbacks;
outcome.result = result;
outcome.error = error;
});
Check(outcome.callbacks == 1, "an output colour space request must complete exactly once");
return outcome;
}
// Puts the player in a real HDR output state before the failure cases run, so
// the values a rollback restores are ones mpv genuinely held. From the default
// state every rollback target is "auto", which a rollback that wrote nothing at
// all would also produce.
void ApplyPqBaseline(MpvPlayer& player, ScriptedCore& core) {
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "the PQ baseline for the failure cases must apply");
CheckApplied(player, "pq", "bt.2020", "auto", "auto", "the PQ baseline must be the state later rollbacks restore");
core.Forget();
}
// The happy path, and the exact strings that are the contract with mpv. The
// order is a dependency order: target-peak is what decides whether a tone-map
// pass runs at all, so everything that pass reads is in place before it is
// named. Nothing else in the tree checks either the names or the ordering.
void TestHdrApplyWritesTheFourPropertiesPeakLast() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
CheckWrites(
core, {{"target-trc", "pq"}, {"target-prim", "bt.2020"}, {"tone-mapping", "auto"}, {"target-peak", "auto"}},
"an HDR request must write all four properties in dependency order with the peak last");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "a fully applied sequence must report kApplied");
Check(outcome.error == MPV_ERROR_SUCCESS, "a fully applied sequence must report no error");
CheckApplied(player, "pq", "bt.2020", "auto", "auto", "the applied cache must hold exactly what was written");
}
// A display peak inside mpv's 10..10000 range moves the tone map into mpv, and
// the peak is the property that says so. The operator stays on mpv's own choice
// while the target is HDR — see the tone-mapping comment in RunPendingHdrOutput.
void TestHdrApplyWithADisplayPeakNamesThatPeak() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
const Outcome outcome = Request(player, SourceTransfer::kHlg, 1000);
CheckWrites(
core, {{"target-trc", "hlg"}, {"target-prim", "bt.2020"}, {"tone-mapping", "auto"}, {"target-peak", "1000"}},
"an HLG request with a display peak must encode HLG and name that peak");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "a fully applied sequence must report kApplied");
CheckApplied(player, "hlg", "bt.2020", "auto", "1000", "the applied cache must hold exactly what was written");
}
// The other half of the contract: an SDR decision withdraws the HDR request
// rather than leaving any part of it standing. A plane on BT.2020 primaries with
// an SDR curve is neither, and is exactly what a partial withdrawal produces.
void TestSdrRequestWithdrawsEveryHdrProperty() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
const Outcome outcome = Request(player, SourceTransfer::kSdr, 0);
CheckWrites(
core, {{"target-trc", "auto"}, {"target-prim", "auto"}, {"tone-mapping", "auto"}, {"target-peak", "auto"}},
"an SDR request must return every output property to auto");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "a fully applied SDR sequence must report kApplied");
CheckApplied(player, "auto", "auto", "auto", "auto", "the applied cache must follow the SDR withdrawal");
}
// The measured SDR fallback: mpv tone-maps to the compositor's own reference
// white, in the surface's real terms rather than assumed ones. mobius is the
// operator that measurement selected, and it is applied here and nowhere else.
void TestSdrRequestWithAReferenceWhiteNamesTheMeasuredTerms() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
const Outcome outcome = Request(player, SourceTransfer::kSdr, 203);
CheckWrites(
core, {{"target-trc", "srgb"}, {"target-prim", "auto"}, {"tone-mapping", "mobius"}, {"target-peak", "203"}},
"an SDR request with a reference white must name sRGB, mobius and that white");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "a fully applied SDR sequence must report kApplied");
CheckApplied(player, "srgb", "auto", "mobius", "203", "the applied cache must hold exactly what was written");
}
// playback-restart re-applies on every seek, so most requests ask for the state
// mpv already holds; skipping those is what stops a seek costing four round
// trips. The skip has to be exact, though: it reports kApplied, and a caller
// commits a surface description against that word. A quad that differs anywhere
// and is skipped anyway leaves the description describing the previous one.
void TestIdenticalRequestIsSkippedButANarrowlyDifferentOneIsNot() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
const Outcome repeat = Request(player, SourceTransfer::kPq, 0);
CheckWrites(core, {}, "a request for the state already in force must write nothing");
Check(repeat.result == MpvPlayer::HdrOutputResult::kApplied, "a skipped request must still report kApplied");
Check(repeat.error == MPV_ERROR_SUCCESS, "a skipped request must report no error");
// Differs from the state in force in target-peak alone.
core.Forget();
const Outcome peak_only = Request(player, SourceTransfer::kPq, 1000);
CheckWrites(
core, {{"target-trc", "pq"}, {"target-prim", "bt.2020"}, {"tone-mapping", "auto"}, {"target-peak", "1000"}},
"a request differing only in the peak must not be skipped");
Check(peak_only.result == MpvPlayer::HdrOutputResult::kApplied, "a re-applied sequence must report kApplied");
CheckApplied(player, "pq", "bt.2020", "auto", "1000", "the applied cache must follow a peak-only change");
// Differs from the state now in force in target-trc alone. The other two
// cannot vary on their own: primaries move with the HDR decision and the
// operator with the tone-map decision, and either decision also moves the
// curve. So the comparison is exercised on the two that can.
core.Forget();
const Outcome curve_only = Request(player, SourceTransfer::kHlg, 1000);
CheckWrites(
core, {{"target-trc", "hlg"}, {"target-prim", "bt.2020"}, {"tone-mapping", "auto"}, {"target-peak", "1000"}},
"a request differing only in the transfer curve must not be skipped");
Check(curve_only.result == MpvPlayer::HdrOutputResult::kApplied, "a re-applied sequence must report kApplied");
CheckApplied(player, "hlg", "bt.2020", "auto", "1000", "the applied cache must follow a curve-only change");
}
// A refused step unwinds what already landed, newest first, and reports
// kRestored — the one result that tells the caller its previously committed
// description is still true and must be left alone. Both halves matter: the
// order, because an unwind in apply order would restore a property the refused
// step's dependencies still contradict, and the values, because "restored" is a
// claim about what mpv now holds, not about how many writes were issued.
void TestRefusedStepUnwindsNewestFirstAndReportsRestored() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2); // tone-mapping, two properties into the sequence
const Outcome outcome = Request(player, SourceTransfer::kSdr, 203);
CheckWrites(
core,
{{"target-trc", "srgb"},
{"target-prim", "auto"},
{"tone-mapping", "mobius"},
{"target-prim", "bt.2020"},
{"target-trc", "pq"}},
"a refused step must restore each earlier property to its previous value, newest first");
Check(outcome.result == MpvPlayer::HdrOutputResult::kRestored, "a cleanly unwound sequence must report kRestored");
Check(outcome.error == MPV_ERROR_PROPERTY_ERROR, "the reported error must be the original refusal");
CheckApplied(player, "pq", "bt.2020", "auto", "auto", "a refused sequence must leave the applied cache untouched");
}
// An unwinding step that is itself refused leaves mpv in a state that is neither
// the old colour space nor the new, and no surface description is correct for a
// signal nobody can name. The escalation drives every property to auto — always
// valid, always describable — and says so with kForcedSdr, which is the caller's
// cue that any HDR description it committed is now a lie about the pixels.
void TestRefusedRollbackForcesSdr() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2); // tone-mapping
core.RefuseWrite(3); // the first unwinding write
const Outcome outcome = Request(player, SourceTransfer::kSdr, 203);
CheckWrites(
core,
{{"target-trc", "srgb"},
{"target-prim", "auto"},
{"tone-mapping", "mobius"},
{"target-prim", "bt.2020"},
{"target-trc", "auto"},
{"target-prim", "auto"},
{"tone-mapping", "auto"},
{"target-peak", "auto"}},
"a refused rollback must drive every output property to auto");
Check(outcome.result == MpvPlayer::HdrOutputResult::kForcedSdr, "a forced SDR unwind must report kForcedSdr");
Check(outcome.error == MPV_ERROR_PROPERTY_ERROR, "the reported error must still be the original refusal");
CheckApplied(player, "auto", "auto", "auto", "auto", "forcing SDR must leave the applied cache describing SDR");
}
// `auto` is valid for all four, so a core that refuses even that is no longer
// taking orders — usually because it is being disposed. What the plane emits is
// then unknowable, and kUnknown is the result the plugin treats as "nothing can
// be said about these pixels", stopping the presentation rather than guessing.
// The applied cache must stay where it was: claiming SDR here would be a guess.
void TestRefusedForcedSdrReportsUnknown() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2); // tone-mapping
core.RefuseWrite(3); // the first unwinding write
core.RefuseWrite(5); // the second write of the forced-SDR reset
const Outcome outcome = Request(player, SourceTransfer::kSdr, 203);
CheckWrites(
core,
{{"target-trc", "srgb"},
{"target-prim", "auto"},
{"tone-mapping", "mobius"},
{"target-prim", "bt.2020"},
{"target-trc", "auto"},
{"target-prim", "auto"}},
"a refused forced-SDR reset must stop rather than carry on down the list");
Check(outcome.result == MpvPlayer::HdrOutputResult::kUnknown, "an uncommandable core must report kUnknown");
Check(outcome.error == MPV_ERROR_PROPERTY_ERROR, "the reported error must still be the original refusal");
CheckApplied(
player, "auto", "bt.2020", "auto", "auto",
"a reset step that landed must be recorded, even though a later one was refused");
}
// Two mechanisms that are each correct alone and wrong together. A forced-SDR
// reset refused on its *first* step records nothing, so the cache still names
// the pre-request state - while mpv holds neither that nor SDR: the sequence's
// own first write landed before it failed. The no-op short-circuit would then
// find that stale cache matching the next identical request, report kApplied
// without writing anything, and the plugin would commit a PQ image description
// over pixels mpv is emitting as sRGB. That is a wrong picture with no error
// anywhere, and it needs the two together, so neither one's tests can catch it.
void TestAnUnknownOutputStateIsNeverAnsweredFromTheCache() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2); // tone-mapping, the request's own third write
core.RefuseWrite(3); // the rollback of target-prim
core.RefuseWrite(4); // the forced-SDR reset's first step, target-trc
Check(
Request(player, SourceTransfer::kSdr, 203).result == MpvPlayer::HdrOutputResult::kUnknown,
"the setup for this case is a reset refused before it recorded anything");
CheckApplied(
player, "pq", "bt.2020", "auto", "auto",
"nothing landed after the refusal, so the cache still names the pre-request state");
// Exactly the request the baseline made, so the cache above matches it in all
// four values. mpv does not: target-trc is srgb, from the write that landed
// before the refusal. Refusing nothing this time isolates the gate - a skip
// here could only come from trusting the cache.
const size_t before = core.writes().size();
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
Check(core.writes().size() > before, "a request after an unknown output state must write, not skip");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "the re-applied sequence must succeed");
CheckApplied(player, "pq", "bt.2020", "auto", "auto", "a clean apply must leave the cache naming what it wrote");
}
// A sequence refused on its very first write unwinds nothing - RollbackPropertySequence
// returns straight back out with nothing to undo - so it reports the untouched
// kRestored: "mpv is exactly where it was". True, and worthless once where it was
// is itself unknown. The caller reads kRestored as "the description you committed
// still holds" and puts the plane back on screen; after an unknown state there is
// no description, and mpv is in the half-reset colour space the last refusal left.
// The two mechanisms are each right alone: unwinding nothing really is a clean
// unwind, and kRestored really does mean the description stands - when the
// starting point was nameable.
void TestARefusalAfterAnUnknownStateIsNotReportedAsRestored() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2);
core.RefuseWrite(3);
core.RefuseWrite(4);
Check(
Request(player, SourceTransfer::kSdr, 203).result == MpvPlayer::HdrOutputResult::kUnknown,
"the setup for this case is an output state that can no longer be named");
// The next request's first write, refused. Nothing to unwind, so the unwind
// result stays at its clean default - which is the trap.
core.RefuseWrite(5);
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
Check(
outcome.result != MpvPlayer::HdrOutputResult::kRestored,
"a refusal on top of an unknown output state must not claim mpv was restored");
Check(
outcome.result == MpvPlayer::HdrOutputResult::kUnknown,
"it must stay unknown, so the caller keeps the plane off screen rather than showing it undescribed");
}
// The third place a result is named: the early return taken when the core can no
// longer be commanded at all. It owes the same answer as the other two, or a
// request already queued when the core went away is answered kUnknown by the
// drain while an identical one arriving a moment later hears kRestored - two
// different claims about one output, decided by timing.
void TestAnUncommandableCoreInheritsTheUnknownState() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2);
core.RefuseWrite(3);
core.RefuseWrite(4);
Check(
Request(player, SourceTransfer::kSdr, 203).result == MpvPlayer::HdrOutputResult::kUnknown,
"the setup for this case is an output state that can no longer be named");
core.Uninstall(player);
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
Check(
outcome.result == MpvPlayer::HdrOutputResult::kUnknown,
"a core that cannot be commanded must not claim the unknown state was restored");
Check(outcome.error == MPV_ERROR_UNINITIALIZED, "the early return still reports why it could not run");
}
} // namespace
} // namespace mpv
int main() {
GMainContext* context = g_main_context_new();
g_main_context_push_thread_default(context);
// The escalation cases provoke the ladder's own g_warning on purpose, and
// every apply logs its decision. Swallow both: a passing run that prints
// "could not restore target-prim" reads like a failing one, and the next
// person to see it will try to fix the wrong thing. Real failures come back
// through Check, not through the log.
g_log_set_default_handler([](const gchar*, GLogLevelFlags, const gchar*, gpointer) {}, nullptr);
try {
mpv::TestHdrApplyWritesTheFourPropertiesPeakLast();
mpv::TestHdrApplyWithADisplayPeakNamesThatPeak();
mpv::TestSdrRequestWithdrawsEveryHdrProperty();
mpv::TestSdrRequestWithAReferenceWhiteNamesTheMeasuredTerms();
mpv::TestIdenticalRequestIsSkippedButANarrowlyDifferentOneIsNot();
mpv::TestRefusedStepUnwindsNewestFirstAndReportsRestored();
mpv::TestRefusedRollbackForcesSdr();
mpv::TestRefusedForcedSdrReportsUnknown();
mpv::TestAnUnknownOutputStateIsNeverAnsweredFromTheCache();
mpv::TestARefusalAfterAnUnknownStateIsNotReportedAsRestored();
mpv::TestAnUncommandableCoreInheritsTheUnknownState();
} catch (const std::exception& error) {
g_main_context_pop_thread_default(context);
g_main_context_unref(context);
std::cerr << "mpv_player_hdr_output_test: " << error.what() << '\n';
return 1;
}
g_main_context_pop_thread_default(context);
g_main_context_unref(context);
std::cout << "mpv_player_hdr_output_test: PASS\n";
return 0;
}
+14 -144
View File
@@ -18,56 +18,6 @@
#include <utility>
#include "mpv_player.h"
#include "mpv_texture.h"
struct LifetimeTextureRegistrar {
GObject parent_instance;
FlTexture* texture;
};
struct LifetimeTextureRegistrarClass {
GObjectClass parent_class;
};
static void LifetimeTextureRegistrarInterfaceInit(FlTextureRegistrarInterface* interface);
static void LifetimeTextureRegistrarDispose(GObject* object);
static void lifetime_texture_registrar_class_init(LifetimeTextureRegistrarClass* klass);
static void lifetime_texture_registrar_init(LifetimeTextureRegistrar* self);
G_DEFINE_TYPE_WITH_CODE(
LifetimeTextureRegistrar, lifetime_texture_registrar, G_TYPE_OBJECT,
G_IMPLEMENT_INTERFACE(fl_texture_registrar_get_type(), LifetimeTextureRegistrarInterfaceInit))
static gboolean LifetimeTextureRegistrarRegister(FlTextureRegistrar* registrar, FlTexture* texture) {
auto* self = reinterpret_cast<LifetimeTextureRegistrar*>(registrar);
if (self->texture) return FALSE;
self->texture = FL_TEXTURE(g_object_ref(texture));
return TRUE;
}
static gboolean LifetimeTextureRegistrarUnregister(FlTextureRegistrar* registrar, FlTexture* texture) {
auto* self = reinterpret_cast<LifetimeTextureRegistrar*>(registrar);
if (self->texture != texture) return FALSE;
g_clear_object(&self->texture);
return TRUE;
}
static void LifetimeTextureRegistrarInterfaceInit(FlTextureRegistrarInterface* interface) {
interface->register_texture = LifetimeTextureRegistrarRegister;
interface->unregister_texture = LifetimeTextureRegistrarUnregister;
}
static void LifetimeTextureRegistrarDispose(GObject* object) {
auto* self = reinterpret_cast<LifetimeTextureRegistrar*>(object);
g_clear_object(&self->texture);
G_OBJECT_CLASS(lifetime_texture_registrar_parent_class)->dispose(object);
}
static void lifetime_texture_registrar_class_init(LifetimeTextureRegistrarClass* klass) {
G_OBJECT_CLASS(klass)->dispose = LifetimeTextureRegistrarDispose;
}
static void lifetime_texture_registrar_init(LifetimeTextureRegistrar* self) { self->texture = nullptr; }
namespace mpv {
@@ -259,87 +209,6 @@ void TestProcessShutdownDoesNotJoinBlockedNativeTeardown() {
Check(WEXITSTATUS(child_status) == 0, "teardown shutdown subprocess did not reach normal static shutdown");
}
struct TextureLifetimeState {
std::mutex mutex;
std::condition_variable condition;
bool callback_entered = false;
bool release_callback = false;
std::atomic<bool> callback_finalized{false};
bool finalized_during_callback = false;
};
void BlockingTextureReadyCallback(gboolean, const gchar*, gpointer user_data) {
auto* state = static_cast<TextureLifetimeState*>(user_data);
{
std::lock_guard<std::mutex> lock(state->mutex);
state->callback_entered = true;
}
state->condition.notify_all();
std::unique_lock<std::mutex> lock(state->mutex);
state->condition.wait(lock, [state]() { return state->release_callback; });
state->finalized_during_callback = state->callback_finalized.load();
}
void TextureReadyCallbackFinalized(gpointer user_data) {
static_cast<TextureLifetimeState*>(user_data)->callback_finalized = true;
}
void TestPopulateRetainsTextureWhileBootstrapCallbackRuns() {
auto* registrar = FL_TEXTURE_REGISTRAR(g_object_new(lifetime_texture_registrar_get_type(), nullptr));
TextureLifetimeState state;
MpvTexture* texture = mpv_texture_new(nullptr, registrar, nullptr);
mpv_texture_set_ready_callback(texture, BlockingTextureReadyCallback, &state, TextureReadyCallbackFinalized);
Check(
fl_texture_registrar_register_texture(registrar, FL_TEXTURE(texture)),
"the lifetime fixture must retain the registered texture");
gboolean populate_result = TRUE;
GError* populate_error = nullptr;
std::thread raster_thread([&]() {
uint32_t target = 0;
uint32_t name = 0;
uint32_t width = 0;
uint32_t height = 0;
auto* texture_class = FL_TEXTURE_GL_GET_CLASS(texture);
populate_result = texture_class->populate(FL_TEXTURE_GL(texture), &target, &name, &width, &height, &populate_error);
});
{
std::unique_lock<std::mutex> lock(state.mutex);
state.condition.wait(lock, [&state]() { return state.callback_entered; });
}
// Match plugin teardown while populate is between releasing its mutex and
// returning from the ready callback. Unregister drops the registrar's
// reference before dispose drops the plugin's reference.
Check(
fl_texture_registrar_unregister_texture(registrar, FL_TEXTURE(texture)),
"the lifetime fixture must unregister the texture");
mpv_texture_dispose(texture);
g_object_unref(texture);
const bool finalized_before_populate_released = state.callback_finalized.load();
{
std::lock_guard<std::mutex> lock(state.mutex);
state.release_callback = true;
}
state.condition.notify_all();
raster_thread.join();
Check(
!finalized_before_populate_released,
"platform disposal finalized the texture while its populate callback was still running");
Check(
!state.finalized_during_callback,
"the ready callback was finalized before populate released its retained texture reference");
Check(state.callback_finalized.load(), "the texture callback was not finalized after populate returned");
Check(!populate_result, "a populate without a player must fail");
Check(populate_error != nullptr, "failed populate must report an error");
g_clear_error(&populate_error);
g_object_unref(registrar);
}
void TestNodeConversionRejectsMalformedPayloads() {
MpvPlayer player;
@@ -657,13 +526,17 @@ void TestRenderTeardownDoesNotDestroyAStillCurrentContext() {
"retry must not repeat render-context destruction");
}
void TestRetainedRenderBlocksAnotherCreationUntilReleased() {
std::vector<NativeRenderTeardownResource> retained{
{reinterpret_cast<mpv_render_context*>(9), reinterpret_cast<EGLDisplay>(10), reinterpret_cast<EGLContext>(11)}};
// A batch that cannot bind its context keeps every resource for the next
// attempt, and a later attempt consumes each exactly once. The teardown queue
// retries on its own thread, so "preserved, then consumed once" is the contract
// that stops a retry either leaking a context or destroying one twice.
void TestFailedTeardownIsRetriedAndConsumedExactlyOnce() {
NativeRenderTeardownBatch batch;
batch.resources.push_back(
{reinterpret_cast<mpv_render_context*>(9), reinterpret_cast<EGLDisplay>(10), reinterpret_cast<EGLContext>(11)});
bool allow_make_current = false;
int free_calls = 0;
int destroy_calls = 0;
int render_creations = 0;
NativeRenderTeardownOperations operations{
[&](EGLDisplay, EGLContext) { return allow_make_current; },
[](EGLDisplay) { return true; },
@@ -675,15 +548,13 @@ void TestRetainedRenderBlocksAnotherCreationUntilReleased() {
[](mpv_handle*) { Check(false, "retained initialization cleanup must not terminate the shared core"); },
};
if (TryReleaseRetainedNativeRenderContexts(retained, operations)) ++render_creations;
Check(render_creations == 0, "a retained render context must block another creation on the same core");
Check(retained.size() == 1, "failed retained cleanup must preserve ownership for another GL-thread retry");
Check(!TryReleaseNativeRenderTeardown(batch, operations), "a batch that cannot bind must not report completion");
Check(batch.resources.size() == 1, "failed teardown must preserve ownership for another GL-thread retry");
allow_make_current = true;
if (TryReleaseRetainedNativeRenderContexts(retained, operations)) ++render_creations;
Check(render_creations == 1, "render creation may resume after retained teardown completes");
Check(retained.empty(), "successful retained teardown must consume the old render context");
Check(free_calls == 1 && destroy_calls == 1, "retained teardown must release each native object exactly once");
Check(TryReleaseNativeRenderTeardown(batch, operations), "teardown completes once the context can be bound");
Check(batch.resources.empty(), "successful teardown must consume the render context");
Check(free_calls == 1 && destroy_calls == 1, "teardown must release each native object exactly once");
}
} // namespace
@@ -695,7 +566,6 @@ int main() {
try {
mpv::TestProcessShutdownDoesNotJoinBlockedNativeTeardown();
mpv::TestPopulateRetainsTextureWhileBootstrapCallbackRuns();
mpv::TestUnavailablePropertyWriteFails();
mpv::TestNodeConversionRejectsMalformedPayloads();
mpv::TestUnavailableCommandFails();
@@ -707,7 +577,7 @@ int main() {
mpv::TestRenderTeardownRetainsOwnershipUntilContextIsCurrent();
mpv::TestRenderTeardownDoesNotDestroyAStillCurrentContext();
mpv::TestNullNodePropertyPayloadDecodesAsNull();
mpv::TestRetainedRenderBlocksAnotherCreationUntilReleased();
mpv::TestFailedTeardownIsRetriedAndConsumedExactlyOnce();
} catch (const std::exception& error) {
g_main_context_pop_thread_default(context);
g_main_context_unref(context);
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -11,9 +11,13 @@ G_BEGIN_DECLS
/// Plugin for MPV playback on Linux.
///
/// The video instance renders mpv video through Flutter's GPU-accelerated
/// texture pipeline via FlTextureGL. The audio-only instance (music
/// playback) skips all texture/GL work and runs mpv with video disabled.
/// The video instance renders into a native Wayland plane: a wl_subsurface
/// below the Flutter surface, which is what can carry HDR. It is the only
/// video path - where it cannot be brought up, initialize() fails with
/// VIDEO_PLANE_UNSUPPORTED naming the reason rather than degrading to
/// something the user cannot see. See start_video_plane() for the conditions.
/// The audio-only instance (music playback) skips all video work and runs mpv
/// with video disabled.
#define MPV_PLUGIN_TYPE (mpv_plugin_get_type())
-540
View File
@@ -1,540 +0,0 @@
#include "mpv_texture.h"
#include <epoxy/egl.h>
#include <epoxy/gl.h>
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "mpv_gpu_bootstrap.h"
namespace {
GQuark TextureErrorDomain() { return g_quark_from_static_string("plezy-mpv-texture"); }
struct TextureResources {
GLuint mpv_fbo = 0;
GLuint mpv_texture = 0;
GLuint flutter_texture = 0;
EGLImageKHR egl_image = EGL_NO_IMAGE_KHR;
int32_t width = 0;
int32_t height = 0;
bool complete() const {
return mpv_fbo != 0 && mpv_texture != 0 && flutter_texture != 0 && egl_image != EGL_NO_IMAGE_KHR;
}
};
bool SetError(GError** error, const char* message) {
g_set_error_literal(error, TextureErrorDomain(), 1, message);
return false;
}
void ClearGlErrors() {
while (glGetError() != GL_NO_ERROR) {
}
}
} // namespace
struct _MpvTexture {
FlTextureGL parent_instance;
mpv::MpvPlayer* player;
FlTextureRegistrar* registrar;
FlView* view;
GMutex mutex;
bool disposed;
TextureResources* active;
std::vector<TextureResources>* retired;
mpv::GpuImageDispatch* image_dispatch;
EGLDisplay flutter_display;
EGLContext flutter_share_context;
EGLContext flutter_cleanup_context;
GMutex bootstrap_mutex;
gint bootstrap_state;
gchar* bootstrap_error;
MpvTextureReadyCallback ready_callback;
gpointer ready_user_data;
GDestroyNotify ready_destroy_notify;
};
G_DEFINE_TYPE(MpvTexture, mpv_texture, fl_texture_gl_get_type())
namespace {
void SignalBootstrap(MpvTexture* self, gboolean success, const char* message) {
MpvTextureReadyCallback callback = nullptr;
gpointer user_data = nullptr;
g_mutex_lock(&self->bootstrap_mutex);
if (self->bootstrap_state == 0) {
self->bootstrap_state = success ? 1 : 2;
if (!success) self->bootstrap_error = g_strdup(message ? message : "Video initialization failed");
callback = self->ready_callback;
user_data = self->ready_user_data;
}
g_mutex_unlock(&self->bootstrap_mutex);
if (callback) callback(success, message, user_data);
}
bool RestoreContext(
EGLDisplay display, EGLSurface draw, EGLSurface read, EGLContext context, EGLenum api, GError** error) {
if (api != EGL_NONE && !eglBindAPI(api)) {
g_warning("MPV texture: failed to restore Flutter EGL API: 0x%x", eglGetError());
return SetError(error, "Failed to restore Flutter EGL API");
}
if (eglMakeCurrent(display, draw, read, context)) return true;
g_warning("MPV texture: failed to restore EGL context: 0x%x", eglGetError());
return SetError(error, "Failed to restore Flutter EGL context");
}
void RestoreOrReleaseContext(
EGLDisplay flutter_display, EGLSurface flutter_draw, EGLSurface flutter_read, EGLContext flutter_context,
EGLenum flutter_api, EGLDisplay mpv_display) {
if (flutter_display != EGL_NO_DISPLAY && flutter_context != EGL_NO_CONTEXT) {
const bool api_restored = flutter_api == EGL_NONE || eglBindAPI(flutter_api);
if (api_restored && eglMakeCurrent(flutter_display, flutter_draw, flutter_read, flutter_context)) return;
g_warning("MPV texture: failed to restore EGL state during cleanup: 0x%x", eglGetError());
}
if (mpv_display != EGL_NO_DISPLAY) {
if (!eglBindAPI(EGL_OPENGL_ES_API)) {
g_warning("MPV texture: failed to bind OpenGL while releasing cleanup context: 0x%x", eglGetError());
} else if (!eglMakeCurrent(mpv_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
g_warning("MPV texture: failed to release EGL context during cleanup: 0x%x", eglGetError());
}
}
if (flutter_api != EGL_NONE && !eglBindAPI(flutter_api)) {
g_warning("MPV texture: failed to restore Flutter EGL API after cleanup: 0x%x", eglGetError());
}
}
bool ResourceSetEmpty(const TextureResources& resources) {
return resources.mpv_fbo == 0 && resources.mpv_texture == 0 && resources.flutter_texture == 0 &&
resources.egl_image == EGL_NO_IMAGE_KHR;
}
bool EnsureFlutterCleanupContext(
MpvTexture* self, EGLDisplay flutter_display, EGLContext flutter_context, EGLenum flutter_api, GError** error) {
if (self->flutter_cleanup_context != EGL_NO_CONTEXT) {
if (self->flutter_display == flutter_display && self->flutter_share_context == flutter_context) return true;
return SetError(error, "Flutter EGL context changed while video textures were active");
}
if (flutter_api != EGL_OPENGL_ES_API) {
return SetError(error, "Flutter is not using an OpenGL ES context");
}
EGLint config_id = 0;
EGLint client_version = 0;
if (!eglQueryContext(flutter_display, flutter_context, EGL_CONFIG_ID, &config_id) ||
!eglQueryContext(flutter_display, flutter_context, EGL_CONTEXT_CLIENT_VERSION, &client_version)) {
g_warning("MPV texture: failed to query Flutter EGL context: 0x%x", eglGetError());
return SetError(error, "Failed to query Flutter EGL context");
}
EGLConfig config = nullptr;
EGLint num_configs = 0;
const EGLint config_attribs[] = {EGL_CONFIG_ID, config_id, EGL_NONE};
if (!eglChooseConfig(flutter_display, config_attribs, &config, 1, &num_configs) || num_configs != 1) {
g_warning("MPV texture: failed to select Flutter EGL config: 0x%x", eglGetError());
return SetError(error, "Failed to select Flutter EGL config");
}
if (!eglBindAPI(EGL_OPENGL_ES_API)) {
g_warning("MPV texture: failed to bind OpenGL ES for cleanup context creation: 0x%x", eglGetError());
return SetError(error, "Failed to bind OpenGL ES for video cleanup");
}
const EGLint context_attribs[] = {EGL_CONTEXT_CLIENT_VERSION, client_version, EGL_NONE};
const EGLContext cleanup_context = eglCreateContext(flutter_display, config, flutter_context, context_attribs);
const bool api_restored = eglBindAPI(flutter_api) == EGL_TRUE;
if (cleanup_context == EGL_NO_CONTEXT || !api_restored) {
if (cleanup_context != EGL_NO_CONTEXT && !eglDestroyContext(flutter_display, cleanup_context)) {
g_warning("MPV texture: failed to destroy rejected cleanup context: 0x%x", eglGetError());
}
if (!api_restored) {
g_warning("MPV texture: failed to restore Flutter EGL API after cleanup context creation: 0x%x", eglGetError());
}
return SetError(error, "Failed to create video cleanup context");
}
self->flutter_display = flutter_display;
self->flutter_share_context = flutter_context;
self->flutter_cleanup_context = cleanup_context;
return true;
}
void DestroyFlutterCleanupContext(MpvTexture* self) {
if (self->flutter_cleanup_context == EGL_NO_CONTEXT || self->flutter_display == EGL_NO_DISPLAY) return;
const EGLenum previous_api = eglQueryAPI();
if (eglGetCurrentContext() == self->flutter_cleanup_context) {
if (!eglBindAPI(EGL_OPENGL_ES_API) ||
!eglMakeCurrent(self->flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
g_warning("MPV texture: failed to release Flutter cleanup context: 0x%x", eglGetError());
}
}
if (!eglDestroyContext(self->flutter_display, self->flutter_cleanup_context)) {
g_warning("MPV texture: failed to destroy Flutter cleanup context: 0x%x", eglGetError());
}
if (previous_api != EGL_NONE && !eglBindAPI(previous_api)) {
g_warning("MPV texture: failed to restore EGL API after cleanup context destruction: 0x%x", eglGetError());
}
self->flutter_cleanup_context = EGL_NO_CONTEXT;
self->flutter_share_context = EGL_NO_CONTEXT;
self->flutter_display = EGL_NO_DISPLAY;
}
void RetireIncompleteCandidate(MpvTexture* self, const TextureResources& candidate) {
if (!ResourceSetEmpty(candidate)) self->retired->push_back(candidate);
}
void CleanupResourceSet(
MpvTexture* self, TextureResources* resources, EGLDisplay flutter_display, EGLSurface flutter_draw,
EGLSurface flutter_read, EGLContext flutter_context, EGLenum flutter_api) {
const EGLDisplay mpv_display = self->player ? self->player->GetEglDisplay() : EGL_NO_DISPLAY;
const EGLContext mpv_context = self->player ? self->player->GetEglContext() : EGL_NO_CONTEXT;
if (resources->egl_image != EGL_NO_IMAGE_KHR && mpv_display != EGL_NO_DISPLAY && self->image_dispatch &&
*self->image_dispatch) {
if (self->image_dispatch->Destroy(mpv_display, resources->egl_image)) {
resources->egl_image = EGL_NO_IMAGE_KHR;
} else {
g_warning("MPV texture: failed to destroy EGL image: 0x%x", eglGetError());
}
}
if (resources->flutter_texture) {
const bool flutter_current = flutter_display == self->flutter_display &&
flutter_context == self->flutter_share_context && flutter_context != EGL_NO_CONTEXT &&
eglGetCurrentContext() == self->flutter_share_context;
const bool cleanup_current =
!flutter_current && self->flutter_display != EGL_NO_DISPLAY &&
self->flutter_cleanup_context != EGL_NO_CONTEXT && eglBindAPI(EGL_OPENGL_ES_API) &&
eglMakeCurrent(self->flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, self->flutter_cleanup_context);
if (flutter_current || cleanup_current) {
glDeleteTextures(1, &resources->flutter_texture);
resources->flutter_texture = 0;
} else {
g_warning("MPV texture: failed to activate Flutter cleanup context: 0x%x", eglGetError());
}
if (cleanup_current) {
RestoreOrReleaseContext(
flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, self->flutter_display);
}
}
if (resources->mpv_fbo || resources->mpv_texture) {
const bool mpv_current = mpv_display != EGL_NO_DISPLAY && mpv_context != EGL_NO_CONTEXT &&
eglBindAPI(EGL_OPENGL_ES_API) &&
eglMakeCurrent(mpv_display, EGL_NO_SURFACE, EGL_NO_SURFACE, mpv_context);
if (mpv_current) {
if (resources->mpv_fbo) glDeleteFramebuffers(1, &resources->mpv_fbo);
if (resources->mpv_texture) glDeleteTextures(1, &resources->mpv_texture);
resources->mpv_fbo = 0;
resources->mpv_texture = 0;
} else {
g_warning("MPV texture: failed to activate EGL context during cleanup: 0x%x", eglGetError());
}
RestoreOrReleaseContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, mpv_display);
}
}
void CleanupRetired(MpvTexture* self) {
if (!self->retired || self->retired->empty() || !self->player) return;
const EGLDisplay flutter_display = eglGetCurrentDisplay();
const EGLContext flutter_context = eglGetCurrentContext();
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
const EGLenum flutter_api = eglQueryAPI();
for (auto& resources : *self->retired) {
CleanupResourceSet(self, &resources, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
}
auto& retired = *self->retired;
retired.erase(
std::remove_if(
retired.begin(), retired.end(),
[](const TextureResources& resources) { return ResourceSetEmpty(resources); }),
retired.end());
}
bool EnsureTextures(MpvTexture* self, int32_t width, int32_t height, GError** error) {
if (self->active->complete() && self->active->width == width && self->active->height == height) return true;
const EGLDisplay flutter_display = eglGetCurrentDisplay();
const EGLContext flutter_context = eglGetCurrentContext();
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
const EGLenum flutter_api = eglQueryAPI();
const EGLDisplay mpv_display = self->player->GetEglDisplay();
const EGLContext mpv_context = self->player->GetEglContext();
if (flutter_display == EGL_NO_DISPLAY || flutter_context == EGL_NO_CONTEXT || mpv_display == EGL_NO_DISPLAY ||
mpv_context == EGL_NO_CONTEXT) {
return SetError(error, "Video EGL contexts are unavailable");
}
if (!EnsureFlutterCleanupContext(self, flutter_display, flutter_context, flutter_api, error)) return false;
if (!*self->image_dispatch) {
std::string dispatch_error;
if (!mpv::ResolveGpuImageDispatch(flutter_display, self->image_dispatch, &dispatch_error)) {
g_warning("MPV texture: GPU bootstrap rejected: %s", dispatch_error.c_str());
return SetError(error, dispatch_error.c_str());
}
}
TextureResources candidate;
candidate.width = width;
candidate.height = height;
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(mpv_display, EGL_NO_SURFACE, EGL_NO_SURFACE, mpv_context)) {
RestoreOrReleaseContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, mpv_display);
return SetError(error, "Failed to activate video EGL context");
}
ClearGlErrors();
glGenTextures(1, &candidate.mpv_texture);
glBindTexture(GL_TEXTURE_2D, candidate.mpv_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glGenFramebuffers(1, &candidate.mpv_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, candidate.mpv_fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, candidate.mpv_texture, 0);
bool framebuffer_complete = candidate.mpv_texture != 0 && candidate.mpv_fbo != 0 &&
glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE &&
glGetError() == GL_NO_ERROR;
if (framebuffer_complete) {
candidate.egl_image = self->image_dispatch->Create(
mpv_display, mpv_context, reinterpret_cast<EGLClientBuffer>(static_cast<uintptr_t>(candidate.mpv_texture)));
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glBindTexture(GL_TEXTURE_2D, 0);
glFlush();
framebuffer_complete = framebuffer_complete && glGetError() == GL_NO_ERROR;
if (!RestoreContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, error)) {
CleanupResourceSet(self, &candidate, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
RetireIncompleteCandidate(self, candidate);
return false;
}
if (!framebuffer_complete || candidate.egl_image == EGL_NO_IMAGE_KHR) {
CleanupResourceSet(self, &candidate, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
RetireIncompleteCandidate(self, candidate);
return SetError(error, "Failed to create a complete video framebuffer");
}
ClearGlErrors();
glGenTextures(1, &candidate.flutter_texture);
glBindTexture(GL_TEXTURE_2D, candidate.flutter_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
self->image_dispatch->image_target_texture(GL_TEXTURE_2D, reinterpret_cast<GLeglImageOES>(candidate.egl_image));
const bool flutter_texture_complete = candidate.flutter_texture != 0 && glGetError() == GL_NO_ERROR;
glBindTexture(GL_TEXTURE_2D, 0);
if (!flutter_texture_complete) {
CleanupResourceSet(self, &candidate, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
RetireIncompleteCandidate(self, candidate);
return SetError(error, "Failed to bind the shared video image");
}
if (self->active->complete()) self->retired->push_back(*self->active);
*self->active = candidate;
CleanupRetired(self);
return true;
}
static gboolean MpvTexturePopulate(
FlTextureGL* texture, uint32_t* target, uint32_t* name, uint32_t* width, uint32_t* height, GError** error) {
MpvTexture* self = MPV_TEXTURE(texture);
g_mutex_lock(&self->mutex);
if (self->disposed || !self->player) {
g_object_ref(self);
g_mutex_unlock(&self->mutex);
SignalBootstrap(self, FALSE, "Video texture was disposed");
g_object_unref(self);
return SetError(error, "Video texture was disposed");
}
if (!self->player->HasRenderContext() && !self->player->InitRenderContext()) {
g_mutex_unlock(&self->mutex);
return SetError(error, "Failed to create video render context");
}
GtkAllocation allocation;
gtk_widget_get_allocation(GTK_WIDGET(self->view), &allocation);
const int scale = gtk_widget_get_scale_factor(GTK_WIDGET(self->view));
const int32_t requested_width = allocation.width * scale;
const int32_t requested_height = allocation.height * scale;
if (requested_width <= 0 || requested_height <= 0) {
g_mutex_unlock(&self->mutex);
return SetError(error, "Video surface has no drawable size");
}
// GL/EGL failures during the first populate are not terminal. Flutter may
// call populate again while waitForVideoReady owns the bounded deadline.
if (!EnsureTextures(self, requested_width, requested_height, error)) {
g_mutex_unlock(&self->mutex);
return FALSE;
}
const EGLDisplay flutter_display = eglGetCurrentDisplay();
const EGLContext flutter_context = eglGetCurrentContext();
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
const EGLenum flutter_api = eglQueryAPI();
const EGLDisplay mpv_display = self->player->GetEglDisplay();
const EGLContext mpv_context = self->player->GetEglContext();
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(mpv_display, EGL_NO_SURFACE, EGL_NO_SURFACE, mpv_context)) {
RestoreOrReleaseContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, mpv_display);
g_mutex_unlock(&self->mutex);
return SetError(error, "Failed to activate video EGL context");
}
ClearGlErrors();
glBindFramebuffer(GL_FRAMEBUFFER, self->active->mpv_fbo);
self->player->ClearRedrawFlag();
self->player->Render(requested_width, requested_height, static_cast<int>(self->active->mpv_fbo));
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glFlush();
const bool render_succeeded = glGetError() == GL_NO_ERROR;
if (!RestoreContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, error)) {
RestoreOrReleaseContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, mpv_display);
g_mutex_unlock(&self->mutex);
return FALSE;
}
if (!render_succeeded) {
g_mutex_unlock(&self->mutex);
return SetError(error, "Video render operation failed");
}
*target = GL_TEXTURE_2D;
*name = self->active->flutter_texture;
*width = static_cast<uint32_t>(requested_width);
*height = static_cast<uint32_t>(requested_height);
g_object_ref(self);
g_mutex_unlock(&self->mutex);
SignalBootstrap(self, TRUE, nullptr);
g_object_unref(self);
return TRUE;
}
static void MpvTextureFinalize(GObject* object) {
MpvTexture* self = MPV_TEXTURE(object);
if (self->ready_destroy_notify && self->ready_user_data) {
self->ready_destroy_notify(self->ready_user_data);
}
g_free(self->bootstrap_error);
delete self->active;
delete self->retired;
delete self->image_dispatch;
g_mutex_clear(&self->bootstrap_mutex);
g_mutex_clear(&self->mutex);
G_OBJECT_CLASS(mpv_texture_parent_class)->finalize(object);
}
} // namespace
static void mpv_texture_class_init(MpvTextureClass* klass) {
FL_TEXTURE_GL_CLASS(klass)->populate = MpvTexturePopulate;
G_OBJECT_CLASS(klass)->finalize = MpvTextureFinalize;
}
static void mpv_texture_init(MpvTexture* self) {
self->player = nullptr;
self->registrar = nullptr;
self->view = nullptr;
g_mutex_init(&self->mutex);
self->disposed = false;
self->active = new TextureResources();
self->retired = new std::vector<TextureResources>();
self->image_dispatch = new mpv::GpuImageDispatch();
self->flutter_display = EGL_NO_DISPLAY;
self->flutter_share_context = EGL_NO_CONTEXT;
self->flutter_cleanup_context = EGL_NO_CONTEXT;
g_mutex_init(&self->bootstrap_mutex);
self->bootstrap_state = 0;
self->bootstrap_error = nullptr;
self->ready_callback = nullptr;
self->ready_user_data = nullptr;
self->ready_destroy_notify = nullptr;
}
MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, FlTextureRegistrar* registrar, FlView* view) {
MpvTexture* self = MPV_TEXTURE(g_object_new(MPV_TEXTURE_TYPE, nullptr));
self->player = player;
self->registrar = registrar;
self->view = view;
return self;
}
void mpv_texture_set_ready_callback(
MpvTexture* self, MpvTextureReadyCallback callback, gpointer user_data, GDestroyNotify destroy_notify) {
gboolean success = FALSE;
const gchar* message = nullptr;
bool complete = false;
g_mutex_lock(&self->bootstrap_mutex);
self->ready_callback = callback;
self->ready_user_data = user_data;
self->ready_destroy_notify = destroy_notify;
if (self->bootstrap_state != 0) {
complete = true;
success = self->bootstrap_state == 1;
message = self->bootstrap_error;
}
g_mutex_unlock(&self->bootstrap_mutex);
if (complete && callback) callback(success, message, user_data);
}
void mpv_texture_mark_frame_available(MpvTexture* self) {
if (!self) return;
g_mutex_lock(&self->mutex);
FlTextureRegistrar* registrar = self->disposed ? nullptr : self->registrar;
g_mutex_unlock(&self->mutex);
if (registrar) {
fl_texture_registrar_mark_texture_frame_available(registrar, FL_TEXTURE(self));
}
}
void mpv_texture_dispose(MpvTexture* self) {
if (!self) return;
g_mutex_lock(&self->mutex);
if (self->disposed) {
g_mutex_unlock(&self->mutex);
return;
}
self->disposed = true;
SignalBootstrap(self, FALSE, "Video initialization was cancelled");
if (self->player) {
const EGLDisplay flutter_display = eglGetCurrentDisplay();
const EGLContext flutter_context = eglGetCurrentContext();
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
const EGLenum flutter_api = eglQueryAPI();
CleanupResourceSet(self, self->active, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
for (auto& resources : *self->retired) {
CleanupResourceSet(self, &resources, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
}
DestroyFlutterCleanupContext(self);
const auto leaked_sets =
static_cast<size_t>(!ResourceSetEmpty(*self->active)) +
static_cast<size_t>(std::count_if(self->retired->begin(), self->retired->end(), [](const auto& resources) {
return !ResourceSetEmpty(resources);
}));
if (leaked_sets != 0) {
g_warning("MPV texture: %zu resource set(s) could not be released before disposal", leaked_sets);
}
}
*self->active = TextureResources{};
self->retired->clear();
self->player = nullptr;
self->registrar = nullptr;
self->view = nullptr;
g_mutex_unlock(&self->mutex);
}
int64_t mpv_texture_get_id(MpvTexture* self) { return fl_texture_get_id(FL_TEXTURE(self)); }
-34
View File
@@ -1,34 +0,0 @@
#ifndef MPV_TEXTURE_H_
#define MPV_TEXTURE_H_
#include <flutter_linux/flutter_linux.h>
#include "mpv_player.h"
G_BEGIN_DECLS
#define MPV_TEXTURE_TYPE (mpv_texture_get_type())
G_DECLARE_FINAL_TYPE(MpvTexture, mpv_texture, MPV, TEXTURE, FlTextureGL)
/// Creates a new MpvTexture that renders mpv video to an offscreen FBO.
MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, FlTextureRegistrar* registrar, FlView* view);
typedef void (*MpvTextureReadyCallback)(gboolean success, const gchar* error_message, gpointer user_data);
/// Installs the one-shot video bootstrap result callback.
void mpv_texture_set_ready_callback(
MpvTexture* self, MpvTextureReadyCallback callback, gpointer user_data, GDestroyNotify destroy_notify);
/// Notifies Flutter that a new frame is available.
void mpv_texture_mark_frame_available(MpvTexture* self);
/// Cleans up GL resources (FBO/texture).
void mpv_texture_dispose(MpvTexture* self);
/// Returns the Flutter texture ID.
int64_t mpv_texture_get_id(MpvTexture* self);
G_END_DECLS
#endif // MPV_TEXTURE_H_
+111
View File
@@ -0,0 +1,111 @@
#ifndef PLEZY_LINUX_MPV_PLANE_GEOMETRY_H_
#define PLEZY_LINUX_MPV_PLANE_GEOMETRY_H_
#include <cstdint>
#include <limits>
// How large the video plane's buffer is and where its subsurface sits, given
// the rect Flutter cut out for it and the output's buffer scale.
//
// This header is deliberately free of Wayland and GTK: both rules bias the
// plane *outward* on purpose, the penalty for getting either wrong is severe —
// an undersized plane shows the desktop through the seam, and a buffer size
// that is not a whole multiple of the buffer scale is a fatal protocol error
// that disconnects the client — and neither deserves a display server to test.
// Header-only is deliberate as well: pure functions over int32, no
// dependencies, every one of them inline.
namespace mpv {
// The buffer scale to actually divide and round by. Scale arrives as an int32
// cast of an unvalidated channel argument, and anything below 1 is not a scale:
// 0 would divide by zero and a negative would inflate the plane instead of
// shrinking it. One physical pixel per logical one is the identity, so it is
// also the safe floor.
inline int32_t NormalizePlaneScale(int32_t scale) { return scale < 1 ? 1 : scale; }
// Where one axis of the plane starts, in whole surface-local units.
//
// Floor, not truncate. C integer division rounds toward zero, which for a
// negative origin - a video rect scrolled partly off the left or top - would
// bias the plane *inward*, while the extent below deliberately rounds outward.
// Flooring makes both ends bias the same way.
inline int32_t PlaneOriginUnits(int32_t position, int32_t scale) {
const int32_t divisor = NormalizePlaneScale(scale);
const int32_t quotient = position / divisor;
return (position % divisor != 0 && position < 0) ? quotient - 1 : quotient;
}
// One dimension of the plane's buffer, in physical pixels, for the rect
// [position, position + extent).
//
// Measured from the floored origin rather than from the extent alone, and this
// is the whole point: the two roundings have to compose. Flooring the origin
// moves the plane's left/top edge outward but does nothing for its right/bottom
// edge, so sizing from the extent on its own leaves the far edge short by
// whatever the floor gave away - at scale 2 a rect at x=1 of width 100 rounds to
// a 100-pixel buffer placed at 0, covering [0,100) while the hole is [1,101).
// The toplevel is an RGBA visual cleared to transparent, so that strip is not
// black: the desktop shows through it. Taking the far edge to the next whole
// unit and subtracting the floored origin covers the rect on both sides by
// construction, for every scale and either sign.
//
// The buffer size must also be an integer multiple of the buffer scale, or
// wl_surface.commit raises the fatal invalid_size error and the compositor
// disconnects us - the process dies with nothing in our own logs. A whole
// number of units times the scale is one by construction.
//
// Arithmetic in 64 bits because position and extent are int32 casts of
// unvalidated channel arguments: their sum, and the rounding added to it, both
// overflow int32 near the ends of the range, and a negative product would reach
// wl_egl_window_resize.
inline int32_t PlaneBufferExtent(int32_t position, int32_t extent, int32_t scale) {
const int64_t block = NormalizePlaneScale(scale);
const int64_t start = PlaneOriginUnits(position, scale);
const int64_t far = static_cast<int64_t>(position) + extent;
// Ceiling division that is correct for negatives too.
const int64_t end = far >= 0 ? (far + block - 1) / block : -((-far) / block);
int64_t span = (end - start) * block;
// The floor of one whole block is what keeps a degenerate rect legal: a zero
// or sub-scale extent would otherwise round to zero, which is not a multiple
// the compositor accepts either. Callers that care whether the rect is worth
// showing must ask before rounding, not after.
if (span < block) span = block;
// The largest multiple of the block that still fits in an int32. Rounding the
// far edge up can carry the span past INT32_MAX, and the result has to remain
// both representable and a whole multiple - taking the cap from the ceiling
// rather than from INT32_MAX would throw away a whole block at odd scales.
const int64_t cap = (static_cast<int64_t>(std::numeric_limits<int32_t>::max()) / block) * block;
if (span > cap) span = cap;
return static_cast<int32_t>(span);
}
// One axis of the subsurface's position, in the toplevel's surface-local frame.
//
// Positions are surface-local, i.e. logical units in the parent's frame.
// Floor, not truncate. C integer division rounds toward zero, which for a
// negative origin - a video rect scrolled partly off the left or top - would
// bias the plane *inward* by up to scale-1 physical pixels while the size
// above deliberately rounds outward. Flooring makes both ends bias the same
// way, so the plane always covers at least the rect Flutter cut out for it.
//
// `view_offset` is where the FlView sits inside the toplevel, and is added
// after the divide because GTK widget coordinates are already logical units,
// the same frame wl_subsurface_set_position expects.
//
// Summed in 64 bits and clamped, for the same reason PlaneBufferExtent is: the
// position is an int32 cast of an unvalidated channel argument, which setVideoRect
// clamps to INT32_MAX rather than rejecting. At scale 1 the floored origin is
// then INT32_MAX, and adding a non-zero offset - which is exactly what a
// client-side-decorated window supplies - is signed overflow. That is undefined
// behaviour, and the reliability builds run under -fsanitize=undefined.
inline int32_t PlaneSurfacePosition(int32_t position, int32_t scale, int32_t view_offset) {
const int64_t sum = static_cast<int64_t>(PlaneOriginUnits(position, scale)) + view_offset;
constexpr int64_t kMin = std::numeric_limits<int32_t>::min();
constexpr int64_t kMax = std::numeric_limits<int32_t>::max();
return static_cast<int32_t>(sum < kMin ? kMin : (sum > kMax ? kMax : sum));
}
} // namespace mpv
#endif // PLEZY_LINUX_MPV_PLANE_GEOMETRY_H_
+268
View File
@@ -0,0 +1,268 @@
#include "plane_geometry.h"
#include <iostream>
#include <limits>
namespace {
int failures = 0;
void Expect(bool condition, const char* expression, int line) {
if (condition) return;
std::cerr << "line " << line << ": check failed: " << expression << '\n';
++failures;
}
#define EXPECT(condition) Expect(static_cast<bool>(condition), #condition, __LINE__)
constexpr int32_t kInt32Max = std::numeric_limits<int32_t>::max();
// The common case: a rect Flutter already sized to a whole number of physical
// pixels must pass through untouched, at every scale. Rounding a legal size is
// not free - it grows the plane past the hole in the UI - so it must not happen
// when there is nothing to round.
void TestExactMultiplesAreUnchanged() {
EXPECT(mpv::PlaneBufferExtent(0, 1920, 1) == 1920);
EXPECT(mpv::PlaneBufferExtent(0, 1920, 2) == 1920);
EXPECT(mpv::PlaneBufferExtent(0, 1920, 3) == 1920);
EXPECT(mpv::PlaneBufferExtent(0, 1080, 2) == 1080);
EXPECT(mpv::PlaneBufferExtent(0, 1083, 3) == 1083);
}
// The rule the compositor kills us over: a size that is not a whole multiple of
// the buffer scale is a fatal invalid_size on commit. It must round *up* - a
// size rounded down is smaller than the region Flutter cut out, and the desktop
// shows through the seam.
void TestSizesOnePixelOverRoundUpNeverDown() {
EXPECT(mpv::PlaneBufferExtent(0, 1921, 2) == 1922);
EXPECT(mpv::PlaneBufferExtent(0, 1921, 3) == 1923);
EXPECT(mpv::PlaneBufferExtent(0, 1922, 3) == 1923);
// One short of a multiple is the other side of the same boundary.
EXPECT(mpv::PlaneBufferExtent(0, 1919, 2) == 1920);
EXPECT(mpv::PlaneBufferExtent(0, 1919, 3) == 1920);
// Scale 1 makes every size legal, so nothing may move.
EXPECT(mpv::PlaneBufferExtent(0, 1921, 1) == 1921);
}
// Dart sends a 0x0 layout before the first real one, and a rect can be scrolled
// down to a sliver. Zero is not a legal buffer size and neither is anything
// below one whole scale unit, so the floor has to hold at every scale.
void TestDegenerateSizesYieldOneScaleUnit() {
EXPECT(mpv::PlaneBufferExtent(0, 0, 1) == 1);
EXPECT(mpv::PlaneBufferExtent(0, 0, 2) == 2);
EXPECT(mpv::PlaneBufferExtent(0, 0, 3) == 3);
EXPECT(mpv::PlaneBufferExtent(0, 1, 2) == 2);
EXPECT(mpv::PlaneBufferExtent(0, 2, 3) == 3);
// A negative extent is not reachable from a sane layout, but it is reachable
// from an int32 cast of an unvalidated channel argument, and it must not
// become a negative buffer size.
EXPECT(mpv::PlaneBufferExtent(0, -4096, 2) == 2);
}
// The round-up adds up to scale-1 to its input, so a size near the type's
// maximum overflows unless it is clamped first - and a negative width reaching
// wl_egl_window_resize is exactly the corruption the clamp exists to stop.
void TestSizesNearIntMaxDoNotOverflow() {
EXPECT(mpv::PlaneBufferExtent(0, kInt32Max, 1) == kInt32Max);
EXPECT(mpv::PlaneBufferExtent(0, kInt32Max, 2) == kInt32Max - 1);
EXPECT(mpv::PlaneBufferExtent(0, kInt32Max, 3) == kInt32Max - 1);
EXPECT(mpv::PlaneBufferExtent(0, kInt32Max - 1, 3) == kInt32Max - 1);
}
// Every size the function can return must still be legal to commit: positive, a
// whole multiple of the scale, and never smaller than what was asked for. The
// individual cases above pin the interesting numbers; this pins the rule.
void TestBufferExtentInvariantsHold() {
// Up to 16 because that is what the plugin clamps devicePixelRatio to before
// handing it over as the buffer scale, so every one of these is reachable.
for (int32_t scale = 1; scale <= 16; ++scale) {
for (int32_t extent = -8; extent <= 64; ++extent) {
const int32_t rounded = mpv::PlaneBufferExtent(0, extent, scale);
EXPECT(rounded >= scale);
EXPECT(rounded % scale == 0);
EXPECT(rounded >= extent);
// Rounding up, not up-and-then-some: the plane grows by less than a scale
// unit, never a whole one.
EXPECT(extent < scale || rounded - extent < scale);
}
}
}
// The one that matters, and the one neither rule can promise alone: wherever
// Flutter put the rect, the plane has to cover all of it. Flooring the origin
// moves the near edge outward and does nothing for the far edge, so an extent
// rounded from the width on its own leaves the far edge short by whatever the
// floor gave away - and the toplevel is transparent, so that strip shows the
// desktop rather than black.
//
// Swept over every scale the plugin accepts and both signs of origin, at rect
// sizes a window can actually have. Coverage is not universal and cannot be: a
// rect whose far edge needs more than INT32_MAX physical pixels is not
// representable, and TestAnUnrepresentableRectStaysLegal below pins what
// happens there instead.
void TestThePlaneAlwaysCoversTheRect() {
for (int32_t scale = 1; scale <= 16; ++scale) {
for (int32_t x = -40; x <= 40; ++x) {
for (int32_t width = 1; width <= 80; ++width) {
// Physical pixels, which is the frame the rect itself is in.
const int64_t origin = static_cast<int64_t>(mpv::PlaneSurfacePosition(x, scale, 0)) * scale;
const int64_t extent = mpv::PlaneBufferExtent(x, width, scale);
EXPECT(origin <= x);
EXPECT(origin + extent >= static_cast<int64_t>(x) + width);
// Still legal to commit, which the far-edge rounding must not cost.
EXPECT(extent % scale == 0);
// And no more generous than it has to be: the cover is tight to within
// one scale unit at each edge.
EXPECT(x - origin < scale);
EXPECT((origin + extent) - (static_cast<int64_t>(x) + width) < scale);
}
}
}
}
// Past the end of int32 the plane cannot cover the rect, because the rect is
// not representable. What still has to hold is the one whose failure is fatal:
// a buffer size that is not a whole multiple of the scale makes wl_surface
// .commit an invalid_size protocol error and disconnects the whole client. So
// this asserts legality rather than coverage, and pins the largest legal answer
// so a future clamp cannot quietly give away a whole scale unit.
void TestAnUnrepresentableRectStaysLegal() {
const int32_t huge = std::numeric_limits<int32_t>::max();
for (int32_t scale = 1; scale <= 16; ++scale) {
for (const int32_t x : {-1, 0, 1, 40}) {
const int32_t extent = mpv::PlaneBufferExtent(x, huge, scale);
EXPECT(extent > 0);
EXPECT(extent % scale == 0);
// The largest multiple of the scale that fits, not one block less.
// Recomputing `(huge / scale) * scale` here would just be the cap
// expression from the header again, so the interesting scales carry
// literals: an oracle that is a copy of the code cannot fail with it.
if (scale == 1) EXPECT(extent == 2147483647);
if (scale == 2) EXPECT(extent == 2147483646);
if (scale == 3) EXPECT(extent == 2147483646);
if (scale == 8) EXPECT(extent == 2147483640);
if (scale == 16) EXPECT(extent == 2147483632);
}
}
}
// An origin already on a scale boundary converts exactly, so the plane lands
// where Flutter put it.
void TestExactPositionMultiplesConvertExactly() {
EXPECT(mpv::PlaneSurfacePosition(0, 2, 0) == 0);
EXPECT(mpv::PlaneSurfacePosition(640, 1, 0) == 640);
EXPECT(mpv::PlaneSurfacePosition(640, 2, 0) == 320);
EXPECT(mpv::PlaneSurfacePosition(639, 3, 0) == 213);
EXPECT(mpv::PlaneSurfacePosition(-640, 2, 0) == -320);
EXPECT(mpv::PlaneSurfacePosition(-639, 3, 0) == -213);
}
// A positive origin off the boundary floors down, which for positives is what
// plain integer division already does. Pinned so the flooring below cannot be
// "fixed" into rounding.
void TestPositivePositionsFloorDown() {
EXPECT(mpv::PlaneSurfacePosition(641, 2, 0) == 320);
EXPECT(mpv::PlaneSurfacePosition(1, 2, 0) == 0);
EXPECT(mpv::PlaneSurfacePosition(2, 3, 0) == 0);
EXPECT(mpv::PlaneSurfacePosition(641, 3, 0) == 213);
EXPECT(mpv::PlaneSurfacePosition(641, 1, 0) == 641);
}
// The case C gets wrong. A video rect scrolled partly off the left or top has a
// negative origin, and integer division truncates *toward zero* - which moves
// the plane inward by up to scale-1 physical pixels while the size deliberately
// grows outward, uncovering the very edge the size was widened to cover.
void TestNegativePositionsFloorAwayFromZero() {
EXPECT(mpv::PlaneSurfacePosition(-1, 2, 0) == -1); // truncation gives 0
EXPECT(mpv::PlaneSurfacePosition(-3, 2, 0) == -2); // truncation gives -1
EXPECT(mpv::PlaneSurfacePosition(-1, 3, 0) == -1); // truncation gives 0
EXPECT(mpv::PlaneSurfacePosition(-4, 3, 0) == -2); // truncation gives -1
EXPECT(mpv::PlaneSurfacePosition(-641, 2, 0) == -321);
// Scale 1 divides evenly, so there is nothing to floor and negatives survive.
EXPECT(mpv::PlaneSurfacePosition(-641, 1, 0) == -641);
}
// The flooring must never place the plane's origin to the right of, or below,
// the rect it is covering: converted back to physical pixels the result is at
// or before the requested origin, and within one scale unit of it.
void TestPositionNeverBiasesInward() {
for (int32_t scale = 1; scale <= 16; ++scale) {
for (int32_t position = -32; position <= 32; ++position) {
const int32_t local = mpv::PlaneSurfacePosition(position, scale, 0);
EXPECT(local * scale <= position);
EXPECT(position - local * scale < scale);
}
}
}
// The FlView is inset inside the toplevel whenever GTK draws client-side
// decorations, and wl_subsurface_set_position is relative to the toplevel. The
// offset is already in logical units, so it is added *after* the divide - adding
// it before would scale it and slide the plane by the wrong amount.
void TestViewOffsetIsAddedInSurfaceLocalUnits() {
EXPECT(mpv::PlaneSurfacePosition(640, 2, 37) == 357);
EXPECT(mpv::PlaneSurfacePosition(641, 2, 37) == 357);
EXPECT(mpv::PlaneSurfacePosition(-3, 2, 37) == 35);
EXPECT(mpv::PlaneSurfacePosition(639, 3, 8) == 221);
EXPECT(mpv::PlaneSurfacePosition(640, 1, 8) == 648);
// Had the offset been scaled instead of added straight, this would be 320+18.
EXPECT(mpv::PlaneSurfacePosition(640, 2, 36) != 338);
}
// Server-side decorations - a KWin session, which is what this is developed on -
// make the offset zero. That path must be indistinguishable from having no
// offset at all, or the CSD fix would have quietly moved the plane everywhere it
// was already correct.
void TestZeroViewOffsetChangesNothing() {
for (int32_t scale = 1; scale <= 16; ++scale) {
for (int32_t position = -32; position <= 32; ++position) {
const int32_t zero = mpv::PlaneSurfacePosition(position, scale, 0);
// Stated as a property rather than by recomputing the implementation's own
// formula: an oracle that is a copy of the code cannot fail for any change
// made to both, including the flooring direction this is named for. The
// property is that the origin lands on or before the rect and within one
// scale unit of it.
EXPECT(static_cast<int64_t>(zero) * scale <= position);
EXPECT(position - static_cast<int64_t>(zero) * scale < scale);
// And an offset really is just an addition on top of that answer.
for (const int32_t offset : {-37, -1, 0, 1, 37}) {
EXPECT(mpv::PlaneSurfacePosition(position, scale, offset) == zero + offset);
}
}
}
}
// Scale reaches both rules as an int32 cast of an unvalidated channel argument.
// Zero would divide by zero and a negative would invert the rounding, so both
// collapse to the identity scale instead.
void TestNonPositiveScaleIsTreatedAsOne() {
EXPECT(mpv::NormalizePlaneScale(0) == 1);
EXPECT(mpv::NormalizePlaneScale(-4) == 1);
EXPECT(mpv::NormalizePlaneScale(1) == 1);
EXPECT(mpv::NormalizePlaneScale(3) == 3);
EXPECT(mpv::PlaneBufferExtent(0, 1921, 0) == 1921);
EXPECT(mpv::PlaneBufferExtent(0, 0, -4) == 1);
EXPECT(mpv::PlaneSurfacePosition(-641, 0, 0) == -641);
EXPECT(mpv::PlaneSurfacePosition(-641, -4, 7) == -634);
}
} // namespace
int main() {
TestExactMultiplesAreUnchanged();
TestSizesOnePixelOverRoundUpNeverDown();
TestDegenerateSizesYieldOneScaleUnit();
TestSizesNearIntMaxDoNotOverflow();
TestBufferExtentInvariantsHold();
TestThePlaneAlwaysCoversTheRect();
TestAnUnrepresentableRectStaysLegal();
TestExactPositionMultiplesConvertExactly();
TestPositivePositionsFloorDown();
TestNegativePositionsFloorAwayFromZero();
TestPositionNeverBiasesInward();
TestViewOffsetIsAddedInSurfaceLocalUnits();
TestZeroViewOffsetChangesNothing();
TestNonPositiveScaleIsTreatedAsOne();
return failures == 0 ? 0 : 1;
}
+107
View File
@@ -0,0 +1,107 @@
#ifndef PLEZY_LINUX_MPV_VIDEO_PARAMS_H_
#define PLEZY_LINUX_MPV_VIDEO_PARAMS_H_
#include <mpv/client.h>
#include <cstring>
#include <string>
// What mpv's `video-params` says about the current source's colour space and
// HDR10 static metadata.
//
// This is a pure function of one mpv_node, and deliberately so: it is the sole
// input to the whole HDR decision, the absent-versus-zero rules below are what
// keeps a source that stated nothing from being described as if it stated zero,
// and getting either wrong describes the plane in a colour space the pixels are
// not in. None of that needs a running mpv core to test. Header-only for the
// same reasons as the other pure headers here: one function over plain structs,
// no dependency beyond libmpv's own type.
namespace mpv {
// The source's colour space under mpv's own names, plus its HDR10 static
// metadata. A zero luminance means the source did not state it — mpv omits the
// field rather than reporting a zero — and the strings are empty when nothing
// is loaded.
struct SourceHdrMetadata {
std::string transfer; ///< mpv trc name, e.g. "pq", "hlg", "bt.1886"
std::string primaries; ///< mpv primaries name, e.g. "bt.2020"
double max_cll = 0.0; ///< nits, maximum content light level
double max_fall = 0.0; ///< nits, maximum frame-average light level
double max_luminance = 0.0; ///< nits, mastering display maximum
double min_luminance = 0.0; ///< nits, mastering display minimum
};
// Reads the fields the HDR decision needs out of a `video-params` node.
//
// Anything that is not the map mpv documents — a node of another type, a null
// list, a key of an unexpected format — yields the default, which reads as "no
// stream" everywhere downstream and describes no plane. Unrecognised names are
// carried through verbatim: what counts as an HDR curve is the caller's
// judgement, not this parse's.
inline SourceHdrMetadata ParseSourceHdrMetadata(const mpv_node* params) {
SourceHdrMetadata metadata;
if (params == nullptr || params->format != MPV_FORMAT_NODE_MAP || params->u.list == nullptr) return metadata;
const mpv_node_list& entries = *params->u.list;
if (entries.keys == nullptr || entries.values == nullptr) return metadata;
auto name = [](const mpv_node& value, std::string* out) {
if (value.format != MPV_FORMAT_STRING || value.u.string == nullptr) return;
out->assign(value.u.string);
};
// mpv writes every luminance as a double. Integers are accepted as well
// because the sub-property read this replaced asked for MPV_FORMAT_DOUBLE,
// which libmpv would have converted for us.
auto number = [](const mpv_node& value, double* out) {
if (value.format == MPV_FORMAT_DOUBLE) {
*out = value.u.double_;
return true;
}
if (value.format == MPV_FORMAT_INT64) {
*out = static_cast<double>(value.u.int64);
return true;
}
return false;
};
// A luminance the source did not state stays absent rather than becoming a
// zero-valued claim, and zero is exactly how the rest of the pipeline spells
// "not stated" — so a zero here would be indistinguishable anyway.
auto positive = [&number](const mpv_node& value, double* out) {
double parsed = 0.0;
if (!number(value, &parsed) || !(parsed > 0.0)) return;
*out = parsed;
};
for (int i = 0; i < entries.num; ++i) {
const char* key = entries.keys[i];
if (key == nullptr) continue;
const mpv_node& value = entries.values[i];
// The two names decide whether the plane may be described as HDR at all, so
// they are taken whether or not any luminance came with them: plenty of
// HDR10 carries a PQ curve and no static metadata whatsoever.
if (std::strcmp(key, "gamma") == 0) {
name(value, &metadata.transfer);
} else if (std::strcmp(key, "primaries") == 0) {
name(value, &metadata.primaries);
} else if (std::strcmp(key, "max-cll") == 0) {
positive(value, &metadata.max_cll);
} else if (std::strcmp(key, "max-fall") == 0) {
positive(value, &metadata.max_fall);
} else if (std::strcmp(key, "max-luma") == 0) {
positive(value, &metadata.max_luminance);
} else if (std::strcmp(key, "min-luma") == 0) {
// The mastering floor is the one luminance a source may legitimately
// state as zero — a display whose black is unmeasurably low — so it is
// taken on its own terms and only a negative is refused.
double parsed = 0.0;
if (number(value, &parsed) && parsed >= 0.0) metadata.min_luminance = parsed;
}
}
return metadata;
}
} // namespace mpv
#endif // PLEZY_LINUX_MPV_VIDEO_PARAMS_H_
+279
View File
@@ -0,0 +1,279 @@
#include "video_params.h"
#include <iostream>
#include <vector>
namespace {
int failures = 0;
void Expect(bool condition, const char* expression, int line) {
if (condition) return;
std::cerr << "line " << line << ": check failed: " << expression << '\n';
++failures;
}
#define EXPECT(condition) Expect(static_cast<bool>(condition), #condition, __LINE__)
mpv_node Text(const char* value) {
mpv_node node{};
node.format = MPV_FORMAT_STRING;
node.u.string = const_cast<char*>(value);
return node;
}
mpv_node Number(double value) {
mpv_node node{};
node.format = MPV_FORMAT_DOUBLE;
node.u.double_ = value;
return node;
}
mpv_node Whole(int64_t value) {
mpv_node node{};
node.format = MPV_FORMAT_INT64;
node.u.int64 = value;
return node;
}
// Builds the node shape mpv delivers for an observed `video-params`: a map whose
// absent fields are missing keys rather than zeroed ones. Node() borrows the
// builder's storage, so nothing may be added after it is called.
class Params {
public:
Params& Add(const char* key, mpv_node value) {
keys_.push_back(const_cast<char*>(key));
values_.push_back(value);
return *this;
}
mpv_node Node() {
list_.num = static_cast<int>(values_.size());
list_.keys = keys_.empty() ? nullptr : keys_.data();
list_.values = values_.empty() ? nullptr : values_.data();
mpv_node node{};
node.format = MPV_FORMAT_NODE_MAP;
node.u.list = &list_;
return node;
}
private:
std::vector<char*> keys_;
std::vector<mpv_node> values_;
mpv_node_list list_{};
};
// A fully described HDR10 master: both names and all four luminances, mixed in
// among the fields the HDR decision has no use for, since mpv sends the whole
// map every time and the keys are in mpv's order, not ours.
void TestEveryFieldPresentIsRead() {
Params params;
params.Add("pixelformat", Text("yuv420p10"))
.Add("w", Whole(3840))
.Add("gamma", Text("pq"))
.Add("primaries", Text("bt.2020"))
.Add("min-luma", Number(0.0001))
.Add("max-luma", Number(1000.0))
.Add("max-cll", Number(999.0))
.Add("max-fall", Number(400.0))
.Add("chroma-location", Text("mpeg2/4/h264"));
const mpv_node node = params.Node();
const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node);
EXPECT(metadata.transfer == "pq");
EXPECT(metadata.primaries == "bt.2020");
EXPECT(metadata.max_cll == 999.0);
EXPECT(metadata.max_fall == 400.0);
EXPECT(metadata.max_luminance == 1000.0);
EXPECT(metadata.min_luminance == 0.0001);
}
// The common HDR10 case: a PQ / BT.2020 stream that states no static metadata at
// all. mpv omits the keys entirely, and every luminance has to stay at zero -
// which is how the rest of the pipeline spells "not stated" - while both names
// still come through, because they are what decides whether the plane may be
// described as HDR in the first place.
void TestNamesSurviveWithNoLuminances() {
Params params;
params.Add("gamma", Text("hlg")).Add("primaries", Text("bt.2020"));
const mpv_node node = params.Node();
const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node);
EXPECT(metadata.transfer == "hlg");
EXPECT(metadata.primaries == "bt.2020");
EXPECT(metadata.max_cll == 0.0);
EXPECT(metadata.max_fall == 0.0);
EXPECT(metadata.max_luminance == 0.0);
EXPECT(metadata.min_luminance == 0.0);
}
// Each luminance is independent: a source may state MaxCLL and nothing else, or
// a mastering range and no light levels. An absent field must never pick up its
// neighbour's value or a default.
void TestEachLuminanceIsAbsentOnItsOwn() {
{
Params params;
params.Add("max-cll", Number(1200.0));
const mpv_node node = params.Node();
const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node);
EXPECT(metadata.max_cll == 1200.0);
EXPECT(metadata.max_fall == 0.0);
EXPECT(metadata.max_luminance == 0.0);
EXPECT(metadata.min_luminance == 0.0);
}
{
Params params;
params.Add("max-fall", Number(250.0));
const mpv_node node = params.Node();
const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node);
EXPECT(metadata.max_cll == 0.0);
EXPECT(metadata.max_fall == 250.0);
EXPECT(metadata.max_luminance == 0.0);
}
{
Params params;
params.Add("min-luma", Number(0.005));
const mpv_node node = params.Node();
const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node);
EXPECT(metadata.max_luminance == 0.0);
EXPECT(metadata.min_luminance == 0.005);
}
{
Params params;
params.Add("max-luma", Number(4000.0));
const mpv_node node = params.Node();
const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node);
EXPECT(metadata.max_luminance == 4000.0);
EXPECT(metadata.min_luminance == 0.0);
}
}
// The mastering floor is the one luminance a source may legitimately state as
// zero, so a present zero has to be taken rather than dropped. The others treat
// zero as no statement, which is the same thing they do with an absent key -
// and since the field's absent value *is* zero, only a negative can tell the
// two rules apart from the outside.
void TestMinLumaAcceptsAZeroTheOthersRefuse() {
Params params;
params.Add("gamma", Text("pq"))
.Add("min-luma", Number(0.0))
.Add("max-luma", Number(0.0))
.Add("max-cll", Number(0.0))
.Add("max-fall", Number(0.0));
const mpv_node node = params.Node();
const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node);
EXPECT(metadata.min_luminance == 0.0);
EXPECT(metadata.max_luminance == 0.0);
EXPECT(metadata.max_cll == 0.0);
EXPECT(metadata.max_fall == 0.0);
// No luminance may be negative. The floor would scale into a mastering
// minimum the protocol rejects, and a negative peak would sail through the
// containment checks that only ever compare upwards.
Params negative;
negative.Add("min-luma", Number(-1.0))
.Add("max-luma", Number(-4000.0))
.Add("max-cll", Number(-1000.0))
.Add("max-fall", Number(-400.0));
const mpv_node negative_node = negative.Node();
const mpv::SourceHdrMetadata refused = mpv::ParseSourceHdrMetadata(&negative_node);
EXPECT(refused.min_luminance == 0.0);
EXPECT(refused.max_luminance == 0.0);
EXPECT(refused.max_cll == 0.0);
EXPECT(refused.max_fall == 0.0);
}
// The names are mpv's own and this parse does not judge them: SDR curves, gamuts
// with no protocol counterpart, and anything a future mpv adds all pass through
// verbatim for the caller to classify. Copying them into an enum here would put
// the same table in two places.
void TestUnknownNamesPassThroughVerbatim() {
Params params;
params.Add("gamma", Text("bt.1886")).Add("primaries", Text("display-p3")).Add("max-cll", Number(120.0));
const mpv_node node = params.Node();
const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node);
EXPECT(metadata.transfer == "bt.1886");
EXPECT(metadata.primaries == "display-p3");
EXPECT(metadata.max_cll == 120.0);
}
// Everything that is not the map mpv documents has to read as "no stream": the
// property is unavailable between files and while an audio-only core runs, and
// the event carries MPV_FORMAT_NONE then. Deciding from a half-read map would
// describe the plane in a colour space nothing is emitting.
void TestNonMapNodesYieldNothing() {
EXPECT(mpv::ParseSourceHdrMetadata(nullptr).transfer.empty());
mpv_node none{};
none.format = MPV_FORMAT_NONE;
EXPECT(mpv::ParseSourceHdrMetadata(&none).transfer.empty());
// The shape an observation of the same property in another format delivers.
const mpv_node string_node = Text("pq");
const mpv::SourceHdrMetadata from_string = mpv::ParseSourceHdrMetadata(&string_node);
EXPECT(from_string.transfer.empty());
EXPECT(from_string.primaries.empty());
// An array is a map's near neighbour and has no keys to walk.
mpv_node_list list{};
list.num = 1;
mpv_node array_node{};
array_node.format = MPV_FORMAT_NODE_ARRAY;
array_node.u.list = &list;
EXPECT(mpv::ParseSourceHdrMetadata(&array_node).transfer.empty());
// A map claiming entries it does not carry, which is what a truncated or
// hostile payload looks like.
mpv_node empty_map{};
empty_map.format = MPV_FORMAT_NODE_MAP;
empty_map.u.list = &list;
EXPECT(mpv::ParseSourceHdrMetadata(&empty_map).transfer.empty());
mpv_node null_map{};
null_map.format = MPV_FORMAT_NODE_MAP;
EXPECT(mpv::ParseSourceHdrMetadata(&null_map).transfer.empty());
}
// A key whose value is not the format mpv documents for it is ignored rather
// than reinterpreted, and it must not stop the rest of the map being read. An
// integer luminance is the one exception: the blocking sub-property read this
// replaced asked for a double and libmpv would have converted it.
void TestMistypedValuesAreSkippedButIntegersAreNot() {
Params params;
params.Add("gamma", Number(2.2))
.Add("primaries", Text("bt.2020"))
.Add("max-cll", Text("1000"))
.Add("max-luma", Whole(4000))
.Add("min-luma", Text("0.0001"));
const mpv_node node = params.Node();
const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node);
EXPECT(metadata.transfer.empty());
EXPECT(metadata.primaries == "bt.2020");
EXPECT(metadata.max_cll == 0.0);
EXPECT(metadata.max_luminance == 4000.0);
EXPECT(metadata.min_luminance == 0.0);
// mpv owns the strings, and a null one is not a name.
Params null_name;
null_name.Add("gamma", Text(nullptr)).Add("primaries", Text("bt.2020"));
const mpv_node null_name_node = null_name.Node();
const mpv::SourceHdrMetadata from_null = mpv::ParseSourceHdrMetadata(&null_name_node);
EXPECT(from_null.transfer.empty());
EXPECT(from_null.primaries == "bt.2020");
}
} // namespace
int main() {
TestEveryFieldPresentIsRead();
TestNamesSurviveWithNoLuminances();
TestEachLuminanceIsAbsentOnItsOwn();
TestMinLumaAcceptsAZeroTheOthersRefuse();
TestUnknownNamesPassThroughVerbatim();
TestNonMapNodesYieldNothing();
TestMistypedValuesAreSkippedButIntegersAreNot();
return failures == 0 ? 0 : 1;
}
File diff suppressed because it is too large Load Diff
+409
View File
@@ -0,0 +1,409 @@
#ifndef PLEZY_LINUX_MPV_WAYLAND_VIDEO_SURFACE_H_
#define PLEZY_LINUX_MPV_WAYLAND_VIDEO_SURFACE_H_
#include <EGL/egl.h>
#include <gtk/gtk.h>
#include <cstdint>
#include <functional>
#include <string>
#include "hdr_metadata.h"
struct wl_callback;
struct wl_compositor;
struct wl_display;
struct wl_egl_window;
struct wl_subcompositor;
struct wl_subsurface;
struct wl_surface;
struct wp_color_management_surface_v1;
struct wp_color_management_surface_feedback_v1;
struct wp_color_manager_v1;
struct wp_image_description_v1;
struct wp_image_description_info_v1;
namespace mpv {
// The compositor's preferred colour encoding for a surface, as delivered by
// wp_image_description_info_v1 in response to get_information.
//
// Only the fields that matter for video are kept. The important one is
// max_luminance: it is the output's *target* peak (KWin sources it from an HDR
// peak override, else the EDID's desired maximum, else 800 nits), which is the
// number a player needs if it is going to tone-map for the display itself.
// Nothing else in the protocol reveals it — PQ's own encoded maximum is always
// 10000 regardless of the panel.
//
// pq, bt2020 and min_luminance_scaled drive no decision: they are kept so
// CommitPreferredQuery can tell a real change from a repeat, and so the logged
// description is complete.
struct PreferredColorDescription {
bool valid = false; // a complete info burst has arrived
bool pq = false; // transfer function is ST2084 PQ
bool bt2020 = false; // container primaries are BT.2020
uint32_t max_luminance = 0; // nits, the output's target peak
uint32_t min_luminance_scaled = 0; // nits * 10000, the output's target floor
uint32_t reference_luminance = 0; // nits, diffuse/SDR white
};
// A native Wayland video plane: a wl_subsurface stacked *below* the Flutter
// toplevel surface, carrying its own EGL window surface that mpv renders into
// directly.
//
// This is the Linux analogue of the Windows video child HWND. Flutter's own
// surface keeps the UI and alpha-blends over this one, so video never travels
// through Flutter's compositor — which on GTK3 costs one CPU-side upload of the
// whole window surface per presented frame (see gdk_cairo_draw_from_gl's
// alpha path), previously paid once per *video* frame.
//
// Everything here runs on the GTK main thread. The subsurface is desynchronized
// so its commits are independent of the parent's frame loop; position and
// stacking, however, are *parent* state and only take effect on a parent
// commit, which is why SetRect() asks the view to redraw.
//
// mpv is never told about Wayland: it only ever sees the EGL surface's default
// framebuffer. Embedding mpv into a foreign Wayland surface is not possible
// (mpv's --wid does not work on Wayland and upstream considers it out of
// scope), so the app owns the subsurface and drives the render itself.
class WaylandVideoSurface {
public:
WaylandVideoSurface() = default;
~WaylandVideoSurface();
WaylandVideoSurface(const WaylandVideoSurface&) = delete;
WaylandVideoSurface& operator=(const WaylandVideoSurface&) = delete;
// True when the process is on a Wayland display. Cheap; safe to call before
// the view is realized, so it can gate the window's visual.
static bool IsSupported(GdkDisplay* display);
// Binds the Wayland globals, creates the subsurface under `view`'s toplevel,
// and creates an EGL window surface on it.
//
// The plane is created at 10 bits per channel when the driver offers such a
// window config, then half-float (NVIDIA offers no 10-bit unorm configs on
// Wayland), falling back to 8. Returns false and fills `error` on any
// failure — the caller reports it; there is no other video path.
bool Create(GtkWidget* view, std::string* error);
// Releases the EGL surface, subsurface and Wayland objects. Idempotent.
void Destroy();
bool valid() const { return egl_surface_ != EGL_NO_SURFACE; }
EGLDisplay egl_display() const { return egl_display_; }
EGLConfig egl_config() const { return egl_config_; }
EGLSurface egl_surface() const { return egl_surface_; }
// Current buffer size in physical pixels. Zero until the first SetRect().
int32_t width() const { return width_; }
int32_t height() const { return height_; }
// "Dart has given the plane a rect worth showing", not "the stored numbers
// are non-zero" - SetRect floors the buffer size at one scale-sized block to
// keep it a multiple of the buffer scale, so after the first SetRect() the
// stored size is never zero.
bool has_size() const { return rect_valid_; }
// Places and sizes the plane. Coordinates are physical pixels in the
// toplevel's frame, matching what the Dart side sends via setVideoRect.
void SetRect(int32_t x, int32_t y, int32_t width, int32_t height, int32_t scale);
// Hides the plane by attaching a null buffer. The next Present() re-shows it.
void SetVisible(bool visible);
bool visible() const { return visible_; }
// True while a committed frame has not yet been acknowledged by the
// compositor. Callers must not render while this holds: the compositor stops
// acknowledging frames for an occluded or minimized surface, and rendering
// regardless would queue work that can never drain.
bool frame_pending() const { return frame_pending_; }
// Invoked on the GTK main thread when the compositor acknowledges a frame.
// This is what resumes rendering after the plane becomes visible again, so
// it must trigger a render — mpv's redraw latch stays set while frames are
// being skipped and will not notify again on its own.
void SetFrameCallback(std::function<void()> callback) { on_frame_ = std::move(callback); }
// Invoked when the plane needs a frame *now*, whether or not mpv has produced
// one. The frame callback above is not a substitute: it is the "a frame was
// acknowledged" path, and the plugin's handler skips rendering unless mpv's
// redraw latch or a pending resize says there is something new. The recovery
// after an abandoned colour transition has neither, and still has to commit -
// withdrawing a description only stages it, and eglSwapBuffers is what makes
// it real.
void SetForcedRenderCallback(std::function<void()> callback) { on_forced_render_ = std::move(callback); }
// Presents whatever was rendered into the EGL surface. No-op while hidden,
// while a frame is still pending, or while a colour transition is staged —
// the last being the one case a caller cannot read off the plane's visible
// state, so see hdr_transition_staged().
bool Present();
// True when this plane can be described as HDR at all: the compositor offers
// a parametric image-description creator, accepts the perceptual render
// intent, and advertises BT.2020 primaries plus at least one HDR curve (PQ or
// HLG) — *and* the plane itself got a deep EGL config (10-bit unorm or fp16)
// and a colour surface, without which the aggregate is dropped again in
// Create(). Which curve a given source needs is checked per source by
// CanDescribeSource(). This says nothing about whether the *display* is in
// HDR — see output_is_hdr().
bool supports_hdr() const { return supports_hdr_; }
// What the compositor says it would prefer for this surface, from
// wp_color_management_surface_feedback_v1. This is the only way to learn the
// output's *real* peak: an HDR output's preferred description carries the
// panel's target luminance, where PQ's own nominal maximum is always 10000.
const PreferredColorDescription& preferred() const { return preferred_; }
// True when the output this surface sits on has enough luminance headroom
// above its own reference white to be worth passing HDR through. The claim
// this makes is deliberately narrower than "the user's HDR toggle is on":
// OutputHasHdrHeadroom explains why no signal in this protocol answers that,
// and why the margin it applies is not a magic number.
bool output_is_hdr() const {
return preferred_.valid && OutputHasHdrHeadroom(preferred_.max_luminance, preferred_.reference_luminance);
}
// Invoked on the GTK main thread when the compositor's preferred description
// changes — a monitor move, or HDR being switched on or off under us.
void SetPreferredChangedCallback(std::function<void()> callback) { on_preferred_changed_ = std::move(callback); }
// Number of bits per colour channel the plane actually got: 16 on a
// half-float plane, 10 on a 10-bit unorm one, otherwise 8. PQ in 8 bits
// bands badly, so HDR needs at least 10.
int depth_bits() const { return depth_bits_; }
// Stages a colour change. It has to be two-phase; apply_hdr_state in
// mpv_plugin.cc tells that story in full. In outline: BeginHdrTransition
// stages and validates the description and holds Present() while it does, the
// caller switches mpv once it settles, and CommitHdrTransition attaches the
// state and releases the hold so the first buffer rendered in the new colour
// space is the one that carries it. Abort backs out and changes nothing.
//
// `describe` is an instruction, not a request: DecideHdr in hdr_metadata.h has
// already weighed the app's permission, this surface's capabilities, the
// output's state and the source.
//
// `on_settled(token, true)` means Commit may proceed. It fires synchronously
// when there is nothing to validate, so the caller must tolerate re-entry.
//
// The token identifies *this* transition, and Commit and Abort ignore any other,
// so a settled-but-uncommitted transition whose mpv request is still in flight
// cannot be committed against a newer description. A token of zero means nothing
// was staged, so there is nothing to commit or abort.
void BeginHdrTransition(bool describe, const HdrMetadata& metadata, std::function<void(uint64_t, bool)> on_settled);
// Applies the transition named by `token`. Returns true when the plane should
// be re-rendered and presented at once, so the new state reaches the screen
// instead of waiting for whatever frame mpv happens to produce next. Ignores a
// token that is not the staged one.
bool CommitHdrTransition(uint64_t token);
// Discards the transition named by `token` and releases the hold. The committed
// colour state is left exactly as it was. Ignores a stale token.
void AbortHdrTransition(uint64_t token);
// True while a transition is staged, i.e. while Present() is being held.
bool hdr_transition_staged() const { return transition_staged_; }
// Drops any staged transition and unsets the description immediately.
//
// For the case where mpv's colour space had to be forced back to SDR while
// unwinding a refused change: the description already committed is then no
// longer true of the pixels, and aborting alone would leave it in place. Returns
// true when the plane should be re-rendered and presented at once.
bool ForceUndescribed();
// Whether this source could be described at all: it carries an HDR curve, a
// BT.2020 container, and the compositor advertised that specific named pair.
//
// Public because the caller has to know the answer *before* it changes mpv's
// output colour space — the pixels have to be committed to before the surface
// is described, or the two disagree for a frame.
bool CanDescribeSource(const HdrMetadata& metadata) const;
// Whether a description is attached, i.e. whether the compositor is currently
// being told this plane carries an HDR curve.
bool hdr_active() const { return hdr_active_; }
private:
bool BindGlobals(GdkDisplay* display, std::string* error);
void BuildImageDescription();
bool InitEgl(std::string* error);
void RequestParentCommit();
void ClearFrameCallback();
/// Takes the current buffer off screen and drops any pending frame callback.
/// A subsurface has no visibility of its own, so this is what "not showing"
/// actually is - used both when Dart hides the plane and when the rect it was
/// covering goes away.
void DetachBuffer();
// Destroys the description staged for the pending transition, if any. The
// attached one is never held; see staged_description_.
void ClearStagedDescription();
// Tears the staged transition down unconditionally and tells whoever was
// waiting that it will not be committed. The token-checked Abort delegates here;
// teardown and ForceUndescribed call it directly.
void DiscardTransition();
// Shared tail of the transition's outcome, whichever event delivered it.
void SettleTransition(bool ok);
static void HandleFrameDone(void* data, wl_callback* callback, uint32_t time);
// Interface version 1 only; version 2 and later send ready2 in its place.
static void HandleImageDescriptionReady(void* data, wp_image_description_v1* desc, uint32_t identity);
// Interface version 2+. Must be present rather than null, for the reason
// given beside the description listener in BuildImageDescription().
static void HandleImageDescriptionReady2(
void* data, wp_image_description_v1* desc, uint32_t identity_hi, uint32_t identity_lo);
static void HandleImageDescriptionFailed(
void* data, wp_image_description_v1* desc, uint32_t cause, const char* message);
// Bounds each half of a staged transition: first the compositor's verdict on
// the image description, then the caller's mpv leg deciding to commit or
// abort. Present() and the plugin's render path are held across *both*, so it
// is re-armed rather than cancelled when the compositor answers - the second
// wait is the longer one and has no timeout of its own.
static constexpr int kTransitionTimeoutSeconds = 5;
// How many roundtrips a synchronous bootstrap waits for its answer. Ready,
// then the info burst, then done is three at worst, plus one spare for a
// compositor that splits them differently.
static constexpr int kBootstrapRoundtrips = 4;
void ArmTransitionWatchdog();
void CancelTransitionWatchdog();
guint watchdog_source_ = 0;
// Creates the preferred-description query. The returned description is ready
// immediately per the protocol, so get_information follows on ready, and the
// accumulated fields are committed when the info burst ends with done.
void BeginPreferredQuery();
void ClearPreferredQuery();
void CommitPreferredQuery();
static void HandlePreferredChanged(void* data, wp_color_management_surface_feedback_v1* feedback, uint32_t identity);
static void HandlePreferredChanged2(
void* data, wp_color_management_surface_feedback_v1* feedback, uint32_t identity_hi, uint32_t identity_lo);
static void HandlePreferredReady(void* data, wp_image_description_v1* desc, uint32_t identity);
static void HandlePreferredReady2(
void* data, wp_image_description_v1* desc, uint32_t identity_hi, uint32_t identity_lo);
static void HandlePreferredFailed(void* data, wp_image_description_v1* desc, uint32_t cause, const char* message);
// Only tf_named, primaries_named, luminances and target_luminance carry
// anything we use, and icc_file has to close the fd it is handed; the
// remainder are deliberate no-ops rather than omissions, for the reason given
// beside the description listener in BuildImageDescription().
static void HandleInfoDone(void* data, wp_image_description_info_v1* info);
static void HandleInfoIccFile(void* data, wp_image_description_info_v1* info, int32_t icc, uint32_t icc_size);
static void HandleInfoPrimaries(
void* data, wp_image_description_info_v1* info, int32_t r_x, int32_t r_y, int32_t g_x, int32_t g_y, int32_t b_x,
int32_t b_y, int32_t w_x, int32_t w_y);
static void HandleInfoPrimariesNamed(void* data, wp_image_description_info_v1* info, uint32_t primaries);
static void HandleInfoTfPower(void* data, wp_image_description_info_v1* info, uint32_t eexp);
static void HandleInfoTfNamed(void* data, wp_image_description_info_v1* info, uint32_t tf);
static void HandleInfoLuminances(
void* data, wp_image_description_info_v1* info, uint32_t min_lum, uint32_t max_lum, uint32_t reference_lum);
static void HandleInfoTargetPrimaries(
void* data, wp_image_description_info_v1* info, int32_t r_x, int32_t r_y, int32_t g_x, int32_t g_y, int32_t b_x,
int32_t b_y, int32_t w_x, int32_t w_y);
static void HandleInfoTargetLuminance(
void* data, wp_image_description_info_v1* info, uint32_t min_lum, uint32_t max_lum);
static void HandleInfoTargetMaxCll(void* data, wp_image_description_info_v1* info, uint32_t max_cll);
static void HandleInfoTargetMaxFall(void* data, wp_image_description_info_v1* info, uint32_t max_fall);
// The colour manager's capability burst. These are static members taking the
// surface as their user data, rather than file-locals over BindGlobals'
// stack; the add_listener call there says why.
static void HandleManagerIntent(void* data, wp_color_manager_v1* manager, uint32_t intent);
static void HandleManagerFeature(void* data, wp_color_manager_v1* manager, uint32_t feature);
static void HandleManagerTransferFunction(void* data, wp_color_manager_v1* manager, uint32_t tf);
static void HandleManagerPrimaries(void* data, wp_color_manager_v1* manager, uint32_t primaries);
static void HandleManagerDone(void* data, wp_color_manager_v1* manager);
GtkWidget* view_ = nullptr;
wl_display* wl_display_ = nullptr; // owned by GDK
wl_compositor* compositor_ = nullptr; // owned by GDK
wl_subcompositor* subcompositor_ = nullptr; // bound by us
wl_surface* surface_ = nullptr;
wl_subsurface* subsurface_ = nullptr;
wl_egl_window* egl_window_ = nullptr;
EGLDisplay egl_display_ = EGL_NO_DISPLAY;
EGLConfig egl_config_ = nullptr;
EGLSurface egl_surface_ = EGL_NO_SURFACE;
int32_t x_ = 0;
int32_t y_ = 0;
int32_t width_ = 0;
int32_t height_ = 0;
int32_t scale_ = 1;
// The view's own offset inside the toplevel, which is the frame
// wl_subsurface_set_position uses. Non-zero under client-side decorations.
int32_t view_x_ = 0;
int32_t view_y_ = 0;
bool visible_ = false;
// Set from the size Dart asked for, before SetRect rounds it into a whole
// number of scale-sized blocks. See has_size().
bool rect_valid_ = false;
bool buffer_attached_ = false;
bool frame_pending_ = false;
wl_callback* frame_callback_ = nullptr;
std::function<void()> on_frame_;
std::function<void()> on_forced_render_;
wp_color_manager_v1* color_manager_ = nullptr;
wp_color_management_surface_v1* color_surface_ = nullptr;
// The description being validated for a staged transition. Never the attached
// one: set_image_description copies, so the object is destroyed immediately
// after it is handed over.
wp_image_description_v1* staged_description_ = nullptr;
// A transition is staged: Present() is held, and Commit or Abort will release
// it. `staged_describe_` is what Commit will apply, and `transition_token_` is
// what Commit and Abort must match to act on it.
bool transition_staged_ = false;
uint64_t transition_token_ = 0;
bool staged_describe_ = false;
HdrMetadata staged_metadata_;
std::function<void(uint64_t, bool)> on_transition_settled_;
// Feedback lives for the whole surface lifetime so preferred_changed keeps
// arriving; the description and info objects are transient, created per query
// and destroyed as soon as their values have been copied out.
wp_color_management_surface_feedback_v1* color_feedback_ = nullptr;
wp_image_description_v1* preferred_description_ = nullptr;
wp_image_description_info_v1* preferred_info_ = nullptr;
PreferredColorDescription preferred_;
PreferredColorDescription pending_preferred_;
std::function<void()> on_preferred_changed_;
// The committed source description, i.e. what the attached description was
// built from. Compared against a new request so an identical one is a no-op
// rather than a needless round-trip through the compositor.
HdrMetadata metadata_;
int depth_bits_ = 8;
// supports_hdr_ is the aggregate gate; the three below are what the compositor
// advertised individually, because whether a *given* source can be described
// depends on its own curve, not on the aggregate.
bool supports_hdr_ = false;
bool supports_pq_ = false;
bool supports_hlg_ = false;
bool supports_bt2020_ = false;
// What the compositor will accept in a luminance description. Consulted by
// PlanHdrLuminance, which turns it plus the source into a legal request set.
CompositorLuminanceSupport luminance_support_;
// What the colour manager advertised, filled by the manager listener during
// the bootstrap. Kept on the object for the reason given above the handlers.
struct ManagerCaps {
bool parametric = false;
bool perceptual = false;
bool pq = false;
bool hlg = false;
bool bt2020 = false;
bool mastering = false;
bool extended_target_volume = false;
bool done = false;
};
ManagerCaps manager_caps_;
bool hdr_active_ = false;
};
} // namespace mpv
#endif // PLEZY_LINUX_MPV_WAYLAND_VIDEO_SURFACE_H_
+42 -3
View File
@@ -1,10 +1,48 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#include <gdk/gdk.h>
#ifdef GDK_WINDOWING_WAYLAND
#include <gdk/gdkwayland.h>
#endif
#include "flutter/generated_plugin_registrant.h"
#include "mpv/mpv_plugin.h"
// On Wayland the mpv video plane is a wl_subsurface stacked *below* this
// window's surface, so the window needs an alpha channel for it to show
// through. Harmless when the plane is unavailable: the Flutter UI paints
// opaque anyway, and X11 sessions refuse video at initialize by design.
static void enable_video_plane_transparency(GtkWindow* window, FlView* view) {
#ifdef GDK_WINDOWING_WAYLAND
GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window));
if (!GDK_IS_WAYLAND_DISPLAY(display)) return;
GdkScreen* screen = gtk_widget_get_screen(GTK_WIDGET(window));
GdkVisual* visual = gdk_screen_get_rgba_visual(screen);
if (visual == nullptr) {
// The plane is stacked *below* the toplevel, so without an alpha channel it
// is occluded by an opaque Flutter surface and the video area goes blank
// with everything else working. GDK's Wayland backend always offers an ARGB
// visual, so this is not expected - say so rather than fail silently, since
// the symptom on its own points nowhere near here.
g_warning("MPV video plane: no RGBA visual; the video plane would be hidden behind an opaque window");
return;
}
gtk_widget_set_visual(GTK_WIDGET(window), visual);
gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE);
// The RGBA visual only reaches the compositor if Flutter stops filling the frame too: fl_view clears to this
// colour every frame, so any opaque value would paint over the subsurface whatever the visual says.
GdkRGBA transparent = {0.0, 0.0, 0.0, 0.0};
fl_view_set_background_color(view, &transparent);
#else
(void)window;
(void)view;
#endif
}
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
@@ -36,24 +74,25 @@ static void my_application_activate(GApplication* application) {
gtk_window_set_default_size(window, 1280, 720);
// Create the Flutter view (opaque — no overlay needed).
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
self->flutter_view = fl_view_new(project);
enable_video_plane_transparency(window, self->flutter_view);
gtk_widget_show(GTK_WIDGET(self->flutter_view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(self->flutter_view));
// Register Flutter plugins.
fl_register_plugins(FL_PLUGIN_REGISTRY(self->flutter_view));
// Register the MPV plugin (uses FlTextureGL — no overlay/GtkGLArea needed).
// Register the MPV plugin. Video goes to the native Wayland plane; there is no other path, so a session that
// cannot host one is refused by name rather than played into nothing.
FlPluginRegistrar* registrar =
fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(self->flutter_view), "MpvPlugin");
mpv_plugin_register_with_registrar(registrar);
// Register the dedicated audio-only MPV core for music playback (no
// texture/GL work at all).
// video or GL work at all).
FlPluginRegistrar* audio_registrar =
fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(self->flutter_view), "MpvAudioPlugin");
mpv_audio_plugin_register_with_registrar(audio_registrar);
+95 -114
View File
@@ -1,12 +1,10 @@
import 'dart:async' show Completer;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/mpv/player/player_base.dart';
import 'package:plezy/mpv/video.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/mock_player_channels.dart';
@@ -348,163 +346,146 @@ void main() {
);
});
test('Linux texture bootstrap gates observations and commands until ready', () async {
PlayerNative.debugUseLinuxVideoBootstrap = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
final ready = Completer<void>();
test('a Linux video plane that cannot start fails initialization by name', () async {
PlayerNative.debugUseLinuxVideoPlane = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoPlane = null);
final calls = <MethodCall>[];
final errors = <String>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
methodHandler: (call) async {
calls.add(call);
if (call.method == 'initialize') return Future.value(73);
if (call.method == 'waitForVideoReady') return ready.future;
return Future.value(null);
},
testBody: () async {
final player = PlayerNative();
try {
final operation = player.setLogLevel('warn');
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(player.textureId, 73);
expect(player.textureIdListenable.value, 73);
expect(calls.any((call) => call.method == 'waitForVideoReady'), isTrue);
expect(calls.any((call) => call.method == 'observeProperty'), isFalse);
expect(calls.any((call) => call.method == 'setLogLevel'), isFalse);
ready.complete();
await operation;
expect(calls.any((call) => call.method == 'observeProperty'), isTrue);
expect(calls.where((call) => call.method == 'setLogLevel'), hasLength(1));
} finally {
if (!ready.isCompleted) ready.complete();
await player.dispose();
}
},
if (call.method == 'initialize') {
throw PlatformException(
code: 'VIDEO_PLANE_UNSUPPORTED',
message: 'compositor does not advertise wl_subcompositor',
);
});
testWidgets('Linux texture handoff stays black until playback restarts', (tester) async {
PlayerNative.debugUseLinuxVideoBootstrap = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
final ready = Completer<void>();
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return 73;
if (call.method == 'waitForVideoReady') {
await ready.future;
}
return null;
},
testBody: () async {
final player = PlayerNative();
await tester.pumpWidget(MaterialApp(home: Video(player: player)));
expect(find.byType(Texture), findsNothing);
final initialization = player.setLogLevel('warn');
await tester.pump();
expect(find.byType(Texture), findsOneWidget);
final videoBox = find.descendant(of: find.byType(Video), matching: find.byType(ColoredBox));
expect(tester.widget<ColoredBox>(videoBox).color, Colors.black);
ready.complete();
await initialization;
player.handlePlayerEvent('playback-restart', null);
await tester.pump();
await tester.pump();
expect(tester.widget<ColoredBox>(videoBox).color, Colors.transparent);
await tester.pumpWidget(const SizedBox());
await tester.runAsync(player.dispose);
},
);
}, timeout: const Timeout(Duration(seconds: 30)));
test('Linux texture bootstrap failure clears the provisional ID and retries', () async {
PlayerNative.debugUseLinuxVideoBootstrap = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
var initializeCount = 0;
var readinessCount = 0;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return 80 + initializeCount++;
if (call.method == 'waitForVideoReady' && readinessCount++ == 0) {
throw PlatformException(code: 'INIT_FAILED', message: 'GPU bootstrap failed');
}
return null;
},
testBody: () async {
final player = PlayerNative();
final subscription = player.streams.error.listen((error) => errors.add(error.message));
try {
await expectLater(
player.setLogLevel('warn'),
throwsA(isA<PlatformException>().having((error) => error.code, 'code', 'INIT_FAILED')),
throwsA(
isA<PlatformException>()
.having((error) => error.code, 'code', 'VIDEO_PLANE_UNSUPPORTED')
.having((error) => error.message, 'message', contains('wl_subcompositor')),
),
);
expect(player.textureId, isNull);
await player.setLogLevel('warn');
expect(initializeCount, 2);
expect(readinessCount, 2);
expect(player.textureId, 81);
// There is no second video path to degrade onto, so the only correct
// outcome is a refusal that names its cause. Nothing may run past it:
// a player that observed properties or accepted commands here would be
// one playing audio at a black window.
expect(calls.map((call) => call.method), ['initialize']);
// The refusal reaches the error stream a turn behind the throw.
await Future<void>.delayed(Duration.zero);
expect(errors.single, contains('wl_subcompositor'));
// Nor is the failure cached as a half-open player: the next caller
// asks the plane again and is refused by name again, rather than
// sliding through on a memoized "already initialized".
await expectLater(player.setLogLevel('warn'), throwsA(isA<PlatformException>()));
expect(calls.map((call) => call.method), ['initialize', 'initialize']);
} finally {
await subscription.cancel();
await player.dispose();
}
},
);
});
test('Linux disposal clears the published texture ID', () async {
PlayerNative.debugUseLinuxVideoBootstrap = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
test('the native hdr-output-changed event reaches the stream, and a typeless envelope does not', () async {
// The only notice Dart gets that dragging the window changed the answer to
// isHdrOutputSupported: Wayland raises no lifecycle event for it. Asserted
// on the real event channel rather than a fake stream because the failure
// mode is a rename on one side of the wire, which a fake cannot see.
var changes = 0;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return 73;
return null;
},
testBody: () async {
final player = PlayerNative();
final textureIds = <int?>[];
player.textureIdListenable.addListener(() => textureIds.add(player.textureIdListenable.value));
final subscription = player.streams.hdrOutputChanged.listen((_) => changes++);
try {
await player.setLogLevel('warn');
expect(player.textureId, 73);
await player.dispose();
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const codec = StandardMethodCodec();
expect(textureIds, [73, null]);
Future<void> sendEvent(Object? event) async {
final done = Completer<void>();
await messenger.handlePlatformMessage(
'com.plezy/mpv_player/events',
codec.encodeSuccessEnvelope(event),
(_) => done.complete(),
);
await done.future;
await Future<void>.delayed(Duration.zero);
}
await sendEvent(const {'type': 'event', 'name': 'hdr-output-changed'});
expect(changes, 1);
// The envelope needs both keys. Omitting `type` is not hypothetical -
// it is exactly what the native side once sent, and the event was
// dropped in silence, so the settings sheet kept whatever HDR verdict
// it had from before the window moved.
await sendEvent(const {'name': 'hdr-output-changed'});
expect(changes, 1);
// And the channel is still live afterwards: a malformed sibling must
// not take the subscription down with it.
await sendEvent(const {'type': 'event', 'name': 'hdr-output-changed'});
expect(changes, 2);
} finally {
await subscription.cancel();
await player.dispose();
}
},
);
});
test('non-Linux texture initialization skips the Linux readiness handshake', () async {
PlayerNative.debugUseLinuxVideoBootstrap = false;
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
test('the HDR output probe asks the plane by name and answers what it said', () async {
// The name is half the contract: nothing else in the app invokes
// isHDRSupported on the player channel, so a misspelling here would simply
// answer null forever and hide the HDR controls on every Linux session.
PlayerNative.debugUseLinuxVideoPlane = true;
addTearDown(() => PlayerNative.debugUseLinuxVideoPlane = null);
final calls = <MethodCall>[];
Object? reply;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
calls.add(call);
if (call.method == 'initialize') return 91;
if (call.method == 'waitForVideoReady') {
throw StateError('non-Linux backends must not use Linux readiness');
}
if (call.method == 'initialize') return true;
if (call.method == 'isHDRSupported') return reply;
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await player.setLogLevel('warn');
expect(player.textureId, 91);
expect(calls.any((call) => call.method == 'waitForVideoReady'), isFalse);
reply = true;
expect(await player.isHdrOutputSupported(), isTrue);
// Not cached: the output under the window is what the answer folds in,
// and that changes without Dart asking anything.
reply = false;
expect(await player.isHdrOutputSupported(), isFalse);
// A native that does not implement the method answers null, which is
// "no HDR" rather than a crash or an optimistic yes.
reply = null;
expect(await player.isHdrOutputSupported(), isFalse);
expect(calls.where((call) => call.method == 'isHDRSupported'), hasLength(3));
} finally {
await player.dispose();
}
@@ -0,0 +1,24 @@
import 'package:flutter_test/flutter_test.dart';
import '../../test_helpers/hdr_startup.dart';
/// The user's free-form mpv config is applied at the end of startup, after the
/// HDR preferences have been pushed. `hdr-enabled` and `hdr-tone-mapping` are
/// not mpv properties - the Linux plugin intercepts both and moves its own
/// persistent HDR state - so a config line naming either would win the plane
/// while SettingsService, which is the only thing the settings sheet renders
/// from, kept the app's value. Startup therefore refuses those two names in the
/// custom pass and logs the skip.
///
/// Removing the filter makes this fail on the write lists: the plane sees a
/// second `hdr-enabled`/`hdr-tone-mapping` carrying the config's value. See
/// installHdrStartupHarness for why this case needs an isolate of its own.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(installHdrStartupHarness);
testWidgets('a custom config naming the HDR properties cannot override the stored preferences', (tester) async {
await expectCustomConfigCannotOverrideHdrPreferences(tester);
});
}
@@ -0,0 +1,26 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../test_helpers/hdr_startup.dart';
/// The other arm of the preference. linux_hdr_startup_test.dart covers a stored
/// `true`, which on its own cannot tell "sends the preference" apart from "sends
/// `yes`": with HDR turned off, startup owes the plane an explicit `no` - the
/// property is not simply skipped, since the plane may still be describing HDR
/// from a previous session and would otherwise keep passthrough on.
///
/// Its own file because a second `VideoPlayerScreen` in the same isolate never
/// reaches `initialize` - see installHdrStartupHarness.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => installHdrStartupHarness(enableHdr: false));
testWidgets('HDR turned off sends the passthrough preference as no', (tester) async {
await expectStartupSurvivesHdrRefusal(
tester,
PlatformException(code: 'HDR_UNSUPPORTED', message: 'output is not in HDR'),
title: 'Linux HDR disabled startup video',
);
});
}
@@ -0,0 +1,27 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../test_helpers/hdr_startup.dart';
/// The negative control for the Linux HDR startup tolerance.
///
/// Swallowing a refused `hdr-enabled` write is scoped to the Linux video path;
/// everywhere else an unexpected refusal must still abort initialization, which
/// is the behaviour that shipped before this feature. Without this test the
/// tolerance could be widened to every platform and both sibling tests would
/// still pass.
///
/// Separate file, one test - see installHdrStartupHarness for why these cannot
/// share an isolate.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => installHdrStartupHarness(linuxVideoPath: false));
testWidgets('a refusal still aborts startup off the Linux video path', (tester) async {
await expectStartupAbortsOnHdrRefusal(
tester,
PlatformException(code: 'HDR_UNSUPPORTED', message: 'output is not in HDR'),
);
});
}
@@ -0,0 +1,32 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../test_helpers/hdr_startup.dart';
/// On Linux the `hdr-enabled` write must survive *whatever* it fails with, not
/// only the one code the native plane answers with today.
///
/// The refusal used here deliberately carries no HDR-specific code, because the
/// tolerance has to hold for more than one failure. `HDR_UNSUPPORTED` is the
/// plane saying it can never carry HDR, but a refused colour transaction - mpv
/// declining one of the four output properties - comes back as a generic
/// property failure instead. A narrow `if (code != 'HDR_UNSUPPORTED') rethrow`
/// would pass every other test in the suite and still turn "this session cannot
/// do HDR" into "this session cannot play video". Why the tolerance exists at
/// all is documented where it lives, in VideoPlayerScreen.
///
/// Separate file, one test - see installHdrStartupHarness for why these cannot
/// share an isolate. The negative control is in
/// linux_hdr_startup_non_linux_test.dart.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(installHdrStartupHarness);
testWidgets('a refusal that is not HDR_UNSUPPORTED still does not stop playback starting', (tester) async {
await expectStartupSurvivesHdrRefusal(
tester,
PlatformException(code: 'SET_PROPERTY_FAILED', message: 'property not found'),
);
});
}
@@ -0,0 +1,35 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../test_helpers/hdr_startup.dart';
/// Startup pushes the HDR preference at a native plane that is allowed to refuse
/// it: the Linux plugin answers `HDR_UNSUPPORTED` when the video plane and
/// compositor cannot describe HDR, or the output the window sits on is not in HDR.
/// That gate runs before any source is considered - startup happens before the
/// media is open - so a plain SDR monitor is enough to trigger it, and losing
/// playback over it would make the feature worse than not having it.
///
/// `audio-delay` is the write immediately after the HDR block, so its arrival is
/// what says initialization carried on past the refusal - and it is asserted
/// *after* `hdr-enabled` so a refactor that reorders the two cannot leave this
/// test passing while proving nothing. Reinstating the rethrow makes it fail,
/// because `audio-delay` never arrives.
///
/// Its companion - that the tolerance is *not* narrowed to `HDR_UNSUPPORTED`,
/// so any refusal survives - is in linux_hdr_startup_refusal_test.dart; see
/// installHdrStartupHarness for why the two cannot share an isolate. The
/// negative control, that a non-Linux host still aborts, is in
/// linux_hdr_startup_non_linux_test.dart.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(installHdrStartupHarness);
testWidgets('an SDR output refusing HDR passthrough does not stop playback starting', (tester) async {
await expectStartupSurvivesHdrRefusal(
tester,
PlatformException(code: 'HDR_UNSUPPORTED', message: 'output is not in HDR'),
);
});
}
@@ -0,0 +1,28 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../test_helpers/hdr_startup.dart';
/// Startup pushes the stored tone-mapping mode at the plane, and the refusal is
/// swallowed for the same reason `hdr-enabled`'s is: an older libmpv rejects the
/// property outright, and a tone-mapping preference is no reason to fail
/// playback. And as with `hdr-enabled`, the plugin holds a mode of its own and
/// reverts it on a refused transaction, so swallowing alone leaves Dart naming
/// `player` while the plane tone-maps in the compositor - a disagreement the
/// settings sheet renders and no later write corrects.
///
/// Dropping the correction in video_player_screen.dart makes this fail on the
/// stored-mode expectation. See installHdrStartupHarness for why this case needs
/// an isolate of its own.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(installHdrStartupHarness);
testWidgets('a refused tone-mapping write leaves the stored mode matching the plane', (tester) async {
await expectRefusedToneMappingRestoresStoredMode(
tester,
PlatformException(code: 'SET_PROPERTY_FAILED', message: 'property not found'),
);
});
}
@@ -1,91 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player_screen.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/mock_player_channels.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
PlayerNative.debugUseLinuxVideoBootstrap = true;
});
tearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
testWidgets('Linux mounts its provisional texture while initialization is pending', (tester) async {
final ready = Completer<void>();
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
return switch (call.method) {
'initialize' => Future<Object?>.value(73),
'waitForVideoReady' => ready.future,
_ => Future<Object?>.value(null),
};
},
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
final key = GlobalKey<VideoPlayerScreenState>();
await tester.pumpWidget(_screen(key));
await _pumpUntil(tester, () => calls.any((call) => call.method == 'waitForVideoReady'));
expect(tester.widget<Texture>(find.byType(Texture)).textureId, 73);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(key.currentState?.player, isNull);
await tester.pumpWidget(const SizedBox.shrink());
await _pumpUntil(
tester,
() => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'),
);
ready.complete();
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 10)));
await tester.pump();
},
);
});
}
Widget _screen(GlobalKey<VideoPlayerScreenState> key) {
return ChangeNotifierProvider(
create: (_) => PlaybackStateProvider(),
child: MaterialApp(
home: VideoPlayerScreen(
key: key,
metadata: testMediaItem(title: 'Linux startup test video'),
isOffline: true,
),
),
);
}
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
for (var i = 0; i < 200 && !condition(); i++) {
await tester.pump(const Duration(milliseconds: 10));
if (!condition()) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 5)));
}
}
expect(condition(), isTrue);
}
@@ -16,6 +16,7 @@ import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/mock_player_channels.dart';
import '../../test_helpers/prefs.dart';
import '../../test_helpers/pump.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -384,7 +385,7 @@ void main() {
testBody: () async {
final key = GlobalKey<VideoPlayerScreenState>();
await tester.pumpWidget(_screen(key));
await _pumpUntil(tester, () => calls.any((call) => call.method == 'dispose'));
await pumpUntil(tester, () => calls.any((call) => call.method == 'dispose'));
expect(key.currentState?.player, isNull);
expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing);
@@ -392,7 +393,7 @@ void main() {
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1));
failedDispose.complete();
await _pumpUntil(tester, () => find.widgetWithText(FilledButton, 'Retry').evaluate().isNotEmpty);
await pumpUntil(tester, () => find.widgetWithText(FilledButton, 'Retry').evaluate().isNotEmpty);
final retryButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Retry'));
final retryFocusable = tester.widget<FocusableButton>(
@@ -400,7 +401,7 @@ void main() {
);
retryButton.onPressed!();
retryFocusable.onPressed!();
await _pumpUntil(tester, () => initializeCount == 2);
await pumpUntil(tester, () => initializeCount == 2);
expect(initializeCount, 2);
expect(key.currentState?.player, isNull);
@@ -409,7 +410,7 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
replacementInitialize.completeError(PlatformException(code: 'late_failure', message: 'forced late failure'));
await _pumpUntil(tester, () => calls.where((call) => call.method == 'dispose').length == 2);
await pumpUntil(tester, () => calls.where((call) => call.method == 'dispose').length == 2);
expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing);
expect(initializeCount, 2);
@@ -432,13 +433,3 @@ Widget _screen(GlobalKey<VideoPlayerScreenState> key) {
),
);
}
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
for (var i = 0; i < 200 && !condition(); i++) {
await tester.pump(const Duration(milliseconds: 10));
if (!condition()) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 5)));
}
}
expect(condition(), isTrue);
}
@@ -211,8 +211,9 @@ class FakePlayer implements Player {
@override
bool get audioPassthroughActive => false;
// Audio only; there is no video output to carry HDR.
@override
int? get textureId => null;
Future<bool> isHdrOutputSupported() async => false;
@override
String get playerType => 'fake';
+334
View File
@@ -0,0 +1,334 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player_screen.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:provider/provider.dart';
import 'media_items.dart';
import 'mock_player_channels.dart';
import 'prefs.dart';
import 'pump.dart';
/// Shared scaffold for the four Linux HDR startup cases.
///
/// Each lives in its own file with one test, because a second `VideoPlayerScreen`
/// in the same isolate never reaches `initialize`. Measured, repeatedly: the
/// second test's wait fails with `calls=[isModeChanged, isHDRChanged,
/// setVideoRect]` and no `initialize` among them, so nothing the HDR block does
/// can be observed.
///
/// Partly explained, and the gap is worth knowing before you retry. `PlayerBase`
/// keeps one event-channel owner at a time; a successor built before the
/// predecessor's release settles inherits that future, and `PlayerBase.invoke`
/// awaits it before touching the channel, returning null on timeout rather than
/// calling (player_base.dart:1035-1041). That accounts for the missing
/// `initialize`, and for `isModeChanged`/`isHDRChanged` arriving anyway since
/// DisplayModeService drives the channel directly. It does *not* account for
/// `setVideoRect`, which goes through the same gate and still lands - so the
/// picture is incomplete and that is the loose end to pull on.
///
/// Two remedies were measured and neither works. Shortening
/// `debugNativeOwnershipDisposeTimeout` only makes `invoke` give up sooner, which
/// is still a dropped call; at its 3 s default it outlasts [pumpUntil]'s 2 s
/// budget, so the wait fails first. Draining the predecessor's `dispose`/`cancel`
/// does not help either - the owner entry clears a microtask after those calls
/// land (player_base.dart:1426-1434).
Future<void> installHdrStartupHarness({bool linuxVideoPath = true, bool enableHdr = true}) async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
// Non-zero so the write that follows the HDR block actually happens.
await SettingsService.instance.write(SettingsService.audioSyncOffset, 250);
// Seeded rather than left at its default, so the value the startup path sends
// can be asserted against a preference this harness chose - both arms of it.
await SettingsService.instance.write(SettingsService.enableHDR, enableHdr);
// Reaches the Linux-only tolerance on any host, so this is real coverage
// everywhere rather than something only Linux CI ever runs - and forcing it
// off is what makes the non-Linux abort testable at all.
PlayerNative.debugUseLinuxVideoPlane = linuxVideoPath;
addTearDown(() => PlayerNative.debugUseLinuxVideoPlane = null);
}
/// Answers like the native plane - `initialize` succeeds with a plain `true`,
/// the surface itself being the compositor's subsurface rather than anything
/// Dart holds - but fails the write of [property] with [refusal].
Future<Object?> Function(MethodCall) _refusingPlane(
List<MethodCall> calls,
PlatformException refusal, {
String property = 'hdr-enabled',
}) => (call) {
calls.add(call);
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == property) {
return Future<Object?>.error(refusal);
}
return switch (call.method) {
'initialize' => Future<Object?>.value(true),
_ => Future<Object?>.value(null),
};
};
// The title reaches the "VideoPlayerScreen initialized for:" log line, so each
// case names itself in any log a failure is diagnosed from.
Future<void> _mountPlayerScreen(WidgetTester tester, String title) => tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => PlaybackStateProvider(),
child: MaterialApp(
home: VideoPlayerScreen(metadata: testMediaItem(title: title), isOffline: true),
),
),
);
/// Mounts the player screen against a native plane that fails the `hdr-enabled`
/// write with [refusal], and asserts initialization ran through the HDR block
/// into the `audio-delay` write that follows it - and that the stored
/// preference came out of the refusal agreeing with the plane.
Future<void> expectStartupSurvivesHdrRefusal(
WidgetTester tester,
PlatformException refusal, {
String title = 'Linux HDR startup test video',
}) async {
// Read before mounting, because startup rewrites it: comparing the wire value
// against a preference the refusal has already corrected would compare the
// correction with itself and pass whatever was sent.
final seededHdrEnabled = SettingsService.instance.read(SettingsService.enableHDR);
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: _refusingPlane(calls, refusal),
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
await _mountPlayerScreen(tester, title);
await pumpUntil(
tester,
() => _propertyWrites(calls).contains('audio-delay'),
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
);
// The write was attempted, not skipped, and the sentinel came after it:
// tolerating the refusal is only meaningful if the preference was actually
// pushed, and `audio-delay` only proves anything downstream of the block.
expect(_propertyWrites(calls), containsAllInOrder(['hdr-enabled', 'audio-delay']));
// And it carried the seeded preference, not a hard-coded arm: with the
// ternary inverted, or a different preference read, everything asserted
// above still holds because only the property *name* is involved.
expect(_valueWrites(calls, 'hdr-enabled'), [_hdrEnabledWire(seededHdrEnabled)]);
// The refused transaction hands the plugin's hdr_wanted back to what it
// held before the write, which on a plugin this session just created is
// off. Dart has to follow it down: the settings switch renders straight
// off this preference, so leaving it on shows HDR enabled over an SDR
// plane, and every later internal re-apply reads the native side - the
// two would stay apart until the user toggled twice. Asserted on both
// arms of the preference, so the stored-off arm proves the correction
// does not disturb a value that was already right.
expect(SettingsService.instance.read(SettingsService.enableHDR), isFalse);
// Unmount and let the dispose/cancel round-trip land while the mock
// handlers are still registered, so teardown is deterministic instead of
// racing withMockPlayerChannels' finally. It does not make a second mount
// in this isolate work - see the note on installHdrStartupHarness.
await tester.pumpWidget(const SizedBox.shrink());
await pumpUntil(
tester,
() => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'),
describe: () =>
'calls=${calls.map((c) => c.method).toList()} events=${eventCalls.map((c) => c.method).toList()}',
);
},
);
}
/// Startup pushes the tone-mapping preference just before `hdr-enabled`, and a
/// refusal there is swallowed the same way. The plugin, though, reverts to the
/// mode it last accepted - the compositor default, since nothing has moved it
/// this session - so the stored preference has to follow, or the settings sheet
/// keeps naming a mode the plane never entered with no way back but a manual
/// toggle.
Future<void> expectRefusedToneMappingRestoresStoredMode(WidgetTester tester, PlatformException refusal) async {
await SettingsService.instance.write(SettingsService.hdrToneMapping, HdrToneMapping.player);
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: _refusingPlane(calls, refusal, property: 'hdr-tone-mapping'),
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
await _mountPlayerScreen(tester, 'Linux HDR tone-mapping refusal video');
await pumpUntil(
tester,
() => _propertyWrites(calls).contains('audio-delay'),
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
);
// The stored mode is what was pushed and refused, so the correction below
// is a real change of mind rather than a value that was never asked for.
expect(_valueWrites(calls, 'hdr-tone-mapping'), ['player']);
expect(_propertyWrites(calls), containsAllInOrder(['hdr-tone-mapping', 'audio-delay']));
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.compositor);
// Same deterministic teardown as [expectStartupSurvivesHdrRefusal].
await tester.pumpWidget(const SizedBox.shrink());
await pumpUntil(
tester,
() => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'),
describe: () =>
'calls=${calls.map((c) => c.method).toList()} events=${eventCalls.map((c) => c.method).toList()}',
);
},
);
}
/// The custom mpv config is free-form `name=value` text, applied after startup
/// has pushed the stored HDR preferences - and neither `hdr-enabled` nor
/// `hdr-tone-mapping` is an mpv property: the Linux plugin intercepts both and
/// moves the plane's own HDR state. An entry for either would therefore land
/// last, change the plane, and never reach SettingsService, which is what the
/// settings sheet renders from. Startup drops those two names for that reason.
///
/// Seeded with a config that contradicts both preferences, so restoring the
/// unfiltered pass fails here: the plane would see a second write of each
/// carrying the config's value while the stored preferences kept the app's. A
/// third, ordinary entry is expected to survive, so a filter that simply
/// skipped the whole pass would fail too.
Future<void> expectCustomConfigCannotOverrideHdrPreferences(WidgetTester tester) async {
await SettingsService.instance.write(SettingsService.hdrToneMapping, HdrToneMapping.player);
await SettingsService.instance.write(
SettingsService.mpvConfigText,
// The last four are real mpv properties the video plane owns and caches, so
// a config write would desynchronise that cache from mpv - see
// _appOwnedMpvProperties. The first two are not mpv properties at all.
'hdr-enabled=no\n'
'hdr-tone-mapping=compositor\n'
'target-trc=pq\n'
'target-prim=bt.2020\n'
'target-peak=4000\n'
'tone-mapping=bt.2390\n'
'sub-scale=1.5\n',
);
// Read before mounting for the same reason as [expectStartupSurvivesHdrRefusal].
final seededHdrEnabled = SettingsService.instance.read(SettingsService.enableHDR);
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
// A plane that accepts everything: the hazard here is ordering, not refusal.
methodHandler: (call) async {
calls.add(call);
return call.method == 'initialize' ? true : null;
},
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
await _mountPlayerScreen(tester, 'Linux HDR custom config video');
// `volume-max` is the write immediately after the custom-config pass, so
// its arrival is what makes the lists below complete rather than merely
// not-appended-to-yet.
await pumpUntil(
tester,
() => _propertyWrites(calls).contains('volume-max'),
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
);
// One write of each, carrying the preference rather than the config line
// that contradicts it.
expect(_valueWrites(calls, 'hdr-enabled'), [_hdrEnabledWire(seededHdrEnabled)]);
expect(_valueWrites(calls, 'hdr-tone-mapping'), ['player']);
// The rest of the config still reaches mpv: the skip goes by name.
expect(_valueWrites(calls, 'sub-scale'), ['1.5']);
// And none of the four the plane owns reached mpv at all: one arriving
// behind the plane's back leaves its cache describing a colour state mpv
// does not hold, and the next transaction then skips the write that would
// have corrected it.
for (final owned in ['target-trc', 'target-prim', 'target-peak', 'tone-mapping']) {
expect(_valueWrites(calls, owned), isEmpty, reason: '$owned is owned by the video plane');
}
// And nothing dragged the preferences down to what the config asked for,
// so the settings sheet and the plane still describe the same session.
expect(SettingsService.instance.read(SettingsService.enableHDR), seededHdrEnabled);
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.player);
// Same deterministic teardown as [expectStartupSurvivesHdrRefusal].
await tester.pumpWidget(const SizedBox.shrink());
await pumpUntil(
tester,
() => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'),
describe: () =>
'calls=${calls.map((c) => c.method).toList()} events=${eventCalls.map((c) => c.method).toList()}',
);
},
);
}
/// The negative side of [expectStartupSurvivesHdrRefusal]: with the Linux video
/// path forced off, the same refusal must abort initialization rather than be
/// swallowed, so `audio-delay` never follows it and the stored preference is
/// left exactly as it was - the reconciliation lives inside the tolerance, and
/// a rethrown refusal says nothing about what the plane settled on.
Future<void> expectStartupAbortsOnHdrRefusal(WidgetTester tester, PlatformException refusal) async {
final calls = <MethodCall>[];
final seededHdrEnabled = SettingsService.instance.read(SettingsService.enableHDR);
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: _refusingPlane(calls, refusal),
testBody: () async {
await _mountPlayerScreen(tester, 'Non-Linux HDR refusal video');
// Wait for the abort to *show*, rather than for a fixed budget to elapse.
// The error screen is the positive marker that initialization gave up, so
// the absence asserted below is final rather than merely not-yet.
await pumpUntil(
tester,
() => find.widgetWithText(FilledButton, 'Retry').evaluate().isNotEmpty,
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
);
expect(_propertyWrites(calls), contains('hdr-enabled'));
expect(_propertyWrites(calls), isNot(contains('audio-delay')));
expect(_valueWrites(calls, 'hdr-enabled'), [_hdrEnabledWire(seededHdrEnabled)]);
expect(SettingsService.instance.read(SettingsService.enableHDR), seededHdrEnabled);
// Same deterministic teardown as the positive helper. There is no release
// to drain here: initialization aborted, so no dispose round-trip follows.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
},
);
}
List<String> _propertyWrites(List<MethodCall> calls) => [
for (final call in calls)
if (call.method == 'setProperty') (call.arguments as Map)['name'] as String,
];
/// The values every `setProperty` write of [name] carried, in order.
List<String> _valueWrites(List<MethodCall> calls, String name) => [
for (final call in calls)
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == name)
(call.arguments as Map)['value'] as String,
];
/// The wire value startup owes a preference seeded to [enabled].
String _hdrEnabledWire(bool enabled) => enabled ? 'yes' : 'no';
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
/// Pumps frames until [condition] holds, yielding to the real event loop
/// between frames so work that is not driven by the test clock - platform
/// channel replies, microtask chains behind `runAsync` - can land.
///
/// Fails the test if [condition] is still false after 200 frames - 2 s of test
/// clock, plus a 5 ms real-time yield per frame. Worth knowing that 2 s is
/// shorter than `PlayerBase.debugNativeOwnershipDisposeTimeout`'s 3 s default,
/// so a wait blocked behind a player handover expires here first and reports an
/// empty observation rather than the timeout. Pass [describe] to attach the
/// observed state to that failure.
Future<void> pumpUntil(WidgetTester tester, bool Function() condition, {String Function()? describe}) async {
for (var i = 0; i < 200 && !condition(); i++) {
await tester.pump(const Duration(milliseconds: 10));
if (!condition()) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 5)));
}
}
expect(condition(), isTrue, reason: describe == null ? null : 'observed ${describe()}');
}
+344 -24
View File
@@ -1,18 +1,25 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/mpv/player/player.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/mpv/player/player_state.dart';
import 'package:plezy/mpv/player/player_streams.dart';
import 'package:plezy/screens/settings/subtitle_styling_screen.dart';
import 'package:plezy/services/sleep_timer_service.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/sleep_timer_service.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/theme.dart';
@@ -77,7 +84,7 @@ void main() {
appliedRates.add(rate);
},
);
await _pumpHostedSheet(tester, player);
await _pumpSheetViaOverlayRoute(tester, player);
await tester.tap(find.text('Playback Speed'));
await tester.pumpAndSettle();
@@ -116,10 +123,10 @@ void main() {
testWidgets('failed HDR write restores the toggle without persisting', (tester) async {
final propertyWrite = Completer<void>();
var writeCount = 0;
final writes = <(String, String)>[];
final player = _FakeSettingsPlayer(
onSetProperty: (_, _) {
writeCount++;
onSetProperty: (name, value) {
writes.add((name, value));
return propertyWrite.future;
},
);
@@ -143,14 +150,20 @@ void main() {
expect(tester.takeException(), isNull);
expect(tester.widget<Switch>(toggle).value, isTrue);
expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue);
expect(writeCount, 1);
// The name carries as much weight as the count: the plane intercepts this
// exact property, and any other name falls through to mpv as a real write.
expect(writes, [('hdr-enabled', 'no')]);
});
testWidgets('accepted HDR write persists once', (tester) async {
var writeCount = 0;
// The switch springing back on its own reads as a lost tap. This message is
// what tells the user the surface itself cannot carry HDR and no retry will
// change that, so it has to survive any rework of the write path.
testWidgets('a plane that can never carry HDR says so', (tester) async {
final writes = <(String, String)>[];
final player = _FakeSettingsPlayer(
onSetProperty: (_, _) async {
writeCount++;
onSetProperty: (name, value) async {
writes.add((name, value));
throw PlatformException(code: 'HDR_UNSUPPORTED', message: 'no colour-management protocol');
},
);
await _pumpSheet(tester, player: player, supportsHdrControl: true);
@@ -162,38 +175,291 @@ void main() {
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(writeCount, 1);
expect(find.text(t.videoSettings.hdrUnsupported), findsOneWidget);
// Once. A refusal is not something to compensate for: nothing was recorded,
// so there is nothing to put back and no second write to explain.
expect(writes, [('hdr-enabled', 'no')]);
expect(tester.widget<Switch>(toggle).value, isTrue);
expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue);
});
testWidgets('an accepted HDR write pushes one hdr-enabled write per toggle', (tester) async {
final writes = <(String, String)>[];
final player = _FakeSettingsPlayer(onSetProperty: (name, value) async => writes.add((name, value)));
await _pumpSheet(tester, player: player, supportsHdrControl: true);
await tester.scrollUntilVisible(find.text('HDR'), 500, scrollable: find.byType(Scrollable).first);
final tile = find.ancestor(of: find.text('HDR'), matching: find.byType(ListTile)).first;
final toggle = find.descendant(of: tile, matching: find.byType(Switch));
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(writes, [('hdr-enabled', 'no')]);
expect(tester.widget<Switch>(toggle).value, isFalse);
expect(SettingsService.instance.read(SettingsService.enableHDR), isFalse);
// Toggled back so the expectation cannot be met by a sheet that sends 'no'
// whichever way the switch went.
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(writes, [('hdr-enabled', 'no'), ('hdr-enabled', 'yes')]);
expect(tester.widget<Switch>(toggle).value, isTrue);
expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue);
});
testWidgets('hides the HDR controls when the host declares the surface cannot carry HDR', (tester) async {
await _pumpSheet(tester);
// Scroll past where the HDR rows would sit. Without a following anchor the
// absence would also be satisfied by the ListView simply not having built
// that far yet, which is not the contract under test.
await tester.scrollUntilVisible(find.text('Auto-Play Next'), 500, scrollable: find.byType(Scrollable).first);
expect(find.text('HDR'), findsNothing);
expect(find.text('HDR Tone Mapping'), findsNothing);
});
// The sheet resolves both the capability probe and the tone-mapping row through
// PlayerNative.usesLinuxVideoPlane, so setting the documented override puts the
// plane's behaviour under test on any host.
group('on the Linux video plane', () {
setUp(() {
PlayerNative.debugUseLinuxVideoPlane = true;
});
tearDown(() {
PlayerNative.debugUseLinuxVideoPlane = null;
});
// supportsHdrControl left null so the sheet asks the player, which is the path
// that ships on Linux. The cases above inject the answer and so cover only the
// gate, not the probe behind it.
testWidgets('hides the HDR controls when the capability probe answers no', (tester) async {
final player = _FakeSettingsPlayer(hdrOutputSupported: false);
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
expect(find.text('Auto-Play Next'), findsOneWidget, reason: 'the list should be fully built');
expect(find.text('HDR'), findsNothing);
expect(find.text('HDR Tone Mapping'), findsNothing);
});
testWidgets('a player reporting an HDR output reveals the controls', (tester) async {
final player = _FakeSettingsPlayer(hdrOutputSupported: true);
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
expect(find.text('HDR'), findsOneWidget);
expect(find.text('HDR Tone Mapping'), findsOneWidget);
});
// Dragging the window onto an HDR monitor raises no lifecycle event on
// Wayland, so this stream is the sheet's only notice that the probe now
// answers differently.
testWidgets('an HDR output arriving under the window reveals the controls', (tester) async {
final player = _FakeSettingsPlayer(hdrOutputSupported: false);
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
expect(find.text('HDR'), findsNothing);
player.hdrOutputSupported = true;
player.hdrOutputChanged.add(null);
await tester.pumpAndSettle();
expect(find.text('HDR'), findsOneWidget);
expect(find.text('HDR Tone Mapping'), findsOneWidget);
});
testWidgets('losing the HDR output takes the controls away again', (tester) async {
final player = _FakeSettingsPlayer(hdrOutputSupported: true);
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
expect(find.text('HDR'), findsOneWidget);
player.hdrOutputSupported = false;
player.hdrOutputChanged.add(null);
await tester.pumpAndSettle();
expect(find.text('Auto-Play Next'), findsOneWidget, reason: 'the list should be fully built');
expect(find.text('HDR'), findsNothing);
expect(find.text('HDR Tone Mapping'), findsNothing);
});
testWidgets('the output-changed subscription does not outlive the sheet', (tester) async {
final player = _FakeSettingsPlayer(hdrOutputSupported: false);
await _pumpSheet(tester, player: player, supportsHdrControl: null, height: 4000);
final probesWhileMounted = player.probeCount;
await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
await tester.pumpAndSettle();
player.hdrOutputSupported = true;
player.hdrOutputChanged.add(null);
await tester.pumpAndSettle();
// The plane outlives any one sheet, so a subscription left behind keeps
// probing - and setState()s - on a disposed State.
expect(player.hdrOutputChanged.hasListener, isFalse);
expect(player.probeCount, probesWhileMounted);
expect(tester.takeException(), isNull);
});
testWidgets('selecting a tone-mapping mode pushes it to mpv and persists it', (tester) async {
final writes = <(String, String)>[];
final player = _FakeSettingsPlayer(onSetProperty: (name, value) async => writes.add((name, value)));
await _pumpSheet(tester, player: player, supportsHdrControl: true, withSheetHost: true);
await tester.scrollUntilVisible(find.text('HDR Tone Mapping'), 500, scrollable: find.byType(Scrollable).first);
await tester.tap(find.text('HDR Tone Mapping'));
await tester.pumpAndSettle();
await tester.tap(find.text('Player'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(writes, [('hdr-tone-mapping', 'player')]);
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.player);
});
testWidgets('a refused tone-mapping write leaves the stored mode alone', (tester) async {
final writes = <(String, String)>[];
final player = _FakeSettingsPlayer(
onSetProperty: (name, value) async {
writes.add((name, value));
throw StateError('rejected');
},
);
await _pumpSheet(tester, player: player, supportsHdrControl: true, withSheetHost: true);
await tester.scrollUntilVisible(find.text('HDR Tone Mapping'), 500, scrollable: find.byType(Scrollable).first);
await tester.tap(find.text('HDR Tone Mapping'));
await tester.pumpAndSettle();
await tester.tap(find.text('Player'));
await tester.pumpAndSettle();
// mpv is asked before the setting is written, precisely so a refusal
// cannot leave the stored mode claiming one the player never entered.
expect(tester.takeException(), isNull);
expect(writes, [('hdr-tone-mapping', 'player')]);
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.compositor);
});
});
// The refusals covered above all come from the player. This is the other half:
// the player takes the value and the store loses it, which is the case that
// used to leave the plane carrying a policy neither the sheet nor the stored
// preference named for the rest of the session.
group('when the preference store refuses the write', () {
late _RejectingPrefsStore store;
setUp(() async {
// The tone-mapping row is gated on the plane. Installing the store belongs
// out here too: SharedPreferencesWithCache binds the platform when it is
// created, and creating it reads the store off disk, which a testWidgets
// body cannot await.
PlayerNative.debugUseLinuxVideoPlane = true;
store = _RejectingPrefsStore(
// Seeded with the values both controls start on, so a rejected write is
// a rejected *overwrite* and the surviving value is an explicit one
// rather than the absence of a key.
initial: {SettingsService.enableHDR.key: true, SettingsService.hdrToneMapping.key: 'compositor'},
refused: {SettingsService.enableHDR.key, SettingsService.hdrToneMapping.key},
);
SharedPreferencesAsyncPlatform.instance = store;
// resetSharedPreferencesForTest already registered the teardown that puts
// the previous platform back.
BaseSharedPreferencesService.resetForTesting();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
tearDown(() {
PlayerNative.debugUseLinuxVideoPlane = null;
});
testWidgets('a lost HDR preference write puts the plane back on the stored policy', (tester) async {
final writes = <(String, String)>[];
final player = _FakeSettingsPlayer(onSetProperty: (name, value) async => writes.add((name, value)));
await _pumpSheet(tester, player: player, supportsHdrControl: true);
await tester.scrollUntilVisible(find.text('HDR'), 500, scrollable: find.byType(Scrollable).first);
final tile = find.ancestor(of: find.text('HDR'), matching: find.byType(ListTile)).first;
final toggle = find.descendant(of: tile, matching: find.byType(Switch));
expect(tester.widget<Switch>(toggle).value, isTrue);
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// The plane accepted 'no' and the store then lost it, so the plane has to
// be told 'yes' again. Leaving it at 'no' is the divergence.
expect(writes, [('hdr-enabled', 'no'), ('hdr-enabled', 'yes')]);
expect(tester.widget<Switch>(toggle).value, isTrue);
expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue);
expect(await store.durable(SettingsService.enableHDR.key), isTrue);
});
testWidgets('a lost tone-mapping preference write puts the plane back on the stored mode', (tester) async {
final writes = <(String, String)>[];
final player = _FakeSettingsPlayer(onSetProperty: (name, value) async => writes.add((name, value)));
await _pumpSheet(tester, player: player, supportsHdrControl: true, withSheetHost: true);
await tester.scrollUntilVisible(find.text('HDR Tone Mapping'), 500, scrollable: find.byType(Scrollable).first);
await tester.tap(find.text('HDR Tone Mapping'));
await tester.pumpAndSettle();
await tester.tap(find.text('Player'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(writes, [('hdr-tone-mapping', 'player'), ('hdr-tone-mapping', 'compositor')]);
expect(SettingsService.instance.read(SettingsService.hdrToneMapping), HdrToneMapping.compositor);
expect(await store.durable(SettingsService.hdrToneMapping.key), 'compositor');
// The pick did not take, so the picker stays open with the tick where it
// was. A tick on 'Player' would mean the sheet is showing a mode the
// stored preference does not name.
expect(_tickOn('Compositor'), findsOneWidget);
expect(_tickOn('Player'), findsNothing);
});
});
}
/// The tick marking the selected option in one of the sheet's picker views.
Finder _tickOn(String label) => find.descendant(
of: find.ancestor(of: find.text(label), matching: find.byType(ListTile)).first,
matching: find.byIcon(Symbols.check_rounded),
);
Future<void> _pumpSheet(
WidgetTester tester, {
bool canControl = false,
Player? player,
bool supportsHdrControl = false,
// Explicitly false by default so the sheet does not consult the platform.
// Pass null to exercise the capability probe instead.
bool? supportsHdrControl = false,
// Option views that dismiss themselves on selection reach
// OverlaySheetController.of(), which asserts without a host above it.
bool withSheetHost = false,
// The default is short enough that the ListView is lazy: callers that need a
// row present without dragging to it pass a taller sheet, which builds all of
// them.
double height = 700,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [testMonoTokensAnimated]),
home: Scaffold(
body: SizedBox(
final sheet = SizedBox(
width: 900,
height: 700,
height: height,
child: VideoSettingsSheet(
player: player ?? _FakeSettingsPlayer(),
supportsHdrControl: supportsHdrControl,
trackControlsState: TrackControlsState(canControl: canControl),
),
),
),
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [testMonoTokensAnimated]),
home: Scaffold(body: withSheetHost ? OverlaySheetHost(child: sheet) : sheet),
),
);
await tester.pumpAndSettle();
}
Future<void> _pumpHostedSheet(WidgetTester tester, Player player) async {
Future<void> _pumpSheetViaOverlayRoute(WidgetTester tester, Player player) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [testMonoTokensAnimated]),
@@ -221,8 +487,20 @@ Future<void> _pumpHostedSheet(WidgetTester tester, Player player) async {
}
class _FakeSettingsPlayer implements Player {
_FakeSettingsPlayer({this.onSetProperty, this.onSetRate})
: _streams = PlayerStreams(
_FakeSettingsPlayer({this.onSetProperty, this.onSetRate, this.hdrOutputSupported = false});
/// The plane's notice that the output under the window changed, which is the
/// only thing that moves [isHdrOutputSupported]'s answer while a sheet is up.
/// Closed by [dispose], which the tests that emit on it call through
/// `addTearDown`.
final hdrOutputChanged = StreamController<void>.broadcast();
@override
Future<void> dispose({bool preserveDisplayMode = false}) async {
await hdrOutputChanged.close();
}
late final PlayerStreams _streams = PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
@@ -241,11 +519,19 @@ class _FakeSettingsPlayer implements Player {
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
hdrOutputChanged: hdrOutputChanged.stream,
);
final PlayerStreams _streams;
final Future<void> Function(String name, String value)? onSetProperty;
final Future<void> Function(double rate)? onSetRate;
bool hdrOutputSupported;
int probeCount = 0;
@override
Future<bool> isHdrOutputSupported() async {
probeCount++;
return hdrOutputSupported;
}
@override
PlayerState get state => const PlayerState();
@@ -272,3 +558,37 @@ class _FakeSettingsPlayer implements Player {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// A preference store that loses the durable half of a write.
///
/// Substituting the platform is how this suite supplies a store at all (see
/// [resetSharedPreferencesForTest]), and it is the only layer that can be lost:
/// `SharedPreferencesWithCache` sits above it and is not subclassable.
final class _RejectingPrefsStore extends InMemorySharedPreferencesAsync {
_RejectingPrefsStore({required Map<String, Object> initial, required this.refused}) : super.withData(initial);
/// Only these keys. Creating the cache runs the legacy-to-async migration,
/// which stores its own completion marker and must be allowed to.
final Set<String> refused;
/// What survived, which is what the next launch reads. Not
/// `SettingsService.read`: that answers from the in-process copy, which a
/// refused write moves before the platform call it then fails.
Future<Object?> durable(String key) async {
final stored = await getPreferences(
GetPreferencesParameters(filter: PreferencesFilters(allowList: {key})),
const SharedPreferencesOptions(),
);
return stored[key];
}
Future<bool> _refuse(String key) async => throw StateError('the preference store refused "$key"');
@override
Future<bool> setBool(String key, bool value, SharedPreferencesOptions options) =>
refused.contains(key) ? _refuse(key) : super.setBool(key, value, options);
@override
Future<bool> setString(String key, String value, SharedPreferencesOptions options) =>
refused.contains(key) ? _refuse(key) : super.setString(key, value, options);
}