refactor(media): centralize HDR classification
This commit is contained in:
@@ -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]'), '');
|
||||
|
||||
@@ -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]'), '');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user