diff --git a/CHANGELOG.md b/CHANGELOG.md index ce165c20b..60211187b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Add `Scaling Behavior` option to FFmpeg Profile - `Scale and Pad`: the default behavior and will maintain aspect ratio of all content - - `Stretch`: a new mode that will NOT maintain aspect ratio when normalizing source content to the desired resolution + - `Stretch`: a new mode that will NOT maintain aspect ratio when normalizing source content to the desired resolution + - `Crop`: a new mode that will scale beyond the desired resolution (maintaining aspect ratio), and crop to desired resolution + - **This mode does NOT detect black and intelligently crop** + - The goal is to fill the canvas by over-scaling and cropping, instead of minimally scaling and padding ### Changed - Upgrade ffmpeg to 6.1, which is now *required* for all installs diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj index 24fdb9810..9b2e1f087 100644 --- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj +++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/ErsatzTV.Core/Domain/ScalingBehavior.cs b/ErsatzTV.Core/Domain/ScalingBehavior.cs index 42e62d28d..b9283e53e 100644 --- a/ErsatzTV.Core/Domain/ScalingBehavior.cs +++ b/ErsatzTV.Core/Domain/ScalingBehavior.cs @@ -3,5 +3,6 @@ namespace ErsatzTV.Core.Domain; public enum ScalingBehavior { ScaleAndPad = 0, - Stretch = 1 + Stretch = 1, + Crop = 2 } diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj index 9264377e2..aab3eaaee 100644 --- a/ErsatzTV.Core/ErsatzTV.Core.csproj +++ b/ErsatzTV.Core/ErsatzTV.Core.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 1d10f5faa..f973b50c8 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -313,10 +313,22 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height); + Option cropSize = Option.None; + if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Stretch) { scaledSize = paddedSize; } + + if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Crop) + { + paddedSize = ffmpegVideoStream.SquarePixelFrameSizeForCrop( + new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)); + + cropSize = new FrameSize( + channel.FFmpegProfile.Resolution.Width, + channel.FFmpegProfile.Resolution.Height); + } var desiredState = new FrameState( playbackSettings.RealtimeOutput, @@ -326,6 +338,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Optional(playbackSettings.PixelFormat), scaledSize, paddedSize, + cropSize, false, playbackSettings.FrameRate, playbackSettings.VideoBitrate, @@ -429,6 +442,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService new PixelFormatYuv420P(), new FrameSize(desiredResolution.Width, desiredResolution.Height), new FrameSize(desiredResolution.Width, desiredResolution.Height), + Option.None, false, playbackSettings.FrameRate, playbackSettings.VideoBitrate, diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index 93c76f191..2097612f1 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -61,6 +61,7 @@ public class PipelineBuilderBaseTests new PixelFormatYuv420P(), new FrameSize(1920, 1080), new FrameSize(1920, 1080), + Option.None, false, Option.None, 2000, @@ -149,6 +150,7 @@ public class PipelineBuilderBaseTests new PixelFormatYuv420P(), new FrameSize(1920, 1080), new FrameSize(1920, 1080), + Option.None, false, Option.None, 2000, @@ -291,6 +293,7 @@ public class PipelineBuilderBaseTests Option.None, new FrameSize(1920, 1080), new FrameSize(1920, 1080), + Option.None, false, Option.None, 2000, @@ -373,6 +376,7 @@ public class PipelineBuilderBaseTests new PixelFormatYuv420P(), new FrameSize(1920, 1080), new FrameSize(1920, 1080), + Option.None, false, Option.None, 2000, diff --git a/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj b/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj index ccb7b5d0b..169c1d629 100644 --- a/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj +++ b/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj @@ -10,7 +10,7 @@ - + diff --git a/ErsatzTV.FFmpeg/Filter/CropFilter.cs b/ErsatzTV.FFmpeg/Filter/CropFilter.cs new file mode 100644 index 000000000..cf269c1bd --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/CropFilter.cs @@ -0,0 +1,49 @@ +using ErsatzTV.FFmpeg.Format; + +namespace ErsatzTV.FFmpeg.Filter; + +public class CropFilter : BaseFilter +{ + private readonly FrameState _currentState; + private readonly FrameSize _croppedSize; + + public CropFilter(FrameState currentState, FrameSize croppedSize) + { + _currentState = currentState; + _croppedSize = croppedSize; + } + + public override string Filter + { + get + { + var crop = $"crop=w={_croppedSize.Width}:h={_croppedSize.Height}"; + + if (_currentState.FrameDataLocation == FrameDataLocation.Hardware) + { + foreach (IPixelFormat pixelFormat in _currentState.PixelFormat) + { + if (pixelFormat is PixelFormatVaapi) + { + foreach (IPixelFormat pf in AvailablePixelFormats.ForPixelFormat(pixelFormat.Name, null)) + { + return $"hwdownload,format=vaapi|{pf.FFmpegName},{crop}"; + } + } + + return $"hwdownload,format={pixelFormat.FFmpegName},{crop}"; + } + + return $"hwdownload,{crop}"; + } + + return crop; + } + } + + public override FrameState NextState(FrameState currentState) => currentState with + { + PaddedSize = _croppedSize, + FrameDataLocation = FrameDataLocation.Software + }; +} diff --git a/ErsatzTV.FFmpeg/Filter/Cuda/ScaleCudaFilter.cs b/ErsatzTV.FFmpeg/Filter/Cuda/ScaleCudaFilter.cs index 7abc132d8..35b50b498 100644 --- a/ErsatzTV.FFmpeg/Filter/Cuda/ScaleCudaFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/Cuda/ScaleCudaFilter.cs @@ -7,17 +7,20 @@ public class ScaleCudaFilter : BaseFilter private readonly FrameState _currentState; private readonly bool _isAnamorphicEdgeCase; private readonly FrameSize _paddedSize; + private readonly Option _croppedSize; private readonly FrameSize _scaledSize; public ScaleCudaFilter( FrameState currentState, FrameSize scaledSize, FrameSize paddedSize, + Option croppedSize, bool isAnamorphicEdgeCase) { _currentState = currentState; _scaledSize = scaledSize; _paddedSize = paddedSize; + _croppedSize = croppedSize; _isAnamorphicEdgeCase = isAnamorphicEdgeCase; } @@ -39,7 +42,9 @@ public class ScaleCudaFilter : BaseFilter string aspectRatio = string.Empty; if (_scaledSize != _paddedSize) { - aspectRatio = ":force_original_aspect_ratio=decrease"; + aspectRatio = _croppedSize.IsSome + ? ":force_original_aspect_ratio=increase" + : ":force_original_aspect_ratio=decrease"; } string squareScale = string.Empty; diff --git a/ErsatzTV.FFmpeg/Filter/ScaleFilter.cs b/ErsatzTV.FFmpeg/Filter/ScaleFilter.cs index 64ca2e7ef..2cf55a1d1 100644 --- a/ErsatzTV.FFmpeg/Filter/ScaleFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/ScaleFilter.cs @@ -7,13 +7,20 @@ public class ScaleFilter : BaseFilter private readonly FrameState _currentState; private readonly bool _isAnamorphicEdgeCase; private readonly FrameSize _paddedSize; + private readonly Option _croppedSize; private readonly FrameSize _scaledSize; - public ScaleFilter(FrameState currentState, FrameSize scaledSize, FrameSize paddedSize, bool isAnamorphicEdgeCase) + public ScaleFilter( + FrameState currentState, + FrameSize scaledSize, + FrameSize paddedSize, + Option croppedSize, + bool isAnamorphicEdgeCase) { _currentState = currentState; _scaledSize = scaledSize; _paddedSize = paddedSize; + _croppedSize = croppedSize; _isAnamorphicEdgeCase = isAnamorphicEdgeCase; } @@ -29,7 +36,9 @@ public class ScaleFilter : BaseFilter string aspectRatio = string.Empty; if (_scaledSize != _paddedSize) { - aspectRatio = ":force_original_aspect_ratio=decrease"; + aspectRatio = _croppedSize.IsSome + ? ":force_original_aspect_ratio=increase" + : ":force_original_aspect_ratio=decrease"; } string scale; diff --git a/ErsatzTV.FFmpeg/Filter/Vaapi/ScaleVaapiFilter.cs b/ErsatzTV.FFmpeg/Filter/Vaapi/ScaleVaapiFilter.cs index afb525267..ff374b3f2 100644 --- a/ErsatzTV.FFmpeg/Filter/Vaapi/ScaleVaapiFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/Vaapi/ScaleVaapiFilter.cs @@ -7,17 +7,20 @@ public class ScaleVaapiFilter : BaseFilter private readonly FrameState _currentState; private readonly bool _isAnamorphicEdgeCase; private readonly FrameSize _paddedSize; + private readonly Option _croppedSize; private readonly FrameSize _scaledSize; public ScaleVaapiFilter( FrameState currentState, FrameSize scaledSize, FrameSize paddedSize, + Option croppedSize, bool isAnamorphicEdgeCase) { _currentState = currentState; _scaledSize = scaledSize; _paddedSize = paddedSize; + _croppedSize = croppedSize; _isAnamorphicEdgeCase = isAnamorphicEdgeCase; } @@ -40,7 +43,9 @@ public class ScaleVaapiFilter : BaseFilter string aspectRatio = string.Empty; if (_scaledSize != _paddedSize) { - aspectRatio = ":force_original_aspect_ratio=decrease"; + aspectRatio = _croppedSize.IsSome + ? ":force_original_aspect_ratio=increase" + : ":force_original_aspect_ratio=decrease"; } string squareScale = string.Empty; diff --git a/ErsatzTV.FFmpeg/FrameState.cs b/ErsatzTV.FFmpeg/FrameState.cs index e51ed603b..649c25401 100644 --- a/ErsatzTV.FFmpeg/FrameState.cs +++ b/ErsatzTV.FFmpeg/FrameState.cs @@ -10,6 +10,7 @@ public record FrameState( Option PixelFormat, FrameSize ScaledSize, FrameSize PaddedSize, + Option CroppedSize, bool IsAnamorphic, Option FrameRate, Option VideoBitrate, diff --git a/ErsatzTV.FFmpeg/MediaStream.cs b/ErsatzTV.FFmpeg/MediaStream.cs index 34079a0ba..a490c9bc3 100644 --- a/ErsatzTV.FFmpeg/MediaStream.cs +++ b/ErsatzTV.FFmpeg/MediaStream.cs @@ -120,6 +120,36 @@ public record VideoStream( return result; } + + public FrameSize SquarePixelFrameSizeForCrop(FrameSize resolution) + { + int width = FrameSize.Width; + int height = FrameSize.Height; + + if (IsAnamorphic) + { + double sar = GetSAR(); + bool edgeCase = IsAnamorphicEdgeCase; + + width = edgeCase + ? FrameSize.Width + : (int)Math.Floor(FrameSize.Width * sar); + + height = edgeCase + ? (int)Math.Floor(FrameSize.Height * sar) + : FrameSize.Height; + } + + double widthPercent = (double)resolution.Width / width; + double heightPercent = (double)resolution.Height / height; + double maxPercent = Math.Max(widthPercent, heightPercent); + + var result = new FrameSize( + (int)Math.Floor(width * maxPercent), + (int)Math.Floor(height * maxPercent)); + + return result; + } private double GetSAR() { diff --git a/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs index 028d8bbbf..6d2855e28 100644 --- a/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs @@ -182,6 +182,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder currentState with { PixelFormat = Some(pixelFormat) }, currentState.ScaledSize, currentState.PaddedSize, + Option.None, false); currentState = filter.NextState(currentState); videoInputFile.FilterSteps.Add(filter); @@ -517,7 +518,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder FrameState desiredState, FrameState currentState) { - if (currentState.PaddedSize != desiredState.PaddedSize) + if (desiredState.CroppedSize.IsNone && currentState.PaddedSize != desiredState.PaddedSize) { IPipelineFilterStep padStep = new PadFilter(currentState, desiredState.PaddedSize); currentState = padStep.NextState(currentState); @@ -555,6 +556,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder currentState, desiredState.ScaledSize, desiredState.PaddedSize, + desiredState.CroppedSize, VideoStream.IsAnamorphicEdgeCase); } else @@ -579,6 +581,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder }, desiredState.ScaledSize, desiredState.PaddedSize, + desiredState.CroppedSize, VideoStream.IsAnamorphicEdgeCase); } diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index 5d3006fda..a3ce6a112 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -504,6 +504,21 @@ public abstract class PipelineBuilderBase : IPipelineBuilder FrameState desiredState, string fontsFolder, ICollection pipelineSteps); + + protected static FrameState SetCrop( + VideoInputFile videoInputFile, + FrameState desiredState, + FrameState currentState) + { + foreach (FrameSize croppedSize in currentState.CroppedSize) + { + IPipelineFilterStep cropStep = new CropFilter(currentState, croppedSize); + currentState = cropStep.NextState(currentState); + videoInputFile.FilterSteps.Add(cropStep); + } + + return currentState; + } private static void SetOutputTsOffset( FFmpegState ffmpegState, diff --git a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs index 9d8f26d93..d4a5507eb 100644 --- a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs @@ -521,7 +521,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder FrameState desiredState, FrameState currentState) { - if (currentState.PaddedSize != desiredState.PaddedSize) + if (desiredState.CroppedSize.IsNone && currentState.PaddedSize != desiredState.PaddedSize) { IPipelineFilterStep padStep = new PadFilter( currentState, @@ -553,6 +553,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder currentState, desiredState.ScaledSize, desiredState.PaddedSize, + desiredState.CroppedSize, VideoStream.IsAnamorphicEdgeCase); } else diff --git a/ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs index 693da05c8..034239db4 100644 --- a/ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs @@ -102,6 +102,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase currentState = SetScale(videoInputFile, videoStream, desiredState, currentState); currentState = SetPad(videoInputFile, videoStream, desiredState, currentState); + currentState = SetCrop(videoInputFile, desiredState, currentState); SetSubtitle( videoInputFile, subtitleInputFile, @@ -301,7 +302,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase FrameState desiredState, FrameState currentState) { - if (currentState.PaddedSize != desiredState.PaddedSize) + if (desiredState.CroppedSize.IsNone && currentState.PaddedSize != desiredState.PaddedSize) { IPipelineFilterStep padStep = new PadFilter(currentState, desiredState.PaddedSize); currentState = padStep.NextState(currentState); @@ -323,6 +324,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase currentState, desiredState.ScaledSize, desiredState.PaddedSize, + desiredState.CroppedSize, VideoStream.IsAnamorphicEdgeCase); currentState = scaleStep.NextState(currentState); diff --git a/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs index e5520c603..2726beb70 100644 --- a/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs @@ -155,6 +155,8 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder currentState = SetPad(videoInputFile, desiredState, currentState); // _logger.LogDebug("After pad: {PixelFormat}", currentState.PixelFormat); + currentState = SetCrop(videoInputFile, desiredState, currentState); + // need to upload for hardware overlay bool forceSoftwareOverlay = context is { HasSubtitleOverlay: true, HasWatermark: true } || ffmpegState.VaapiDriver == "radeonsi"; @@ -482,7 +484,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder FrameState desiredState, FrameState currentState) { - if (currentState.PaddedSize != desiredState.PaddedSize) + if (desiredState.CroppedSize.IsNone && currentState.PaddedSize != desiredState.PaddedSize) { IPipelineFilterStep padStep = new PadFilter( currentState, @@ -515,6 +517,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder currentState, desiredState.ScaledSize, desiredState.PaddedSize, + desiredState.CroppedSize, VideoStream.IsAnamorphicEdgeCase); } else @@ -536,6 +539,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder }, desiredState.ScaledSize, desiredState.PaddedSize, + desiredState.CroppedSize, VideoStream.IsAnamorphicEdgeCase); } diff --git a/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs index 79debb83b..007e4298f 100644 --- a/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs +++ b/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs @@ -10,6 +10,7 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe { private const string BundledVersion = "N-112071-g00a837c70c"; private const string BundledVersionVaapi = "N-112071-g00a837c70c"; + private const string WindowsVersionPrefix = "2023-09-07-git-9c9f48e7f2"; private readonly IConfigElementRepository _configElementRepository; public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository) => @@ -73,16 +74,19 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe private Option ValidateVersion(string version, string app) { if (version.StartsWith("3.", StringComparison.OrdinalIgnoreCase) || - version.StartsWith("4.", StringComparison.OrdinalIgnoreCase)) + version.StartsWith("4.", StringComparison.OrdinalIgnoreCase) || + version.StartsWith("5.", StringComparison.OrdinalIgnoreCase)) { - return FailResult($"{app} version {version} is too old; please install 6.0!"); + return FailResult($"{app} version {version} is too old; please install 6.1 (snapshot)!"); } - if (!version.StartsWith("6.0", StringComparison.OrdinalIgnoreCase) && version != BundledVersion && + if (!version.StartsWith("6.1", StringComparison.OrdinalIgnoreCase) && + !version.StartsWith(WindowsVersionPrefix, StringComparison.OrdinalIgnoreCase) && + version != BundledVersion && version != BundledVersionVaapi) { return WarningResult( - $"{app} version {version} is unexpected and may have problems; please install 6.0!"); + $"{app} version {version} is unexpected and may have problems; please install 6.1 (snapshot)!"); } return None; diff --git a/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj b/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj index f078da917..5c90d26ce 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.Scanner/ErsatzTV.Scanner.csproj b/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj index 94681b2c0..0dbb38848 100644 --- a/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj +++ b/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj @@ -22,7 +22,7 @@ - + diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 5de46e130..3f678855c 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -60,7 +60,7 @@ - + diff --git a/ErsatzTV/Pages/FFmpegEditor.razor b/ErsatzTV/Pages/FFmpegEditor.razor index de90a8b28..5c2960799 100644 --- a/ErsatzTV/Pages/FFmpegEditor.razor +++ b/ErsatzTV/Pages/FFmpegEditor.razor @@ -46,6 +46,7 @@ Scale and Pad Stretch + Crop