From 34be3310245eec0296db391617765949e09f8ba1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:19:21 +0200 Subject: [PATCH] refactor(media): centralize HDR classification --- lib/media/media_display_criteria.dart | 85 ++++++++++++--- lib/services/jellyfin_display_metadata.dart | 41 ++------ lib/services/plex_mappers.dart | 33 +----- test/media/media_display_criteria_test.dart | 109 ++++++++++++++++++++ test/services/jellyfin_media_info_test.dart | 51 +++++++++ test/services/plex_mappers_test.dart | 38 +++++++ 6 files changed, 280 insertions(+), 77 deletions(-) diff --git a/lib/media/media_display_criteria.dart b/lib/media/media_display_criteria.dart index 026bc436..35fe73ea 100644 --- a/lib/media/media_display_criteria.dart +++ b/lib/media/media_display_criteria.dart @@ -1,5 +1,63 @@ import '../utils/json_utils.dart'; +typedef MediaDisplayColorTags = ({String? transfer, String? primaries, String? matrix}); + +enum MediaDisplayColorType { + dolbyVision(true), + hlg(true), + pq(true), + sdr(false), + unknown(false); + + const MediaDisplayColorType(this.isHdr); + + final bool isHdr; + + MediaDisplayColorTags get defaultTags => _defaultDisplayColorTags[this]!; +} + +const _defaultDisplayColorTags = { + MediaDisplayColorType.dolbyVision: (transfer: null, primaries: null, matrix: null), + MediaDisplayColorType.hlg: (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'), + MediaDisplayColorType.pq: (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc'), + MediaDisplayColorType.sdr: (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709'), + MediaDisplayColorType.unknown: (transfer: null, primaries: null, matrix: null), +}; + +/// Classifies already-extracted display metadata without relying on a backend +/// JSON shape. Compatibility IDs describe the Dolby Vision base layer. +MediaDisplayColorType classifyMediaDisplayColor({ + bool isDolbyVision = false, + int? doviCompatibilityId, + String? range, + String? transfer, + String? primaries, + String? matrix, + bool assumeSdr = false, +}) { + final tags = _normalizedColorTags(range, transfer, primaries, matrix); + if (doviCompatibilityId == 4 || tags.contains('hlg') || tags.contains('arib')) { + return MediaDisplayColorType.hlg; + } + if (doviCompatibilityId == 1 || + doviCompatibilityId == 6 || + tags.contains('hdr') || + tags.contains('pq') || + tags.contains('smpte2084') || + tags.contains('st2084') || + tags.contains('bt2020')) { + return MediaDisplayColorType.pq; + } + if (doviCompatibilityId == 2) { + return MediaDisplayColorType.sdr; + } + if (isDolbyVision) return MediaDisplayColorType.dolbyVision; + if (tags.contains('sdr') || tags.contains('bt709') || assumeSdr) { + return MediaDisplayColorType.sdr; + } + return MediaDisplayColorType.unknown; +} + /// Backend-neutral display metadata used to prime native display matching /// before the decoder has emitted mpv/video properties. class MediaDisplayCriteria { @@ -58,16 +116,15 @@ class MediaDisplayCriteria { bool get canPrimeNativeDisplayCriteria => hasDimensions && (hasDisplayMetadata || hasFrameRate); - bool get isHdr { - if ((doviProfile ?? 0) > 0 && doviCompatibilityId != 2) return true; - final tags = _normalizedColorTags(transfer, primaries, matrix); - return tags.contains('hlg') || - tags.contains('arib') || - tags.contains('pq') || - tags.contains('smpte2084') || - tags.contains('st2084') || - tags.contains('bt2020'); - } + MediaDisplayColorType get colorType => classifyMediaDisplayColor( + isDolbyVision: (doviProfile ?? 0) > 0, + doviCompatibilityId: doviCompatibilityId, + transfer: transfer, + primaries: primaries, + matrix: matrix, + ); + + bool get isHdr => colorType.isHdr; bool get isUsable => hasFrameRate || canPrimeNativeDisplayCriteria; @@ -97,5 +154,9 @@ String? _stringOrNull(Object? value) { bool _hasValue(String? value) => value != null && value.isNotEmpty; -String _normalizedColorTags(String? transfer, String? primaries, String? matrix) => - [transfer, primaries, matrix].whereType().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); +String _normalizedColorTags(String? range, String? transfer, String? primaries, String? matrix) => [ + range, + transfer, + primaries, + matrix, +].whereType().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); diff --git a/lib/services/jellyfin_display_metadata.dart b/lib/services/jellyfin_display_metadata.dart index a61d9556..5e7a37f0 100644 --- a/lib/services/jellyfin_display_metadata.dart +++ b/lib/services/jellyfin_display_metadata.dart @@ -14,14 +14,16 @@ MediaDisplayCriteria? jellyfinDisplayCriteriaFromStream( final transfer = _stringOrNull(videoStream['ColorTransfer']); final primaries = _stringOrNull(videoStream['ColorPrimaries']); final matrix = _stringOrNull(videoStream['ColorSpace']); - final defaults = _jellyfinDefaultDisplayColorTags( - videoRangeType: videoRangeType, - videoRange: videoRange, + final range = '${videoRangeType ?? ''} ${videoRange ?? ''}'; + final defaults = classifyMediaDisplayColor( + isDolbyVision: (doviProfile ?? 0) > 0, doviCompatibilityId: doviCompatibilityId, + range: range, transfer: transfer, primaries: primaries, matrix: matrix, - ); + assumeSdr: range.trim().isEmpty, + ).defaultTags; final criteria = MediaDisplayCriteria.fromRaw( fps: videoStream['RealFrameRate'] ?? videoStream['AverageFrameRate'], width: videoStream['Width'] ?? source['Width'], @@ -64,38 +66,7 @@ bool jellyfinVideoStreamIsHdr(Map source, Map return range.contains('hdr') || range.contains('hlg'); } -({String? transfer, String? primaries, String? matrix}) _jellyfinDefaultDisplayColorTags({ - required String? videoRangeType, - required String? videoRange, - int? doviCompatibilityId, - String? transfer, - String? primaries, - String? matrix, -}) { - final range = '${videoRangeType ?? ''} ${videoRange ?? ''}'; - final colorTags = _normalizedDisplayColorTags(transfer, primaries, matrix); - if (doviCompatibilityId == 4 || range.contains('hlg') || colorTags.contains('hlg') || colorTags.contains('arib')) { - return (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'); - } - if (doviCompatibilityId == 1 || - doviCompatibilityId == 6 || - range.contains('hdr') || - colorTags.contains('smpte2084') || - colorTags.contains('st2084') || - colorTags.contains('pq') || - colorTags.contains('bt2020')) { - return (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc'); - } - if (doviCompatibilityId == 2 || range.trim().isEmpty || range.contains('sdr')) { - return (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709'); - } - return (transfer: null, primaries: null, matrix: null); -} - String? _stringOrNull(Object? value) { final string = value?.toString().trim(); return string == null || string.isEmpty ? null : string; } - -String _normalizedDisplayColorTags(String? transfer, String? primaries, String? matrix) => - [transfer, primaries, matrix].whereType().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 233519ef..2cff7ad2 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -283,9 +283,6 @@ String? _stringOrNull(Object? value) { return string == null || string.isEmpty ? null : string; } -String _normalizedDisplayColorTags(String? transfer, String? primaries, String? matrix) => - [transfer, primaries, matrix].whereType().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); - @JsonSerializable(createToJson: false) class PlexRoleDto { @JsonKey(fromJson: flexibleInt) @@ -1090,13 +1087,14 @@ class PlexMappers { final transfer = _stringOrNull(videoStream['colorTrc']); final primaries = _stringOrNull(videoStream['colorPrimaries']); final matrix = _stringOrNull(videoStream['colorSpace']); - final defaults = _defaultDisplayColorTags( + final defaults = classifyMediaDisplayColor( isDolbyVision: hasDolbyVision, doviCompatibilityId: doviCompatibilityId, transfer: transfer, primaries: primaries, matrix: matrix, - ); + assumeSdr: !hasDolbyVision, + ).defaultTags; final criteria = MediaDisplayCriteria.fromRaw( fps: videoStream['frameRate'], width: videoStream['width'] ?? media?['width'], @@ -1111,31 +1109,6 @@ class PlexMappers { return criteria.isUsable ? criteria : null; } - static ({String? transfer, String? primaries, String? matrix}) _defaultDisplayColorTags({ - required bool isDolbyVision, - int? doviCompatibilityId, - String? transfer, - String? primaries, - String? matrix, - }) { - final colorTags = _normalizedDisplayColorTags(transfer, primaries, matrix); - if (doviCompatibilityId == 4 || colorTags.contains('hlg') || colorTags.contains('arib')) { - return (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'); - } - if (doviCompatibilityId == 1 || - doviCompatibilityId == 6 || - colorTags.contains('smpte2084') || - colorTags.contains('st2084') || - colorTags.contains('pq') || - colorTags.contains('bt2020')) { - return (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc'); - } - if (doviCompatibilityId == 2 || !isDolbyVision) { - return (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709'); - } - return (transfer: null, primaries: null, matrix: null); - } - /// Map a parsed [PlexLibraryDto] into a [MediaLibrary]. static MediaLibrary mediaLibrary(PlexLibraryDto dto) { return MediaLibrary( diff --git a/test/media/media_display_criteria_test.dart b/test/media/media_display_criteria_test.dart index d7784fe8..dc3ecb62 100644 --- a/test/media/media_display_criteria_test.dart +++ b/test/media/media_display_criteria_test.dart @@ -2,6 +2,91 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_display_criteria.dart'; void main() { + group('classifyMediaDisplayColor', () { + final cases = [ + const _ColorCase('Dolby Vision', MediaDisplayColorType.dolbyVision, isDolbyVision: true), + const _ColorCase( + 'Dolby Vision ignores incidental SDR tags', + MediaDisplayColorType.dolbyVision, + isDolbyVision: true, + primaries: 'bt709', + ), + const _ColorCase('HLG compatibility ID', MediaDisplayColorType.hlg, doviCompatibilityId: 4), + const _ColorCase('HLG range', MediaDisplayColorType.hlg, range: 'HLG'), + const _ColorCase('ARIB transfer', MediaDisplayColorType.hlg, transfer: 'ARIB-STD-B67'), + const _ColorCase('HDR10 compatibility ID', MediaDisplayColorType.pq, doviCompatibilityId: 1), + const _ColorCase('PQ compatibility ID', MediaDisplayColorType.pq, doviCompatibilityId: 6), + const _ColorCase('HDR10 range', MediaDisplayColorType.pq, range: 'DOVIWithHDR10Plus'), + const _ColorCase('PQ transfer', MediaDisplayColorType.pq, transfer: 'PQ'), + const _ColorCase('SMPTE 2084 transfer', MediaDisplayColorType.pq, transfer: 'smpte2084'), + const _ColorCase('ST 2084 transfer', MediaDisplayColorType.pq, transfer: 'ST-2084'), + const _ColorCase('BT.2020 primaries', MediaDisplayColorType.pq, primaries: 'BT.2020'), + const _ColorCase('BT.2020 matrix', MediaDisplayColorType.pq, matrix: 'bt2020nc'), + const _ColorCase('SDR compatibility ID', MediaDisplayColorType.sdr, doviCompatibilityId: 2), + const _ColorCase('SDR range', MediaDisplayColorType.sdr, range: 'SDR'), + const _ColorCase('BT.709 primaries', MediaDisplayColorType.sdr, primaries: 'BT.709'), + const _ColorCase('assumed SDR', MediaDisplayColorType.sdr, assumeSdr: true), + const _ColorCase('missing metadata', MediaDisplayColorType.unknown), + const _ColorCase('unrecognized metadata', MediaDisplayColorType.unknown, transfer: 'gamma22'), + const _ColorCase( + 'HLG takes precedence over PQ and SDR', + MediaDisplayColorType.hlg, + doviCompatibilityId: 2, + transfer: 'arib-std-b67', + primaries: 'bt2020', + ), + const _ColorCase( + 'PQ takes precedence over SDR and Dolby Vision', + MediaDisplayColorType.pq, + isDolbyVision: true, + doviCompatibilityId: 2, + transfer: 'smpte2084', + ), + const _ColorCase( + 'SDR takes precedence over Dolby Vision', + MediaDisplayColorType.sdr, + isDolbyVision: true, + doviCompatibilityId: 2, + ), + ]; + + for (final testCase in cases) { + test(testCase.name, () { + expect( + classifyMediaDisplayColor( + isDolbyVision: testCase.isDolbyVision, + doviCompatibilityId: testCase.doviCompatibilityId, + range: testCase.range, + transfer: testCase.transfer, + primaries: testCase.primaries, + matrix: testCase.matrix, + assumeSdr: testCase.assumeSdr, + ), + testCase.expected, + ); + }); + } + + test('provides defaults and HDR state for every classification', () { + const expected = { + MediaDisplayColorType.dolbyVision: (isHdr: true, tags: (transfer: null, primaries: null, matrix: null)), + MediaDisplayColorType.hlg: ( + isHdr: true, + tags: (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'), + ), + MediaDisplayColorType.pq: (isHdr: true, tags: (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc')), + MediaDisplayColorType.sdr: (isHdr: false, tags: (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709')), + MediaDisplayColorType.unknown: (isHdr: false, tags: (transfer: null, primaries: null, matrix: null)), + }; + + expect(expected.keys, unorderedEquals(MediaDisplayColorType.values)); + for (final entry in expected.entries) { + expect(entry.key.isHdr, entry.value.isHdr, reason: entry.key.name); + expect(entry.key.defaultTags, entry.value.tags, reason: entry.key.name); + } + }); + }); + group('MediaDisplayCriteria', () { test('can prime native display criteria from frame rate and dimensions', () { const criteria = MediaDisplayCriteria(fps: 23.976, width: 1920, height: 1080); @@ -16,3 +101,27 @@ void main() { }); }); } + +class _ColorCase { + final String name; + final MediaDisplayColorType expected; + final bool isDolbyVision; + final int? doviCompatibilityId; + final String? range; + final String? transfer; + final String? primaries; + final String? matrix; + final bool assumeSdr; + + const _ColorCase( + this.name, + this.expected, { + this.isDolbyVision = false, + this.doviCompatibilityId, + this.range, + this.transfer, + this.primaries, + this.matrix, + this.assumeSdr = false, + }); +} diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index a24ccf0a..276dc632 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_display_criteria.dart'; import 'package:plezy/services/jellyfin_media_info_mapper.dart'; /// Field-mapping pin for the Jellyfin → Plex `MediaInfo` translator. The @@ -138,6 +139,56 @@ void main() { expect(criteria.matrix, 'bt2020nc'); }); + test('maps each Jellyfin color metadata class', () { + final cases = <({Map stream, MediaDisplayColorType type, MediaDisplayColorTags tags})>[ + ( + stream: {'VideoRangeType': 'DOVI', 'DvProfile': 5}, + type: MediaDisplayColorType.dolbyVision, + tags: (transfer: null, primaries: null, matrix: null), + ), + ( + stream: {'VideoRangeType': 'HLG'}, + type: MediaDisplayColorType.hlg, + tags: (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'), + ), + ( + stream: {'VideoRangeType': 'HDR10'}, + type: MediaDisplayColorType.pq, + tags: (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc'), + ), + ( + stream: {'VideoRange': 'SDR'}, + type: MediaDisplayColorType.sdr, + tags: (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709'), + ), + ( + stream: {'VideoRangeType': 'Unknown'}, + type: MediaDisplayColorType.unknown, + tags: (transfer: null, primaries: null, matrix: null), + ), + ]; + + for (final testCase in cases) { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'Id': 'src-1', + 'Width': 1920, + 'Height': 1080, + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'RealFrameRate': 24, ...testCase.stream}, + ], + }); + final criteria = info.displayCriteria; + + expect(criteria, isNotNull); + expect(criteria!.colorType, testCase.type); + expect( + (transfer: criteria.transfer, primaries: criteria.primaries, matrix: criteria.matrix), + testCase.tags, + reason: testCase.type.name, + ); + } + }); + test('handles missing MediaStreams gracefully', () { final info = jellyfinMediaSourceToMediaSourceInfo({'Id': 'x'}); expect(info.audioTracks, isEmpty); diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart index daa10c35..f4fb161f 100644 --- a/test/services/plex_mappers_test.dart +++ b/test/services/plex_mappers_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_display_criteria.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_stream.dart'; import 'package:plezy/services/plex_mappers.dart'; @@ -22,6 +23,43 @@ void main() { expect(dto.userRating, 9.5); }); + test('display criteria maps each Plex color metadata class', () { + final cases = <({Map stream, MediaDisplayColorType type, MediaDisplayColorTags tags})>[ + ( + stream: {'DOVIProfile': 5, 'DOVIPresent': 1}, + type: MediaDisplayColorType.dolbyVision, + tags: (transfer: null, primaries: null, matrix: null), + ), + ( + stream: {'DOVIProfile': 8, 'DOVIBLCompatID': 4}, + type: MediaDisplayColorType.hlg, + tags: (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'), + ), + ( + stream: {'colorTrc': 'smpte2084'}, + type: MediaDisplayColorType.pq, + tags: (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc'), + ), + ( + stream: {}, + type: MediaDisplayColorType.sdr, + tags: (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709'), + ), + ]; + + for (final testCase in cases) { + final criteria = PlexMappers.displayCriteriaFromJson({'width': 1920, 'height': 1080}, testCase.stream); + + expect(criteria, isNotNull); + expect(criteria!.colorType, testCase.type); + expect( + (transfer: criteria.transfer, primaries: criteria.primaries, matrix: criteria.matrix), + testCase.tags, + reason: testCase.type.name, + ); + } + }); + group('PlexMappers.mediaItem (movie)', () { test('maps a Plex movie with watch state, ratings, genres, and people', () { final json = {