Files
jellyfin/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
NoFear0411 e5bbb1ea0c
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
OpenAPI / OpenAPI - HEAD (push) Has been cancelled
OpenAPI / OpenAPI - BASE (push) Has been cancelled
OpenAPI / OpenAPI - Difference (push) Has been cancelled
OpenAPI / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI / OpenAPI - Publish Stable Spec (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Add spec-compliant dvh1 HLS variant for Dolby Vision Profile 5 (#16362)
* Add spec-compliant dvh1 HLS variant for Dolby Vision Profile 5

DV Profile 5 has no backward-compatible base layer, so
SUPPLEMENTAL-CODECS cannot be used. The master playlist
currently labels P5 streams as hvc1 in the CODECS field,
even though DynamicHlsController already passes
-tag✌️0 dvh1 -strict -2 to FFmpeg for P5 copy-codec
streams, writing a dvh1 FourCC and dvvC configuration box
into the fMP4 init segment. This mismatch between the
manifest (hvc1) and the bitstream (dvh1) causes
spec-compliant clients like Apple TV and webOS 24+ to set
up an HDR10 pipeline instead of a Dolby Vision one.

Add a dvh1 variant before the existing hvc1 variant for P5
copy-codec streams. Both variants point to the same stream
URL. Spec-compliant clients select dvh1 and activate the
DV decoder path. Legacy clients that reject dvh1 in CODECS
fall through to the hvc1 variant and detect DV from the
init segment, preserving existing behavior.

Fixes #16179

* Address review: support AV1 DoVi P10, add client capability check

- GetDoviString: add isAv1 parameter, return dav1 FourCC for AV1 DoVi
  (P10 bl_compat_id=0) and dvh1 for HEVC DoVi (P5)
- Remove redundant IsDovi() check; VideoRangeType.DOVI is sufficient
  and correctly limits to profiles without a compatible base layer
- Replace IsDoviRemoved() with client capability check using
  GetRequestedRangeTypes(state.VideoStream.Codec) to only emit the
  dvh1/dav1 variant for clients that declared DOVI support
- Update comments and doc summary to reflect P5 + P10/bl0 scope

* Use codec string instead of boolean for DoVi FourCC mapping

Replace bool isAv1 with string codec in GetDoviString for
future-proofing when DoVi extends to H.266/VVC or AV2.

* Move AppendDoviPlaylist next to AppendPlaylist

* Fix SA1508: remove blank line before closing brace

* Use AppendLine() instead of Append(Environment.NewLine)
2026-03-28 22:12:06 +01:00

371 lines
11 KiB
C#

using System;
using System.Globalization;
using System.Text;
namespace Jellyfin.Api.Helpers;
/// <summary>
/// Helpers to generate HLS codec strings according to
/// <a href="https://datatracker.ietf.org/doc/html/rfc6381#section-3.3">RFC 6381 section 3.3</a>
/// and the <a href="https://mp4ra.org">MP4 Registration Authority</a>.
/// </summary>
public static class HlsCodecStringHelpers
{
/// <summary>
/// Codec name for MP3.
/// </summary>
public const string MP3 = "mp4a.40.34";
/// <summary>
/// Codec name for AC-3.
/// </summary>
public const string AC3 = "ac-3";
/// <summary>
/// Codec name for E-AC-3.
/// </summary>
public const string EAC3 = "ec-3";
/// <summary>
/// Codec name for FLAC.
/// </summary>
public const string FLAC = "fLaC";
/// <summary>
/// Codec name for ALAC.
/// </summary>
public const string ALAC = "alac";
/// <summary>
/// Codec name for OPUS.
/// </summary>
public const string OPUS = "Opus";
/// <summary>
/// Codec name for TRUEHD.
/// </summary>
public const string TRUEHD = "mlpa";
/// <summary>
/// Gets a MP3 codec string.
/// </summary>
/// <returns>MP3 codec string.</returns>
public static string GetMP3String()
{
return MP3;
}
/// <summary>
/// Gets an AAC codec string.
/// </summary>
/// <param name="profile">AAC profile.</param>
/// <returns>AAC codec string.</returns>
public static string GetAACString(string? profile)
{
StringBuilder result = new StringBuilder("mp4a", 9);
if (string.Equals(profile, "HE-AAC", StringComparison.OrdinalIgnoreCase))
{
result.Append(".40.5");
}
else
{
// Default to LC if profile is invalid
result.Append(".40.2");
}
return result.ToString();
}
/// <summary>
/// Gets an AC-3 codec string.
/// </summary>
/// <returns>AC-3 codec string.</returns>
public static string GetAC3String()
{
return AC3;
}
/// <summary>
/// Gets an E-AC-3 codec string.
/// </summary>
/// <returns>E-AC-3 codec string.</returns>
public static string GetEAC3String()
{
return EAC3;
}
/// <summary>
/// Gets an FLAC codec string.
/// </summary>
/// <returns>FLAC codec string.</returns>
public static string GetFLACString()
{
return FLAC;
}
/// <summary>
/// Gets an ALAC codec string.
/// </summary>
/// <returns>ALAC codec string.</returns>
public static string GetALACString()
{
return ALAC;
}
/// <summary>
/// Gets an OPUS codec string.
/// </summary>
/// <returns>OPUS codec string.</returns>
public static string GetOPUSString()
{
return OPUS;
}
/// <summary>
/// Gets an TRUEHD codec string.
/// </summary>
/// <returns>TRUEHD codec string.</returns>
public static string GetTRUEHDString()
{
return TRUEHD;
}
/// <summary>
/// Gets an DTS codec string.
/// </summary>
/// <param name="profile">DTS profile.</param>
/// <returns>DTS codec string.</returns>
public static string GetDTSString(string? profile)
{
if (string.Equals(profile, "DTS", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS-ES", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS 96/24", StringComparison.OrdinalIgnoreCase))
{
return "dtsc";
}
if (string.Equals(profile, "DTS-HD HRA", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS-HD MA", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS-HD MA + DTS:X", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS-HD MA + DTS:X IMAX", StringComparison.OrdinalIgnoreCase))
{
return "dtsh";
}
if (string.Equals(profile, "DTS Express", StringComparison.OrdinalIgnoreCase))
{
return "dtse";
}
// Default to DTS core if profile is invalid
return "dtsc";
}
/// <summary>
/// Gets a H.264 codec string.
/// </summary>
/// <param name="profile">H.264 profile.</param>
/// <param name="level">H.264 level.</param>
/// <returns>H.264 string.</returns>
public static string GetH264String(string? profile, int level)
{
StringBuilder result = new StringBuilder("avc1", 11);
if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase))
{
result.Append(".6400");
}
else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase))
{
result.Append(".4D40");
}
else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase))
{
result.Append(".42E0");
}
else
{
// Default to constrained baseline if profile is invalid
result.Append(".4240");
}
string levelHex = level.ToString("X2", CultureInfo.InvariantCulture);
result.Append(levelHex);
return result.ToString();
}
/// <summary>
/// Gets a H.265 codec string.
/// </summary>
/// <param name="profile">H.265 profile.</param>
/// <param name="level">H.265 level.</param>
/// <returns>H.265 string.</returns>
public static string GetH265String(string? profile, int level)
{
// The h265 syntax is a bit of a mystery at the time this comment was written.
// This is what I've found through various sources:
// FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
StringBuilder result = new StringBuilder("hvc1", 16);
if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase))
{
result.Append(".2.4");
}
else
{
// Default to main if profile is invalid
result.Append(".1.4");
}
result.Append(".L")
.Append(level)
.Append(".B0");
return result.ToString();
}
/// <summary>
/// Gets a VP9 codec string.
/// </summary>
/// <param name="width">Video width.</param>
/// <param name="height">Video height.</param>
/// <param name="pixelFormat">Video pixel format.</param>
/// <param name="framerate">Video framerate.</param>
/// <param name="bitDepth">Video bitDepth.</param>
/// <returns>The VP9 codec string.</returns>
public static string GetVp9String(int width, int height, string pixelFormat, float framerate, int bitDepth)
{
// refer: https://www.webmproject.org/vp9/mp4/
StringBuilder result = new StringBuilder("vp09", 13);
var profileString = pixelFormat switch
{
"yuv420p" => "00",
"yuvj420p" => "00",
"yuv422p" => "01",
"yuv444p" => "01",
"yuv420p10le" => "02",
"yuv420p12le" => "02",
"yuv422p10le" => "03",
"yuv422p12le" => "03",
"yuv444p10le" => "03",
"yuv444p12le" => "03",
_ => "00"
};
var lumaPictureSize = width * height;
var lumaSampleRate = lumaPictureSize * framerate;
var levelString = lumaPictureSize switch
{
<= 0 => "00",
<= 36864 => "10",
<= 73728 => "11",
<= 122880 => "20",
<= 245760 => "21",
<= 552960 => "30",
<= 983040 => "31",
<= 2228224 => lumaSampleRate <= 83558400 ? "40" : "41",
<= 8912896 => lumaSampleRate <= 311951360 ? "50" : (lumaSampleRate <= 588251136 ? "51" : "52"),
<= 35651584 => lumaSampleRate <= 1176502272 ? "60" : (lumaSampleRate <= 4706009088 ? "61" : "62"),
_ => "00" // This should not happen
};
if (bitDepth != 8
&& bitDepth != 10
&& bitDepth != 12)
{
// Default to 8 bits
bitDepth = 8;
}
result.Append('.').Append(profileString).Append('.').Append(levelString);
var bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture);
result.Append('.')
.Append(bitDepthD2);
return result.ToString();
}
/// <summary>
/// Gets an AV1 codec string.
/// </summary>
/// <param name="profile">AV1 profile.</param>
/// <param name="level">AV1 level.</param>
/// <param name="tierFlag">AV1 tier flag.</param>
/// <param name="bitDepth">AV1 bit depth.</param>
/// <returns>The AV1 codec string.</returns>
public static string GetAv1String(string? profile, int level, bool tierFlag, int bitDepth)
{
// https://aomediacodec.github.io/av1-isobmff/#codecsparam
// FORMAT: [codecTag].[profile].[level][tier].[bitDepth]
StringBuilder result = new StringBuilder("av01", 13);
if (string.Equals(profile, "Main", StringComparison.OrdinalIgnoreCase))
{
result.Append(".0");
}
else if (string.Equals(profile, "High", StringComparison.OrdinalIgnoreCase))
{
result.Append(".1");
}
else if (string.Equals(profile, "Professional", StringComparison.OrdinalIgnoreCase))
{
result.Append(".2");
}
else
{
// Default to Main
result.Append(".0");
}
if (level is <= 0 or > 31)
{
// Default to the maximum defined level 6.3
level = 19;
}
if (bitDepth != 8
&& bitDepth != 10
&& bitDepth != 12)
{
// Default to 8 bits
bitDepth = 8;
}
result.Append('.')
// Needed to pad it double digits; otherwise, browsers will reject the stream.
.AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", level)
.Append(tierFlag ? 'H' : 'M');
string bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture);
result.Append('.')
.Append(bitDepthD2);
return result.ToString();
}
/// <summary>
/// Gets a Dolby Vision codec string for profiles without a compatible base layer.
/// </summary>
/// <param name="dvProfile">Dolby Vision profile number.</param>
/// <param name="dvLevel">Dolby Vision level number.</param>
/// <param name="codec">Video codec name (e.g. "hevc", "av1") to determine the DoVi FourCC.</param>
/// <returns>Dolby Vision codec string.</returns>
public static string GetDoviString(int dvProfile, int dvLevel, string codec)
{
// HEVC DoVi uses dvh1, AV1 DoVi uses dav1 (out-of-band parameter sets, recommended by Apple HLS spec Rule 1.10)
var fourCc = string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase) ? "dav1" : "dvh1";
StringBuilder result = new StringBuilder(fourCc, 12);
result.Append('.')
.AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", dvProfile)
.Append('.')
.AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", dvLevel);
return result.ToString();
}
}