From 0f9584ef42fd277d4e589f4bbbc352d4a4b355dc Mon Sep 17 00:00:00 2001 From: RXWatcher Date: Thu, 16 Jul 2026 16:53:30 +0200 Subject: [PATCH] fix(overlays): derive dynamic-range badges from Dolby Vision metadata (#365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(overlays): derive dynamic-range badges from Dolby Vision metadata Dolby Vision files were labeled with a generic "HDR" badge everywhere outside the Media Info dialog, because badge sites only consulted the bare FileVersion.hdr boolean even though the payload carries video_tracks[].dolby_vision / dv_profile / video_range_type / color_transfer. Web: add a shared helper (web/src/lib/videoRange.ts) that derives a display label ("DV", "DV HDR10", "DV HLG", "HDR10+", "HDR10", "HLG") from the probed video tracks, mirroring the server-side normalizeHDR vocabulary, with the hdr boolean kept as the last-resort fallback for stale pre-DV probe rows. Use it in QualityBadges, VersionDropdown, VersionFlyout, and the player HUD (playback-info) in place of the hardcoded "HDR" literal. Badge styling is unchanged. Server: in internal/overlays/summary.go, break bestFile resolution ties by richness of dynamic-range metadata (DV > explicit HDR10/HLG via color_transfer > bare hdr boolean > SDR) so a first-scanned generic-HDR file no longer masks a Dolby Vision sibling on card overlays. Full ties still keep the earliest file, so the selection stays deterministic. Co-Authored-By: Claude Opus 4.8 * fix(overlays): detect Dolby Vision via dv_profile and DOVI range type Probed rows set DolbyVision and DVProfile together, but catalog-seeded or imported tracks can carry only dv_profile or a DOVI* video_range_type with an empty dolby_vision string. Share one hasDolbyVision predicate between rangeRank and normalizeHDR so those rows rank and label as DV, matching the web videoRange helper. Co-Authored-By: Claude Opus 4.8 * fix(overlays): read video_range_type and hdr10_plus in hdrTypeFromTracks Review follow-up: hdrTypeFromTracks only inspected color_transfer, so server card overlays could never label HDR10+ and a track whose only signal is video_range_type (e.g. catalog-seeded rows without probed color metadata) fell through to the bare-boolean tier of rangeRank — inconsistent with hasDolbyVision, which already reads the range type. The server now shares the web helper's detection order exactly, making the web/server vocabulary mirror claim true. Also drops the unused video_range field from the web VideoRangeTrack interface. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: rxwatcher Co-authored-by: Claude Opus 4.8 Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- internal/overlays/summary.go | 64 +++++-- internal/overlays/summary_test.go | 159 ++++++++++++++++++ web/src/lib/videoRange.test.ts | 103 ++++++++++++ web/src/lib/videoRange.ts | 89 ++++++++++ .../ItemDetail/components/QualityBadges.tsx | 4 +- .../ItemDetail/components/VersionDropdown.tsx | 6 +- .../ItemDetail/components/VersionFlyout.tsx | 4 +- web/src/player/playback-info.test.ts | 4 +- web/src/player/playback-info.ts | 8 +- 9 files changed, 419 insertions(+), 22 deletions(-) create mode 100644 web/src/lib/videoRange.test.ts create mode 100644 web/src/lib/videoRange.ts diff --git a/internal/overlays/summary.go b/internal/overlays/summary.go index b068b55e..e90cb3b9 100644 --- a/internal/overlays/summary.go +++ b/internal/overlays/summary.go @@ -51,20 +51,53 @@ func BuildSummary(files []*models.MediaFile) *Summary { func bestFile(files []*models.MediaFile) *models.MediaFile { var best *models.MediaFile - bestRank := -1 + bestRes := -1 + bestRange := -1 for _, file := range files { if file == nil { continue } - rank := resolutionRank(file.Resolution) - if best == nil || rank > bestRank { + res := resolutionRank(file.Resolution) + rng := rangeRank(file) + if best == nil || res > bestRes || (res == bestRes && rng > bestRange) { best = file - bestRank = rank + bestRes = res + bestRange = rng } } return best } +// rangeRank orders files with equal resolution by the richness of their +// dynamic-range metadata so a Dolby Vision version is not masked by a +// first-scanned file that only carries the bare HDR boolean (e.g. a stale +// pre-DV probe row). Ties keep the earliest file in the slice. +func rangeRank(file *models.MediaFile) int { + if hasDolbyVision(file.VideoTracks) { + return 3 + } + if hdrTypeFromTracks(file.VideoTracks) != "" { + return 2 + } + if file.HDR { + return 1 + } + return 0 +} + +// hasDolbyVision reports whether any track carries Dolby Vision metadata. +// Probed rows set DolbyVision and DVProfile together, but seeded/imported +// rows may carry only dv_profile or a DOVI* video_range_type. +func hasDolbyVision(tracks []models.VideoTrack) bool { + for _, track := range tracks { + if track.DolbyVision != "" || track.DVProfile > 0 || + strings.HasPrefix(track.VideoRangeType, "DOVI") { + return true + } + } + return false +} + func normalizeResolution(value string) string { cleaned := strings.ToLower(strings.TrimSpace(value)) switch cleaned { @@ -92,13 +125,7 @@ func resolutionRank(value string) int { } func normalizeHDR(file *models.MediaFile) string { - hasDV := false - for _, track := range file.VideoTracks { - if track.DolbyVision != "" { - hasDV = true - break - } - } + hasDV := hasDolbyVision(file.VideoTracks) hdrType := hdrTypeFromTracks(file.VideoTracks) switch { @@ -115,9 +142,22 @@ func normalizeHDR(file *models.MediaFile) string { } } -// hdrTypeFromTracks inspects video track color transfer to distinguish HDR variants. +// hdrTypeFromTracks distinguishes HDR variants from the scanner-derived +// video_range_type enum (HDR10, HDR10Plus, HLG, DOVIWith*) with color +// transfer as a fallback, so rows without probed color metadata (e.g. +// catalog-seeded imports) still resolve. Keep in sync with trackHdrType in +// web/src/lib/videoRange.ts. func hdrTypeFromTracks(tracks []models.VideoTrack) string { for _, track := range tracks { + rangeType := strings.TrimSpace(track.VideoRangeType) + switch { + case track.HDR10Plus || strings.Contains(rangeType, "HDR10Plus"): + return "HDR10+" + case rangeType == "HDR10" || strings.HasSuffix(rangeType, "WithHDR10"): + return "HDR10" + case rangeType == "HLG" || strings.HasSuffix(rangeType, "WithHLG"): + return "HLG" + } ct := strings.ToLower(track.ColorTransfer) switch { case strings.Contains(ct, "smpte2084"): diff --git a/internal/overlays/summary_test.go b/internal/overlays/summary_test.go index a0de3b2a..47cc79f8 100644 --- a/internal/overlays/summary_test.go +++ b/internal/overlays/summary_test.go @@ -212,6 +212,165 @@ func TestNormalizeReleaseType(t *testing.T) { } } +func TestNormalizeHDR(t *testing.T) { + cases := []struct { + name string + file *models.MediaFile + want string + }{ + {"sdr", &models.MediaFile{}, ""}, + {"bare boolean", &models.MediaFile{HDR: true}, "HDR"}, + {"hdr10 via color transfer", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{ColorTransfer: "smpte2084"}}, + }, "HDR10"}, + {"hlg via color transfer", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{ColorTransfer: "arib-std-b67"}}, + }, "HLG"}, + {"dv only", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{DolbyVision: "Profile 5"}}, + }, "DV"}, + {"dv with hdr10 base layer", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{ + DolbyVision: "Profile 8", + ColorTransfer: "smpte2084", + }}, + }, "DV HDR10"}, + {"dv via profile number only", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{DVProfile: 5}}, + }, "DV"}, + {"dv via DOVI range type only", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{ + VideoRangeType: "DOVIWithHDR10", + ColorTransfer: "smpte2084", + }}, + }, "DV HDR10"}, + {"hdr10 via range type without color transfer", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{VideoRangeType: "HDR10"}}, + }, "HDR10"}, + {"hlg via range type without color transfer", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{VideoRangeType: "HLG"}}, + }, "HLG"}, + {"hdr10+ via flag", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{ + HDR10Plus: true, + ColorTransfer: "smpte2084", + }}, + }, "HDR10+"}, + {"hdr10+ via range type", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{VideoRangeType: "HDR10Plus"}}, + }, "HDR10+"}, + {"dv with hdr10+ base layer", &models.MediaFile{ + HDR: true, + VideoTracks: []models.VideoTrack{{VideoRangeType: "DOVIWithELHDR10Plus"}}, + }, "DV HDR10+"}, + {"sdr range type stays sdr", &models.MediaFile{ + VideoTracks: []models.VideoTrack{{VideoRangeType: "SDR"}}, + }, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeHDR(tc.file); got != tc.want { + t.Errorf("normalizeHDR = %q, want %q", got, tc.want) + } + }) + } +} + +func TestBestFileRangeTieBreaking(t *testing.T) { + genericHDR := &models.MediaFile{Resolution: "2160p", HDR: true} + explicitHDR10 := &models.MediaFile{ + Resolution: "2160p", + HDR: true, + VideoTracks: []models.VideoTrack{{ColorTransfer: "smpte2084"}}, + } + dv := &models.MediaFile{ + Resolution: "2160p", + HDR: true, + VideoTracks: []models.VideoTrack{{ + DolbyVision: "Profile 8", + ColorTransfer: "smpte2084", + }}, + } + sdr := &models.MediaFile{Resolution: "2160p"} + dvProfileOnly := &models.MediaFile{ + Resolution: "2160p", + HDR: true, + VideoTracks: []models.VideoTrack{{DVProfile: 5}}, + } + lowResDV := &models.MediaFile{ + Resolution: "1080p", + HDR: true, + VideoTracks: []models.VideoTrack{{DolbyVision: "Profile 5"}}, + } + rangeTypeHDR10 := &models.MediaFile{ + Resolution: "2160p", + HDR: true, + VideoTracks: []models.VideoTrack{{VideoRangeType: "HDR10"}}, + } + + cases := []struct { + name string + files []*models.MediaFile + want *models.MediaFile + }{ + {"nil files ignored", []*models.MediaFile{nil, genericHDR}, genericHDR}, + {"dv beats first-scanned generic hdr at same resolution", + []*models.MediaFile{genericHDR, dv}, dv}, + {"dv beats explicit hdr10 at same resolution", + []*models.MediaFile{explicitHDR10, dv}, dv}, + {"dv via profile number only still outranks generic hdr", + []*models.MediaFile{genericHDR, dvProfileOnly}, dvProfileOnly}, + {"range-type-only hdr10 outranks bare boolean", + []*models.MediaFile{genericHDR, rangeTypeHDR10}, rangeTypeHDR10}, + {"explicit hdr10 beats bare boolean at same resolution", + []*models.MediaFile{genericHDR, explicitHDR10}, explicitHDR10}, + {"bare boolean beats sdr at same resolution", + []*models.MediaFile{sdr, genericHDR}, genericHDR}, + {"higher resolution still wins over lower-res dv", + []*models.MediaFile{lowResDV, genericHDR}, genericHDR}, + {"full tie keeps earliest file", + []*models.MediaFile{genericHDR, {Resolution: "2160p", HDR: true}}, genericHDR}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := bestFile(tc.files); got != tc.want { + t.Errorf("bestFile picked %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestBuildSummaryPrefersDolbyVisionSibling(t *testing.T) { + genericHDR := &models.MediaFile{Resolution: "2160p", HDR: true, CodecVideo: "hevc"} + dv := &models.MediaFile{ + Resolution: "2160p", + HDR: true, + CodecVideo: "hevc", + VideoTracks: []models.VideoTrack{{ + Codec: "hevc", + DolbyVision: "Profile 8", + ColorTransfer: "smpte2084", + }}, + } + got := BuildSummary([]*models.MediaFile{genericHDR, dv}) + if got == nil { + t.Fatal("expected non-nil summary") + } + if got.HDR != "DV HDR10" { + t.Errorf("HDR = %q, want %q", got.HDR, "DV HDR10") + } +} + func TestBuildSummaryAggregatesNewFields(t *testing.T) { file := &models.MediaFile{ Resolution: "1080p", diff --git a/web/src/lib/videoRange.test.ts b/web/src/lib/videoRange.test.ts new file mode 100644 index 00000000..fae68b64 --- /dev/null +++ b/web/src/lib/videoRange.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { bestVideoRangeLabel, videoRangeLabel, type VideoRangeSource } from "./videoRange"; + +describe("videoRangeLabel", () => { + it("returns empty for SDR / unprobed", () => { + expect(videoRangeLabel({ hdr: false })).toBe(""); + expect(videoRangeLabel({ hdr: false, video_tracks: [{}] })).toBe(""); + }); + + it("falls back to generic HDR from the bare boolean", () => { + expect(videoRangeLabel({ hdr: true })).toBe("HDR"); + expect(videoRangeLabel({ hdr: true, video_tracks: [{}] })).toBe("HDR"); + }); + + it("detects Dolby Vision from the dolby_vision string", () => { + expect(videoRangeLabel({ hdr: true, video_tracks: [{ dolby_vision: "Profile 5" }] })).toBe( + "DV", + ); + }); + + it("detects Dolby Vision from dv_profile alone", () => { + expect(videoRangeLabel({ hdr: true, video_tracks: [{ dv_profile: 8 }] })).toBe("DV"); + }); + + it("detects Dolby Vision from a DOVI video_range_type", () => { + expect( + videoRangeLabel({ hdr: true, video_tracks: [{ video_range_type: "DOVIWithSDR" }] }), + ).toBe("DV"); + }); + + it("combines DV with HDR10 base-layer compatibility", () => { + expect( + videoRangeLabel({ + hdr: true, + video_tracks: [{ dolby_vision: "Profile 8", video_range_type: "DOVIWithHDR10" }], + }), + ).toBe("DV HDR10"); + expect( + videoRangeLabel({ + hdr: true, + video_tracks: [{ dolby_vision: "Profile 7", color_transfer: "smpte2084" }], + }), + ).toBe("DV HDR10"); + }); + + it("combines DV with HLG compatibility", () => { + expect( + videoRangeLabel({ hdr: true, video_tracks: [{ video_range_type: "DOVIWithHLG" }] }), + ).toBe("DV HLG"); + }); + + it("labels HDR10+ from the flag or range type", () => { + expect(videoRangeLabel({ hdr: true, video_tracks: [{ hdr10_plus: true }] })).toBe("HDR10+"); + expect(videoRangeLabel({ hdr: true, video_tracks: [{ video_range_type: "HDR10Plus" }] })).toBe( + "HDR10+", + ); + expect( + videoRangeLabel({ + hdr: true, + video_tracks: [{ video_range_type: "DOVIWithELHDR10Plus" }], + }), + ).toBe("DV HDR10+"); + }); + + it("labels HDR10 and HLG from video_range_type or color_transfer", () => { + expect(videoRangeLabel({ hdr: true, video_tracks: [{ video_range_type: "HDR10" }] })).toBe( + "HDR10", + ); + expect(videoRangeLabel({ hdr: true, video_tracks: [{ color_transfer: "smpte2084" }] })).toBe( + "HDR10", + ); + expect(videoRangeLabel({ hdr: true, video_tracks: [{ video_range_type: "HLG" }] })).toBe("HLG"); + expect(videoRangeLabel({ hdr: true, video_tracks: [{ color_transfer: "arib-std-b67" }] })).toBe( + "HLG", + ); + }); +}); + +describe("bestVideoRangeLabel", () => { + it("prefers DV over a generic-HDR sibling version", () => { + const versions: VideoRangeSource[] = [ + { hdr: true }, + { + hdr: true, + video_tracks: [{ dolby_vision: "Profile 8", video_range_type: "DOVIWithHDR10" }], + }, + ]; + expect(bestVideoRangeLabel(versions)).toBe("DV HDR10"); + }); + + it("prefers explicit HDR10 over the bare boolean", () => { + const versions: VideoRangeSource[] = [ + { hdr: true }, + { hdr: true, video_tracks: [{ color_transfer: "smpte2084" }] }, + ]; + expect(bestVideoRangeLabel(versions)).toBe("HDR10"); + }); + + it("returns empty when every version is SDR", () => { + expect(bestVideoRangeLabel([{ hdr: false }, { hdr: false }])).toBe(""); + }); +}); diff --git a/web/src/lib/videoRange.ts b/web/src/lib/videoRange.ts new file mode 100644 index 00000000..d76ce959 --- /dev/null +++ b/web/src/lib/videoRange.ts @@ -0,0 +1,89 @@ +// Derives a compact dynamic-range badge label ("DV", "DV HDR10", "HDR10+", +// "HDR10", "HLG", "HDR") from a file version's probed video tracks. Mirrors +// the server-side vocabulary in internal/overlays/summary.go (normalizeHDR) +// so card overlays, detail badges, version pickers, and the player HUD all +// agree. The bare `hdr` boolean is kept as the last-resort fallback for +// files probed before Dolby Vision / video-range metadata existed. + +/** Minimal structural shape shared by VersionVideoTrack and PlayerVideoTrack. */ +export interface VideoRangeTrack { + dolby_vision?: string; + dv_profile?: number; + hdr10_plus?: boolean; + video_range_type?: string; + color_transfer?: string; +} + +/** Minimal structural shape shared by FileVersion and PlayerFileVersion. */ +export interface VideoRangeSource { + hdr?: boolean; + video_tracks?: VideoRangeTrack[]; +} + +function trackHasDolbyVision(track: VideoRangeTrack): boolean { + if (track.dolby_vision?.trim()) return true; + if ((track.dv_profile ?? 0) > 0) return true; + return (track.video_range_type?.trim() ?? "").startsWith("DOVI"); +} + +// Distinguishes HDR variants from the scanner-derived video_range_type enum +// (HDR10, HDR10Plus, HLG, DOVIWith*) with color_transfer as a fallback, +// matching the server's hdrTypeFromTracks. +function trackHdrType(track: VideoRangeTrack): string { + const rangeType = track.video_range_type?.trim() ?? ""; + if (track.hdr10_plus || rangeType.includes("HDR10Plus")) return "HDR10+"; + if (rangeType === "HDR10" || rangeType.endsWith("WithHDR10")) return "HDR10"; + if (rangeType === "HLG" || rangeType.endsWith("WithHLG")) return "HLG"; + + const transfer = track.color_transfer?.toLowerCase() ?? ""; + if (transfer.includes("smpte2084")) return "HDR10"; + if (transfer.includes("arib-std-b67")) return "HLG"; + return ""; +} + +/** + * Display label for a file version's dynamic range: "DV", "DV HDR10", + * "DV HDR10+", "DV HLG", "HDR10+", "HDR10", "HLG", "HDR" (boolean-only + * fallback), or "" for SDR/unknown. + */ +export function videoRangeLabel(source: VideoRangeSource): string { + let hasDV = false; + let hdrType = ""; + for (const track of source.video_tracks ?? []) { + if (trackHasDolbyVision(track)) hasDV = true; + if (!hdrType) hdrType = trackHdrType(track); + } + + if (hasDV) return hdrType ? `DV ${hdrType}` : "DV"; + if (hdrType) return hdrType; + if (source.hdr) return "HDR"; + return ""; +} + +// Rollup preference when a badge summarizes several versions: any DV variant +// beats explicit HDR10+/HDR10/HLG, which beat the generic boolean "HDR". +const LABEL_RANK: Record = { + "DV HDR10+": 8, + "DV HDR10": 7, + "DV HLG": 6, + DV: 5, + "HDR10+": 4, + HDR10: 3, + HLG: 2, + HDR: 1, +}; + +/** Best (most specific) dynamic-range label across a set of versions. */ +export function bestVideoRangeLabel(sources: VideoRangeSource[]): string { + let best = ""; + let bestRank = 0; + for (const source of sources) { + const label = videoRangeLabel(source); + const rank = LABEL_RANK[label] ?? 0; + if (rank > bestRank) { + best = label; + bestRank = rank; + } + } + return best; +} diff --git a/web/src/pages/ItemDetail/components/QualityBadges.tsx b/web/src/pages/ItemDetail/components/QualityBadges.tsx index eff23589..5ae42a5b 100644 --- a/web/src/pages/ItemDetail/components/QualityBadges.tsx +++ b/web/src/pages/ItemDetail/components/QualityBadges.tsx @@ -1,4 +1,5 @@ import type { FileVersion } from "@/api/types"; +import { bestVideoRangeLabel } from "@/lib/videoRange"; import { pickBestAttributes } from "./versionRankingUtils"; interface QualityBadgesProps { @@ -11,7 +12,8 @@ export default function QualityBadges({ versions }: QualityBadgesProps) { const badges: string[] = []; if (best.resolution) badges.push(best.resolution); - if (best.hdr) badges.push("HDR"); + const rangeLabel = bestVideoRangeLabel(versions); + if (rangeLabel) badges.push(rangeLabel); if (best.audioLabel) badges.push(best.audioLabel); if (badges.length === 0) return null; diff --git a/web/src/pages/ItemDetail/components/VersionDropdown.tsx b/web/src/pages/ItemDetail/components/VersionDropdown.tsx index 52463d87..47b36e40 100644 --- a/web/src/pages/ItemDetail/components/VersionDropdown.tsx +++ b/web/src/pages/ItemDetail/components/VersionDropdown.tsx @@ -5,6 +5,7 @@ import type { FileVersion, PlaybackVariant } from "@/api/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { videoRangeLabel } from "@/lib/videoRange"; import { sortPlaybackVariantsByEditionPreference } from "./versionRankingUtils"; import { buildDetailLine, buildQualitySummary, sortByResolution } from "./VersionFlyout"; @@ -128,6 +129,7 @@ export default function VersionDropdown({ const isSelected = version.file_id === activeVersion?.file_id; const summary = buildQualitySummary(version); const detail = buildDetailLine(version); + const rangeLabel = videoRangeLabel(version); return (