Merge pull request #447 from Silo-Server/codex/jellycompat-color-range
fix(playback): preserve and expose video color range
This commit is contained in:
@@ -2580,6 +2580,7 @@ func toVideoTrackRecords(tracks []models.VideoTrack) []VideoTrackRecord {
|
||||
Bitrate: track.Bitrate,
|
||||
VideoRange: track.VideoRange,
|
||||
VideoRangeType: track.VideoRangeType,
|
||||
ColorRange: track.ColorRange,
|
||||
ColorPrimaries: track.ColorPrimaries,
|
||||
ColorSpace: track.ColorSpace,
|
||||
ColorTransfer: track.ColorTransfer,
|
||||
|
||||
@@ -3,8 +3,30 @@ package catalogseed
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestToVideoTrackRecordsPreservesColorRange(t *testing.T) {
|
||||
got := toVideoTrackRecords([]models.VideoTrack{
|
||||
{ColorRange: "tv"},
|
||||
{ColorRange: "pc"},
|
||||
{ColorRange: "unknown"},
|
||||
})
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("records length = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].ColorRange != "tv" || got[1].ColorRange != "pc" || got[2].ColorRange != "unknown" {
|
||||
t.Fatalf(
|
||||
"ColorRange values = [%q, %q, %q], want [tv, pc, unknown]",
|
||||
got[0].ColorRange,
|
||||
got[1].ColorRange,
|
||||
got[2].ColorRange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogSeedSearchUpsertIDsIncludesChangedItemsAndEmbeddings(t *testing.T) {
|
||||
itemStates := map[string]bool{
|
||||
"movie-1": true,
|
||||
|
||||
@@ -156,6 +156,7 @@ type VideoTrackRecord struct {
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
VideoRange string `json:"video_range,omitempty"`
|
||||
VideoRangeType string `json:"video_range_type,omitempty"`
|
||||
ColorRange string `json:"color_range,omitempty"`
|
||||
ColorPrimaries string `json:"color_primaries,omitempty"`
|
||||
ColorSpace string `json:"color_space,omitempty"`
|
||||
ColorTransfer string `json:"color_transfer,omitempty"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -273,6 +274,55 @@ func TestBuildMediaStreamsUsesJellyfinVideoRangeType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMediaStreamsPreservesColorRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
colorRange string
|
||||
wantColorRange string
|
||||
wantJSONField bool
|
||||
}{
|
||||
{name: "limited", colorRange: "tv", wantColorRange: "tv", wantJSONField: true},
|
||||
{name: "full", colorRange: "pc", wantColorRange: "pc", wantJSONField: true},
|
||||
{name: "internal unknown sentinel", colorRange: "unknown"},
|
||||
{name: "missing"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
version := catalog.FileVersion{
|
||||
VideoTracks: []models.VideoTrack{{
|
||||
Codec: "h264",
|
||||
ColorRange: tt.colorRange,
|
||||
}},
|
||||
}
|
||||
|
||||
streams := buildMediaStreams("item", "source", version)
|
||||
if len(streams) != 1 {
|
||||
t.Fatalf("streams length = %d, want 1", len(streams))
|
||||
}
|
||||
if got := streams[0].ColorRange; got != tt.wantColorRange {
|
||||
t.Fatalf("ColorRange = %q, want %q", got, tt.wantColorRange)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(streams[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal media stream: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal media stream: %v", err)
|
||||
}
|
||||
got, present := decoded["ColorRange"]
|
||||
if present != tt.wantJSONField {
|
||||
t.Fatalf("JSON ColorRange present = %v, want %v (value %#v)", present, tt.wantJSONField, got)
|
||||
}
|
||||
if tt.wantJSONField && got != tt.wantColorRange {
|
||||
t.Fatalf("JSON ColorRange = %#v, want %q", got, tt.wantColorRange)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecProfileAVCRefFramesConstraint(t *testing.T) {
|
||||
version := catalog.FileVersion{
|
||||
FileID: 1,
|
||||
|
||||
@@ -264,6 +264,7 @@ type mediaStreamDTO struct {
|
||||
AspectRatio string `json:"AspectRatio,omitempty"`
|
||||
VideoRange string `json:"VideoRange,omitempty"`
|
||||
VideoRangeType string `json:"VideoRangeType,omitempty"`
|
||||
ColorRange string `json:"ColorRange,omitempty"`
|
||||
ColorPrimaries string `json:"ColorPrimaries,omitempty"`
|
||||
ColorSpace string `json:"ColorSpace,omitempty"`
|
||||
ColorTransfer string `json:"ColorTransfer,omitempty"`
|
||||
|
||||
@@ -890,6 +890,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
|
||||
AspectRatio: track.AspectRatio,
|
||||
VideoRange: compatVideoRange(track, version.HDR),
|
||||
VideoRangeType: compatVideoRangeType(track, version.HDR),
|
||||
ColorRange: compatColorRange(track.ColorRange),
|
||||
ColorPrimaries: track.ColorPrimaries,
|
||||
ColorSpace: track.ColorSpace,
|
||||
ColorTransfer: track.ColorTransfer,
|
||||
@@ -970,6 +971,16 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
|
||||
return streams
|
||||
}
|
||||
|
||||
func compatColorRange(colorRange string) string {
|
||||
colorRange = strings.TrimSpace(colorRange)
|
||||
if strings.EqualFold(colorRange, "unknown") {
|
||||
// The scanner persists this sentinel so legacy probe repair converges,
|
||||
// but Jellyfin omits ColorRange when ffprobe did not provide a value.
|
||||
return ""
|
||||
}
|
||||
return colorRange
|
||||
}
|
||||
|
||||
func mediaSourceETag(version catalog.FileVersion) string {
|
||||
sum := sha1.Sum(fmt.Appendf(nil, "%d:%s:%s:%d", version.FileID, version.Container, version.CodecVideo, version.Bitrate))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
|
||||
@@ -219,6 +219,7 @@ type VideoTrack struct {
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
VideoRange string `json:"video_range,omitempty"`
|
||||
VideoRangeType string `json:"video_range_type,omitempty"`
|
||||
ColorRange string `json:"color_range,omitempty"`
|
||||
ColorPrimaries string `json:"color_primaries,omitempty"`
|
||||
ColorSpace string `json:"color_space,omitempty"`
|
||||
ColorTransfer string `json:"color_transfer,omitempty"`
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPersonKindAudiobookRoles(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -40,3 +43,34 @@ func TestNormalizeVideoBitDepth(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoTrackColorRangeJSON(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value string
|
||||
present bool
|
||||
}{
|
||||
{name: "limited", value: "tv", present: true},
|
||||
{name: "full", value: "pc", present: true},
|
||||
{name: "unspecified", value: "unknown", present: true},
|
||||
{name: "empty omitted", value: "", present: false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(VideoTrack{ColorRange: test.value})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, present := decoded["color_range"]
|
||||
if present != test.present {
|
||||
t.Fatalf("color_range present = %v, want %v (%s)", present, test.present, data)
|
||||
}
|
||||
if test.present && got != test.value {
|
||||
t.Fatalf("color_range = %#v, want %q", got, test.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func SourceDescriptorFromFileV3(file *models.MediaFile, audioIndex int) SourceDe
|
||||
source.VideoProfile = strings.ToLower(strings.TrimSpace(track.Profile))
|
||||
source.VideoLevel = track.Level
|
||||
source.BitDepth = models.NormalizeVideoBitDepth(track.BitDepth, track.PixelFormat, track.Profile)
|
||||
source.ColorRange = normalizeColorRangeV3(track.ColorRange)
|
||||
source.Width = track.Width
|
||||
source.Height = track.Height
|
||||
source.FrameRate = parseFrameRateV3(track.FrameRate)
|
||||
@@ -73,6 +74,15 @@ func SourceDescriptorFromFileV3(file *models.MediaFile, audioIndex int) SourceDe
|
||||
return source
|
||||
}
|
||||
|
||||
func normalizeColorRangeV3(value string) string {
|
||||
switch normalized := strings.ToLower(strings.TrimSpace(value)); normalized {
|
||||
case "tv", "pc", "unknown":
|
||||
return normalized
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func detailedVideoEligibleV3(source SourceDescriptorV3, request StartRequestV3) bool {
|
||||
if !HasFeatureV3(request.ClientFeatures, FeatureDetailedDecodeV3) && !HasFeatureV3(request.ClientPlaybackContext.Features, FeatureDetailedDecodeV3) {
|
||||
return false
|
||||
|
||||
@@ -412,6 +412,7 @@ type SourceDescriptorV3 struct {
|
||||
VideoProfile string `json:"video_profile,omitempty"`
|
||||
VideoLevel int `json:"video_level,omitempty"`
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
ColorRange string `json:"color_range,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
FrameRate float64 `json:"frame_rate,omitempty"`
|
||||
|
||||
@@ -265,6 +265,28 @@ func TestSourceDescriptorV3NormalizesLegacyHEVCMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceDescriptorV3PreservesCanonicalColorRange(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "limited", input: "tv", want: "tv"},
|
||||
{name: "full", input: "pc", want: "pc"},
|
||||
{name: "unspecified", input: "unknown", want: "unknown"},
|
||||
{name: "normalizes case and whitespace", input: " PC ", want: "pc"},
|
||||
{name: "rejects non-ffmpeg value", input: "limited", want: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
file := detailedFixtureFileV3()
|
||||
file.VideoTracks[0].ColorRange = test.input
|
||||
if got := SourceDescriptorFromFileV3(file, 0).ColorRange; got != test.want {
|
||||
t.Fatalf("color range = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPlaybackV3DirectPlaysLegacyHDR10WithInferredBitDepth(t *testing.T) {
|
||||
file := detailedFixtureFileV3()
|
||||
file.VideoTracks[0].BitDepth = 0
|
||||
@@ -933,7 +955,7 @@ func validStartRequestV3() StartRequestV3 {
|
||||
}
|
||||
|
||||
func detailedFixtureFileV3() *models.MediaFile {
|
||||
return &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv", Container: "mkv", CodecVideo: "hevc", CodecAudio: "aac", Resolution: "2160p", Bitrate: 60_000, AudioChannels: 2, VideoTracks: []models.VideoTrack{{Codec: "hevc", Profile: "Main 10", Level: 153, Width: 3840, Height: 2160, FrameRate: "24000/1001", Bitrate: 60_000, BitDepth: 10, VideoRange: "HDR", VideoRangeType: "HDR10"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo"}}}
|
||||
return &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv", Container: "mkv", CodecVideo: "hevc", CodecAudio: "aac", Resolution: "2160p", Bitrate: 60_000, AudioChannels: 2, VideoTracks: []models.VideoTrack{{Codec: "hevc", Profile: "Main 10", Level: 153, Width: 3840, Height: 2160, FrameRate: "24000/1001", Bitrate: 60_000, BitDepth: 10, VideoRange: "HDR", VideoRangeType: "HDR10", ColorRange: "tv"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo"}}}
|
||||
}
|
||||
|
||||
func testTransformationRegistryV3() *TransformationRegistryV3 {
|
||||
|
||||
@@ -76,6 +76,7 @@ type ffprobeStream struct {
|
||||
StartTime string `json:"start_time"`
|
||||
Duration string `json:"duration"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
ColorRange string `json:"color_range"`
|
||||
ColorTransfer string `json:"color_transfer"`
|
||||
ColorPrimaries string `json:"color_primaries"`
|
||||
ColorSpace string `json:"color_space"`
|
||||
@@ -186,6 +187,9 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
dvProfile := dolbyVisionProfileNumber(s.SideDataList)
|
||||
// ffprobe omits unspecified optional fields by default; "unknown" is
|
||||
// FFmpeg's canonical name for AVCOL_RANGE_UNSPECIFIED.
|
||||
colorRange := firstNonEmpty(s.ColorRange, "unknown")
|
||||
track := VideoTrackInfo{
|
||||
Title: firstNonEmpty(s.Tags["title"], s.CodecLongName, strings.ToUpper(s.CodecName)),
|
||||
Codec: s.CodecName,
|
||||
@@ -205,6 +209,7 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
|
||||
Bitrate: parseNumeric(s.BitRate) / 1000,
|
||||
VideoRange: videoRangeLabel(s),
|
||||
VideoRangeType: videoRangeType(s),
|
||||
ColorRange: colorRange,
|
||||
ColorPrimaries: s.ColorPrimaries,
|
||||
ColorSpace: s.ColorSpace,
|
||||
ColorTransfer: s.ColorTransfer,
|
||||
|
||||
@@ -54,6 +54,9 @@ func NeedsCriticalProbeRepair(file *models.MediaFile) bool {
|
||||
if strings.TrimSpace(file.CodecVideo) == "" || strings.TrimSpace(file.Resolution) == "" {
|
||||
return true
|
||||
}
|
||||
if videoTracksMissingColorRange(file.VideoTracks) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if file.Chapters == nil {
|
||||
return true
|
||||
@@ -61,6 +64,15 @@ func NeedsCriticalProbeRepair(file *models.MediaFile) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func videoTracksMissingColorRange(tracks []models.VideoTrack) bool {
|
||||
for _, track := range tracks {
|
||||
if strings.TrimSpace(track.ColorRange) == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PlaybackProbeEnsurer repairs missing playback-critical probe metadata on
|
||||
// demand by running a local ffprobe and persisting the result.
|
||||
type PlaybackProbeEnsurer struct {
|
||||
|
||||
@@ -47,6 +47,31 @@ func TestNeedsCriticalProbeRepair_ProbedVideoMissingResolutionStillRepairs(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsCriticalProbeRepair_ProbedVideoMissingColorRangeRepairsOnce(t *testing.T) {
|
||||
now := time.Now()
|
||||
f := &models.MediaFile{
|
||||
ProbeSource: "local",
|
||||
ProbeUpdatedAt: &now,
|
||||
Duration: 7200,
|
||||
Container: "mkv",
|
||||
CodecAudio: "aac",
|
||||
AudioTracks: []models.AudioTrack{{Language: "eng"}},
|
||||
CodecVideo: "h264",
|
||||
Resolution: "1080p",
|
||||
VideoTracks: []models.VideoTrack{{Codec: "h264"}},
|
||||
Chapters: []models.MediaChapter{},
|
||||
}
|
||||
|
||||
if !NeedsCriticalProbeRepair(f) {
|
||||
t.Fatal("a legacy video without color range should need probe repair")
|
||||
}
|
||||
|
||||
f.VideoTracks[0].ColorRange = "unknown"
|
||||
if NeedsCriticalProbeRepair(f) {
|
||||
t.Fatal("a reprobed video with unknown color range should not repair again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsCriticalProbeRepair_UnprobedFileRepairs(t *testing.T) {
|
||||
if !NeedsCriticalProbeRepair(&models.MediaFile{}) {
|
||||
t.Fatal("an unprobed file must need probe repair")
|
||||
@@ -64,7 +89,7 @@ func implausiblyShortLargeVideoFile(probedAt time.Time) *models.MediaFile {
|
||||
AudioTracks: []models.AudioTrack{{Language: "eng"}},
|
||||
CodecVideo: "h264",
|
||||
Resolution: "720p",
|
||||
VideoTracks: []models.VideoTrack{{Codec: "h264"}},
|
||||
VideoTracks: []models.VideoTrack{{Codec: "h264", ColorRange: "unknown"}},
|
||||
Chapters: []models.MediaChapter{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
package scanner
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestProbePipelinePreservesVideoColorRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
colorRange string
|
||||
wantColorRange string
|
||||
}{
|
||||
{name: "limited", colorRange: "tv", wantColorRange: "tv"},
|
||||
{name: "full", colorRange: "pc", wantColorRange: "pc"},
|
||||
{name: "unspecified", wantColorRange: "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rawJSON := `{"streams":[{"codec_type":"video","codec_name":"h264","color_range":"` + tt.colorRange + `"}]}`
|
||||
var raw ffprobeOutput
|
||||
if err := json.Unmarshal([]byte(rawJSON), &raw); err != nil {
|
||||
t.Fatalf("unmarshal ffprobe output: %v", err)
|
||||
}
|
||||
|
||||
probe := convertProbeData(&raw)
|
||||
file := &models.MediaFile{}
|
||||
applyProbeData(file, probe, "local")
|
||||
|
||||
if len(file.VideoTracks) != 1 {
|
||||
t.Fatalf("VideoTracks length = %d, want 1", len(file.VideoTracks))
|
||||
}
|
||||
if got := file.VideoTracks[0].ColorRange; got != tt.wantColorRange {
|
||||
t.Fatalf("ColorRange = %q, want %q", got, tt.wantColorRange)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProbeDataVideoRangeTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -3186,6 +3186,7 @@ func applyProbeData(mf *models.MediaFile, probe *ProbeData, probeSource string)
|
||||
Bitrate: vt.Bitrate,
|
||||
VideoRange: vt.VideoRange,
|
||||
VideoRangeType: vt.VideoRangeType,
|
||||
ColorRange: vt.ColorRange,
|
||||
ColorPrimaries: vt.ColorPrimaries,
|
||||
ColorSpace: vt.ColorSpace,
|
||||
ColorTransfer: vt.ColorTransfer,
|
||||
|
||||
@@ -67,6 +67,7 @@ type VideoTrackInfo struct {
|
||||
Bitrate int
|
||||
VideoRange string
|
||||
VideoRangeType string
|
||||
ColorRange string
|
||||
ColorPrimaries string
|
||||
ColorSpace string
|
||||
ColorTransfer string
|
||||
|
||||
@@ -970,6 +970,7 @@ export interface VersionVideoTrack {
|
||||
bitrate?: number;
|
||||
video_range?: string;
|
||||
video_range_type?: string;
|
||||
color_range?: string;
|
||||
color_primaries?: string;
|
||||
color_space?: string;
|
||||
color_transfer?: string;
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeVersion(overrides: Partial<PlayerFileVersion> = {}): PlayerFileVers
|
||||
height: 2160,
|
||||
bitrate: 21900,
|
||||
video_range: "HDR10",
|
||||
color_range: "tv",
|
||||
dolby_vision: "Profile 8.1",
|
||||
},
|
||||
],
|
||||
@@ -95,6 +96,7 @@ describe("playback info helpers", () => {
|
||||
expect(rowValue(sections, "Current Source File", "Video range type")).toBe(
|
||||
"Dolby Vision Profile 8.1 (HDR10)",
|
||||
);
|
||||
expect(rowValue(sections, "Current Source File", "Color range")).toBe("Limited (tv)");
|
||||
expect(rowValue(sections, "Current Source File", "Audio codec")).toBe(
|
||||
"EAC3 Dolby Digital Plus + Dolby Atmos",
|
||||
);
|
||||
@@ -146,6 +148,27 @@ describe("playback info helpers", () => {
|
||||
expect(rowValue(sections, "Current Source File", "Container")).toBe("—");
|
||||
expect(rowValue(sections, "Current Source File", "Size")).toBe("—");
|
||||
expect(rowValue(sections, "Current Source File", "Audio sample rate")).toBe("—");
|
||||
expect(rowValue(sections, "Current Source File", "Color range")).toBe("—");
|
||||
});
|
||||
|
||||
it("formats full and unspecified source color ranges", () => {
|
||||
const full = buildPlaybackInfoSections({
|
||||
streamUrl: "/api/v1/stream/full",
|
||||
playMethod: "direct",
|
||||
playbackInfo: null,
|
||||
currentSourceVersion: makeVersion({ video_tracks: [{ color_range: "pc" }] }),
|
||||
runtimeStats: {},
|
||||
});
|
||||
const unknown = buildPlaybackInfoSections({
|
||||
streamUrl: "/api/v1/stream/unknown",
|
||||
playMethod: "direct",
|
||||
playbackInfo: null,
|
||||
currentSourceVersion: makeVersion({ video_tracks: [{ color_range: "unknown" }] }),
|
||||
runtimeStats: {},
|
||||
});
|
||||
|
||||
expect(rowValue(full, "Current Source File", "Color range")).toBe("Full (pc)");
|
||||
expect(rowValue(unknown, "Current Source File", "Color range")).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("shows the requested source when playback auto-switches to a lower version", () => {
|
||||
|
||||
@@ -147,6 +147,10 @@ export function buildPlaybackInfoSections({
|
||||
label: "Video range type",
|
||||
value: formatVideoRangeType(currentSourceVersion, videoTrack),
|
||||
},
|
||||
{
|
||||
label: "Color range",
|
||||
value: formatColorRange(videoTrack?.color_range),
|
||||
},
|
||||
{
|
||||
label: "Audio codec",
|
||||
value: formatOriginalAudioCodec(currentSourceVersion, audioTrack),
|
||||
@@ -340,6 +344,19 @@ export function formatVideoRangeType(
|
||||
return "—";
|
||||
}
|
||||
|
||||
export function formatColorRange(value?: string): string {
|
||||
switch (value?.trim().toLowerCase()) {
|
||||
case "tv":
|
||||
return "Limited (tv)";
|
||||
case "pc":
|
||||
return "Full (pc)";
|
||||
case "unknown":
|
||||
return "Unknown";
|
||||
default:
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatOriginalAudioCodec(
|
||||
version?: PlayerFileVersion,
|
||||
track?: PlayerAudioTrack,
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface PlayerVideoTrack {
|
||||
frame_rate?: string;
|
||||
bitrate?: number;
|
||||
video_range?: string;
|
||||
color_range?: string;
|
||||
color_primaries?: string;
|
||||
color_space?: string;
|
||||
color_transfer?: string;
|
||||
|
||||
Reference in New Issue
Block a user