diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index b54101eff27..051f9af909d 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -3081,6 +3081,7 @@ export type RetryConfig = { // // @public (undocumented) export type SelectionPreferences = { + videoPreference?: VideoSelectionOption; audioPreference?: AudioSelectionOption; subtitlePreference?: SubtitleSelectionOption; }; @@ -3417,6 +3418,10 @@ export interface UserdataSample { // @public (undocumented) export type VariableMap = Record; +// Warnings were encountered during analysis: +// +// src/config.ts:227:3 - (ae-forgotten-export) The symbol "VideoSelectionOption" needs to be exported by the entry point hls.d.ts + // (No @packageDocumentation comment for this package) ``` diff --git a/docs/API.md b/docs/API.md index ef8fc0d64fb..572d94adc46 100644 --- a/docs/API.md +++ b/docs/API.md @@ -79,6 +79,7 @@ See [API Reference](https://hlsjs-dev.video-dev.org/api-docs/) for a complete li - [`pLoader`](#ploader) - [`xhrSetup`](#xhrsetup) - [`fetchSetup`](#fetchsetup) + - [`videoPreference`](#videopreference) - [`audioPreference`](#audiopreference) - [`subtitlePreference`](#subtitlepreference) - [`abrController`](#abrcontroller) @@ -1145,6 +1146,22 @@ var config = { }; ``` +### `videoPreference` + +(default `undefined`) + +These settings determine whether HDR video should be selected before SDR video. Which VIDEO-RANGE values are allowed, and in what order of priority can also be specified. + +Format `{ preferHdr: boolean, allowedVideoRanges: ('SDR' | 'PQ' | 'HLG')[] }` + +- Allow all video ranges if `allowedVideoRanges` is unspecified. +- If `preferHdr` is defined, use the value to filter `allowedVideoRanges`. +- Else check window for HDR support and set `preferHdr` to the result. + +When `preferHdr` is set, skip checking if the window supports HDR and instead use the value provided to determine level selection preference via dynamic range. A value of `preferHdr === true` will attempt to use HDR levels before selecting from SDR levels. + +`allowedVideoRanges` can restrict playback to a limited set of VIDEO-RANGE transfer functions and set their priority for selection. For example, to ignore all HDR variants, set `allowedVideoRanges` to `['SDR']`. Or, to ignore all HLG variants, set `allowedVideoRanges` to `['SDR', 'PQ']`. To prioritize PQ variants over HLG, set `allowedVideoRanges` to `['SDR', 'HLG', 'PQ']`. + ### `audioPreference` (default: `undefined`) diff --git a/src/config.ts b/src/config.ts index f9be9ab4483..05b3e52938a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,6 +32,7 @@ import type { import type { AudioSelectionOption, SubtitleSelectionOption, + VideoSelectionOption, } from './types/media-playlist'; export type ABRControllerConfig = { @@ -223,6 +224,7 @@ export type StreamControllerConfig = { }; export type SelectionPreferences = { + videoPreference?: VideoSelectionOption; audioPreference?: AudioSelectionOption; subtitlePreference?: SubtitleSelectionOption; }; diff --git a/src/controller/abr-controller.ts b/src/controller/abr-controller.ts index 3747ecd3624..736d0fb81c7 100644 --- a/src/controller/abr-controller.ts +++ b/src/controller/abr-controller.ts @@ -3,6 +3,7 @@ import { Events } from '../events'; import { ErrorDetails } from '../errors'; import { PlaylistLevelType } from '../types/loader'; import { logger } from '../utils/logger'; +import { getVideoSelectionOptions } from '../utils/hdr'; import { SUPPORTED_INFO_DEFAULT, getMediaDecodingInfoPromise, @@ -45,12 +46,18 @@ class AbrController implements AbrComponentAPI { private fragCurrent: Fragment | null = null; private partCurrent: Part | null = null; private bitrateTestDelay: number = 0; + private allowedVideoRanges: Array = []; public bwEstimator: EwmaBandWidthEstimator; constructor(hls: Hls) { this.hls = hls; this.bwEstimator = this.initEstimator(); + const { allowedVideoRanges } = getVideoSelectionOptions( + 'SDR', + hls.config.videoPreference, + ); + this.allowedVideoRanges = allowedVideoRanges; this.registerListeners(); } @@ -633,7 +640,8 @@ class AbrController implements AbrComponentAPI { let currentCodecSet: string | undefined; let currentVideoRange: VideoRange | undefined = 'SDR'; let currentFrameRate = level?.frameRate || 0; - const audioPreference = config.audioPreference; + + const { audioPreference, videoPreference } = config; const audioTracksByGroup = this.audioTracksByGroup || (this.audioTracksByGroup = getAudioTracksByGroup(allAudioTracks)); @@ -654,10 +662,14 @@ class AbrController implements AbrComponentAPI { currentVideoRange, currentBw, audioPreference, + videoPreference, ); - const { codecSet, videoRange, minFramerate, minBitrate } = startTier; + const { codecSet, videoRanges, minFramerate, minBitrate, preferHdr } = + startTier; currentCodecSet = codecSet; - currentVideoRange = videoRange; + currentVideoRange = preferHdr + ? videoRanges[videoRanges.length - 1] + : videoRanges[0]; currentFrameRate = minFramerate; currentBw = Math.max(currentBw, minBitrate); logger.log(`[abr] picked start tier ${JSON.stringify(startTier)}`); @@ -734,7 +746,8 @@ class AbrController implements AbrComponentAPI { // and which decrease or increase frame-rate for up and down-switch respectfully if ( (currentCodecSet && levelInfo.codecSet !== currentCodecSet) || - (currentVideoRange && levelInfo.videoRange !== currentVideoRange) || + (this.allowedVideoRanges.length > 0 && + !this.allowedVideoRanges.includes(levelInfo.videoRange)) || (upSwitch && currentFrameRate > levelInfo.frameRate) || (!upSwitch && currentFrameRate > 0 && diff --git a/src/types/media-playlist.ts b/src/types/media-playlist.ts index e4870535b1b..bd5337be827 100644 --- a/src/types/media-playlist.ts +++ b/src/types/media-playlist.ts @@ -1,5 +1,6 @@ import type { AttrList } from '../utils/attr-list'; import type { LevelDetails } from '../loader/level-details'; +import type { VideoRange } from './level'; export type AudioPlaylistType = 'AUDIO'; @@ -9,6 +10,11 @@ export type SubtitlePlaylistType = 'SUBTITLES' | 'CLOSED-CAPTIONS'; export type MediaPlaylistType = MainPlaylistType | SubtitlePlaylistType; +export type VideoSelectionOption = { + preferHdr?: boolean; + allowedVideoRanges?: Array; +}; + export type AudioSelectionOption = { lang?: string; assocLang?: string; diff --git a/src/utils/hdr.ts b/src/utils/hdr.ts new file mode 100644 index 00000000000..369cecc2ae0 --- /dev/null +++ b/src/utils/hdr.ts @@ -0,0 +1,70 @@ +import { type VideoRange, VideoRangeValues } from '../types/level'; +import type { VideoSelectionOption } from '../types/media-playlist'; + +/** + * @returns Whether we can detect and validate HDR capability within the window context + */ +export function isHdrSupported() { + if (typeof matchMedia === 'function') { + const mediaQueryList = matchMedia('(dynamic-range: high)'); + const badQuery = matchMedia('bad query'); + if (mediaQueryList.media !== badQuery.media) { + return mediaQueryList.matches === true; + } + } + return false; +} + +/** + * Sanitizes inputs to return the active video selection options for HDR/SDR. + * When both inputs are null: + * + * `{ preferHdr: false, allowedVideoRanges: [] }` + * + * When `currentVideoRange` non-null, maintain the active range: + * + * `{ preferHdr: currentVideoRange !== 'SDR', allowedVideoRanges: [currentVideoRange] }` + * + * When VideoSelectionOption non-null: + * + * - Allow all video ranges if `allowedVideoRanges` unspecified. + * - If `preferHdr` is non-null use the value to filter `allowedVideoRanges`. + * - Else check window for HDR support and set `preferHdr` to the result. + * + * @param currentVideoRange + * @param videoPreference + */ +export function getVideoSelectionOptions( + currentVideoRange: VideoRange | undefined, + videoPreference: VideoSelectionOption | undefined, +) { + let preferHdr = false; + let allowedVideoRanges: Array = []; + + if (currentVideoRange) { + preferHdr = currentVideoRange !== 'SDR'; + allowedVideoRanges = [currentVideoRange]; + } + + if (videoPreference) { + allowedVideoRanges = + videoPreference.allowedVideoRanges || VideoRangeValues.slice(0); + preferHdr = + videoPreference.preferHdr !== undefined + ? videoPreference.preferHdr + : isHdrSupported(); + + if (preferHdr) { + allowedVideoRanges = allowedVideoRanges.filter( + (range: VideoRange) => range !== 'SDR', + ); + } else { + allowedVideoRanges = ['SDR']; + } + } + + return { + preferHdr, + allowedVideoRanges, + }; +} diff --git a/src/utils/rendition-helper.ts b/src/utils/rendition-helper.ts index 2191057cb8f..0a46fb38de5 100644 --- a/src/utils/rendition-helper.ts +++ b/src/utils/rendition-helper.ts @@ -1,10 +1,12 @@ import { codecsSetSelectionPreferenceValue } from './codecs'; +import { getVideoSelectionOptions } from './hdr'; import { logger } from './logger'; import type { Level, VideoRange } from '../types/level'; import type { AudioSelectionOption, MediaPlaylist, SubtitleSelectionOption, + VideoSelectionOption, } from '../types/media-playlist'; export type CodecSetTier = { @@ -26,16 +28,18 @@ type AudioTrackGroup = { }; type StartParameters = { codecSet: string | undefined; - videoRange: VideoRange | undefined; + videoRanges: Array; + preferHdr: boolean; minFramerate: number; minBitrate: number; }; export function getStartCodecTier( codecTiers: Record, - videoRange: VideoRange | undefined, + currentVideoRange: VideoRange | undefined, currentBw: number, audioPreference: AudioSelectionOption | undefined, + videoPreference: VideoSelectionOption | undefined, ): StartParameters { const codecSets = Object.keys(codecTiers); const channelsPreference = audioPreference?.channels; @@ -48,14 +52,25 @@ export function getStartCodecTier( let minFramerate = Infinity; let minBitrate = Infinity; let selectedScore = 0; + let videoRanges: Array = []; + + const { preferHdr, allowedVideoRanges } = getVideoSelectionOptions( + currentVideoRange, + videoPreference, + ); + for (let i = codecSets.length; i--; ) { const tier = codecTiers[codecSets[i]]; hasStereo = tier.channels[2] > 0; minHeight = Math.min(minHeight, tier.minHeight); minFramerate = Math.min(minFramerate, tier.minFramerate); minBitrate = Math.min(minBitrate, tier.minBitrate); - if (videoRange) { - hasCurrentVideoRange ||= tier.videoRanges[videoRange] > 0; + const matchingVideoRanges = allowedVideoRanges.filter( + (range) => tier.videoRanges[range] > 0, + ); + if (matchingVideoRanges.length > 0) { + hasCurrentVideoRange = true; + videoRanges = matchingVideoRanges; } } minHeight = Number.isFinite(minHeight) ? minHeight : 0; @@ -64,9 +79,10 @@ export function getStartCodecTier( const maxFramerate = Math.max(30, minFramerate); minBitrate = Number.isFinite(minBitrate) ? minBitrate : currentBw; currentBw = Math.max(minBitrate, currentBw); - // If there are no SDR variants, set currentVideoRange to undefined + // If there are no variants with matching preference, set currentVideoRange to undefined if (!hasCurrentVideoRange) { - videoRange = undefined; + currentVideoRange = undefined; + videoRanges = []; } const codecSet = codecSets.reduce( (selected: string | undefined, candidate: string) => { @@ -134,10 +150,12 @@ export function getStartCodecTier( ); return selected; } - if (videoRange && candidateTier.videoRanges[videoRange] === 0) { + if (!videoRanges.some((range) => candidateTier.videoRanges[range] > 0)) { logStartCodecCandidateIgnored( candidate, - `no variants with VIDEO-RANGE of ${videoRange} found`, + `no variants with VIDEO-RANGE of ${JSON.stringify( + videoRanges, + )} found`, ); return selected; } @@ -164,7 +182,8 @@ export function getStartCodecTier( ); return { codecSet, - videoRange, + videoRanges, + preferHdr, minFramerate, minBitrate, };