From dd5fd1ef8f9defe6f306a1bf085e1019a67c984d Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:13:40 -0500 Subject: [PATCH] fix cropping jellyfin and emby content that is too small (#2481) * fix cropping jellyfin and emby content that is too small * fix transcoding tests with nvidia * update dependencies --- .config/dotnet-tools.json | 2 +- CHANGELOG.md | 1 + .../ErsatzTV.Core.Tests.csproj | 2 +- .../FFmpegPlaybackSettingsCalculatorTests.cs | 31 ++++++++++++ .../FFmpegPlaybackSettingsCalculator.cs | 47 ++++++++++++------- .../ErsatzTV.FFmpeg.Tests.csproj | 2 +- ErsatzTV.FFmpeg/AspectRatio.cs | 30 ++++++++++++ .../ErsatzTV.Infrastructure.Tests.csproj | 2 +- .../Core/FFmpeg/TranscodingTests.cs | 23 +++++---- .../ErsatzTV.Scanner.Tests.csproj | 2 +- ErsatzTV/ErsatzTV.csproj | 4 +- 11 files changed, 111 insertions(+), 35 deletions(-) create mode 100644 ErsatzTV.FFmpeg/AspectRatio.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 150ea5616..0b7967ddb 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2025.2.1", + "version": "2025.2.2.1", "commands": [ "jb" ], diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d9a2b81..5b386abb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix some more hls.js warnings by adding codec information to multi-variant playlists - Fix hardware decode of h264 constrained baseline content using VAAPI accel - Custom stream selector: ignore embedded text subtitles that have not been extracted +- Fix cropping Jellyfin and Emby content that is smaller than the crop resolution ### Changed - Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj index 5cca821be..47444e74c 100644 --- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj +++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj @@ -15,7 +15,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs index cf63a1930..36ee3f5b8 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs @@ -430,6 +430,37 @@ public class FFmpegPlaybackSettingsCalculatorTests actual.PadToDesiredResolution.ShouldBeFalse(); } + [Test] + public void Should_ScaleBeyondMinSize_ForCrop_ForTransportStream_UnknownSAR() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + Resolution = new Resolution { Width = 640, Height = 411 }, + ScalingBehavior = ScalingBehavior.Crop + }; + + var version = new MediaVersion + { Width = 626, Height = 476, SampleAspectRatio = "0:0", DisplayAspectRatio = "4:3" }; + + FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + version, + new MediaStream(), + DateTimeOffset.Now, + DateTimeOffset.Now, + TimeSpan.Zero, + TimeSpan.Zero, + false, + StreamInputKind.Vod, + None); + + IDisplaySize scaledSize = actual.ScaledSize.IfNone(new MediaVersion { Width = 0, Height = 0 }); + scaledSize.Width.ShouldBe(640); + scaledSize.Height.ShouldBe(480); + actual.PadToDesiredResolution.ShouldBeFalse(); + } + [Test] public void Should_ScaleDownToMinSize_ForCrop_ForTransportStream() { diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index 1539ddd52..25470eed4 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -92,9 +92,18 @@ public static class FFmpegPlaybackSettingsCalculator case StreamingMode.TransportStream: result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration; - if (NeedToScale(ffmpegProfile, videoVersion) && videoVersion.SampleAspectRatio != "0:0") + string sampleAspectRatio = videoVersion.SampleAspectRatio; + if (sampleAspectRatio == "0:0") { - DisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion); + sampleAspectRatio = AspectRatio.CalculateSAR( + videoVersion.Width, + videoVersion.Height, + videoVersion.DisplayAspectRatio); + } + + if (NeedToScale(ffmpegProfile, videoVersion, sampleAspectRatio)) + { + DisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion, sampleAspectRatio); if (!scaledSize.IsSameSizeAs(videoVersion)) { int fixedHeight = scaledSize.Height + scaledSize.Height % 2; @@ -229,11 +238,11 @@ public static class FFmpegPlaybackSettingsCalculator FrameRate = 24 }; - private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) => - IsIncorrectSize(ffmpegProfile.Resolution, version) || - IsTooLarge(ffmpegProfile.Resolution, version) || - IsOddSize(version) || - TooSmallToCrop(ffmpegProfile, version); + private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version, string sampleAspectRatio) => + (IsIncorrectSize(ffmpegProfile.Resolution, version) || + IsTooLarge(ffmpegProfile.Resolution, version) || + IsOddSize(version) || + TooSmallToCrop(ffmpegProfile, version)) && sampleAspectRatio != "0:0"; private static bool IsIncorrectSize(Resolution desiredResolution, MediaVersion version) => IsAnamorphic(version) || @@ -257,14 +266,17 @@ public static class FFmpegPlaybackSettingsCalculator return version.Height < ffmpegProfile.Resolution.Height || version.Width < ffmpegProfile.Resolution.Width; } - private static DisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version) + private static DisplaySize CalculateScaledSize( + FFmpegProfile ffmpegProfile, + MediaVersion version, + string sampleAspectRatio) { - DisplaySize sarSize = SARSize(version); - int p = version.Width * sarSize.Width; - int q = version.Height * sarSize.Height; + (double sarWidth, double sarHeight) = SARSize(sampleAspectRatio); + var p = (int)Math.Round(version.Width * sarWidth); + var q = (int)Math.Round(version.Height * sarHeight); int g = Gcd(q, p); - p = p / g; - q = q / g; + p /= g; + q /= g; Resolution targetSize = ffmpegProfile.Resolution; int hw1 = targetSize.Width; int hh1 = hw1 * q / p; @@ -323,11 +335,10 @@ public static class FFmpegPlaybackSettingsCalculator return version.DisplayAspectRatio != $"{version.Width}:{version.Height}"; } - private static DisplaySize SARSize(MediaVersion version) + private static (double, double) SARSize(string sampleAspectRatio) { - string[] split = version.SampleAspectRatio.Split(":"); - return new DisplaySize( - int.Parse(split[0], CultureInfo.InvariantCulture), - int.Parse(split[1], CultureInfo.InvariantCulture)); + string[] split = sampleAspectRatio.Split(":"); + return (double.Parse(split[0], CultureInfo.InvariantCulture), + double.Parse(split[1], CultureInfo.InvariantCulture)); } } diff --git a/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj b/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj index b39dd78b8..459b99776 100644 --- a/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj +++ b/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/ErsatzTV.FFmpeg/AspectRatio.cs b/ErsatzTV.FFmpeg/AspectRatio.cs new file mode 100644 index 000000000..0ba7ce3d0 --- /dev/null +++ b/ErsatzTV.FFmpeg/AspectRatio.cs @@ -0,0 +1,30 @@ +using System.Globalization; + +namespace ErsatzTV.FFmpeg; + +public static class AspectRatio +{ + public static string CalculateSAR(int width, int height, string displayAspectRatio) + { + // first check for decimal DAR + if (!double.TryParse(displayAspectRatio, out double dar)) + { + // if not, assume it's a ratio + string[] split = displayAspectRatio.Split(':'); + var num = double.Parse(split[0], CultureInfo.InvariantCulture); + var den = double.Parse(split[1], CultureInfo.InvariantCulture); + dar = num / den; + } + + double res = width / (double)height; + var formattedDar = string.Format( + CultureInfo.InvariantCulture, + dar % 1 == 0 ? "{0:F0}" : "{0:0.############}", + dar); + var formattedRes = string.Format( + CultureInfo.InvariantCulture, + res % 1 == 0 ? "{0:F0}" : "{0:0.############}", + res); + return $"{formattedDar}:{formattedRes}"; + } +} diff --git a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj index 2d105aa4f..a6d91391c 100644 --- a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj +++ b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index 810a65ecb..9bfbba551 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -17,6 +17,7 @@ using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Metadata; using ErsatzTV.FFmpeg; using ErsatzTV.FFmpeg.Capabilities; +using ErsatzTV.FFmpeg.Capabilities.Nvidia; using ErsatzTV.FFmpeg.Filter; using ErsatzTV.FFmpeg.Filter.Cuda; using ErsatzTV.FFmpeg.Filter.Qsv; @@ -112,7 +113,7 @@ public class TranscodingTests public static Watermark[] Watermarks = [ Watermark.None, - Watermark.PermanentOpaqueScaled, + //Watermark.PermanentOpaqueScaled, // Watermark.PermanentOpaqueActualSize, // Watermark.PermanentTransparentScaled, // Watermark.PermanentTransparentActualSize @@ -121,7 +122,7 @@ public class TranscodingTests public static Subtitle[] Subtitles = [ Subtitle.None, - Subtitle.Picture, + //Subtitle.Picture, // Subtitle.Text ]; @@ -133,8 +134,8 @@ public class TranscodingTests public static ScalingBehavior[] ScalingBehaviors = [ - ScalingBehavior.ScaleAndPad - //ScalingBehavior.Crop, + ScalingBehavior.ScaleAndPad, + ScalingBehavior.Crop, //ScalingBehavior.Stretch ]; @@ -150,20 +151,20 @@ public class TranscodingTests new("libx264", "yuv420p", "tv", "smpte170m", "bt709", "smpte170m"), // // // // // // // example format that requires setparams filter - new("libx264", "yuv420p", string.Empty, string.Empty, string.Empty, string.Empty), + //new("libx264", "yuv420p", string.Empty, string.Empty, string.Empty, string.Empty), // // // // // // // new("libx264", "yuvj420p"), - new("libx264", "yuv420p10le"), + //new("libx264", "yuv420p10le"), // // // // new("libx264", "yuv444p10le"), // // // // // // // new("mpeg1video", "yuv420p"), // // // // - new("mpeg2video", "yuv420p"), + //new("mpeg2video", "yuv420p"), // // //new InputFormat("libx265", "yuv420p"), - new("libx265", "yuv420p10le"), + //new("libx265", "yuv420p10le"), // - new("mpeg4", "yuv420p") + //new("mpeg4", "yuv420p") // // new("libvpx-vp9", "yuv420p"), // new("libvpx-vp9", "yuv420p10le"), @@ -187,7 +188,7 @@ public class TranscodingTests public static FFmpegProfileBitDepth[] BitDepths = [ FFmpegProfileBitDepth.EightBit, - FFmpegProfileBitDepth.TenBit + //FFmpegProfileBitDepth.TenBit ]; public static FFmpegProfileVideoFormat[] VideoFormats = @@ -443,6 +444,8 @@ public class TranscodingTests [ValueSource(typeof(TestData), nameof(TestData.StreamingModes))] StreamingMode streamingMode) { + NvEncSharpRedirector.Init(); + string file = fileToTest; if (string.IsNullOrWhiteSpace(file)) { diff --git a/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj b/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj index 5717300b7..9551d8c08 100644 --- a/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj +++ b/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 2698b65d1..19b190976 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -54,10 +54,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - +