refactor(media): centralize HDR classification

This commit is contained in:
edde746
2026-07-12 08:42:25 +02:00
parent ceae98b75f
commit 34be331024
6 changed files with 280 additions and 77 deletions
+73 -12
View File
@@ -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, MediaDisplayColorTags>{
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<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
String _normalizedColorTags(String? range, String? transfer, String? primaries, String? matrix) => [
range,
transfer,
primaries,
matrix,
].whereType<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
+6 -35
View File
@@ -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<String, dynamic> source, Map<String, dynamic>
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<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
+3 -30
View File
@@ -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<String>().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(
+109
View File
@@ -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, ({bool isHdr, MediaDisplayColorTags tags})>{
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,
});
}
@@ -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<String, dynamic> 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);
+38
View File
@@ -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<String, dynamic> 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: <String, dynamic>{},
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 = {