diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6071880..18fb698b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Fixed - Use MIME Type `application/x-mpegurl` for all playlists instead of `application/vnd.apple.mpegurl` -- Remove `setsar` filter which caused issues scaling between two different aspect ratios - - For example, some 4:3 content would appear stretched when scaled to a 16:9 resolution +- Replace `setsar` filter with `setdar` filter + - `setsar` caused issues scaling between two different aspect ratios + - For example, some 4:3 content would appear stretched when scaled to a 16:9 resolution + - `setdar` is now only used when aspect ratios match +- Prioritize aspect ratio from container when video stream contains conflicting aspect ratio + - This is usually caused by bad authoring, but the change should improve scaling behavior for edge cases ### Added - Support DSD audio file formats (DFF and DSF) in local song libraries diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 57bd3e16e..51fca17c6 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -140,6 +140,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService videoStream.Codec, AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger), new FrameSize(videoVersion.Width, videoVersion.Height), + videoVersion.DisplayAspectRatio, videoVersion.RFrameRate, videoPath != audioPath); // still image when paths are different @@ -209,6 +210,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService await playbackSettings.ScaledSize.Map(ss => new FrameSize(ss.Width, ss.Height)) .IfNoneAsync(new FrameSize(videoVersion.Width, videoVersion.Height)), new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height), + channel.FFmpegProfile.Resolution.Width == 640 ? "4:3" : "16:9", playbackSettings.FrameRate, playbackSettings.VideoBitrate, playbackSettings.VideoBufferSize, @@ -302,6 +304,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService new PixelFormatYuv420P(), new FrameSize(desiredResolution.Width, desiredResolution.Height), new FrameSize(desiredResolution.Width, desiredResolution.Height), + desiredResolution.Width == 640 ? "4:3" : "16:9", playbackSettings.FrameRate, playbackSettings.VideoBitrate, playbackSettings.VideoBufferSize, @@ -329,6 +332,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService VideoFormat.GeneratedImage, new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p new FrameSize(videoVersion.Width, videoVersion.Height), + videoVersion.DisplayAspectRatio, None, true); @@ -417,7 +421,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService { var videoInputFile = new VideoInputFile( inputFile, - new List { new(0, string.Empty, None, FrameSize.Unknown, None, true) }); + new List { new(0, string.Empty, None, FrameSize.Unknown, string.Empty, None, true) }); var pipelineBuilder = new PipelineBuilder( await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, HardwareAccelerationMode.None), @@ -497,6 +501,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService "unknown", new PixelFormatUnknown(), new FrameSize(1, 1), + string.Empty, Option.None, !options.IsAnimated) }, diff --git a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs index 5936decb1..c1a707ecb 100644 --- a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs @@ -3,6 +3,8 @@ using System.Globalization; using System.Text; using System.Text.RegularExpressions; using Bugsnag; +using CliWrap; +using CliWrap.Buffered; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; @@ -157,42 +159,48 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider return await _metadataRepository.UpdateLocalStatistics(mediaItem, version) && durationChange; } - private Task> GetProbeOutput(string ffprobePath, string filePath) + private async Task> GetProbeOutput(string ffprobePath, string filePath) { - var startInfo = new ProcessStartInfo + string[] arguments = { - FileName = ffprobePath, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - StandardOutputEncoding = Encoding.UTF8, - StandardErrorEncoding = Encoding.UTF8 + "-hide_banner", + "-print_format", "json", + "-show_format", + "-show_streams", + "-show_chapters", + "-i", filePath }; - startInfo.ArgumentList.Add("-v"); - startInfo.ArgumentList.Add("quiet"); - startInfo.ArgumentList.Add("-print_format"); - startInfo.ArgumentList.Add("json"); - startInfo.ArgumentList.Add("-show_format"); - startInfo.ArgumentList.Add("-show_streams"); - startInfo.ArgumentList.Add("-show_chapters"); - startInfo.ArgumentList.Add("-i"); - startInfo.ArgumentList.Add(filePath); + BufferedCommandResult probe = await Cli.Wrap(ffprobePath) + .WithArguments(arguments) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(Encoding.UTF8); - var probe = new Process + if (probe.ExitCode != 0) { - StartInfo = startInfo - }; + return BaseError.New($"FFprobe at {ffprobePath} exited with code {probe.ExitCode}"); + } - probe.Start(); - return probe.StandardOutput.ReadToEndAsync().MapAsync>( - async output => + FFprobe ffprobe = JsonConvert.DeserializeObject(probe.StandardOutput); + + if (ffprobe != null) + { + const string PATTERN = @"\[SAR\s+([0-9]+:[0-9]+)\s+DAR\s+([0-9]+:[0-9]+)\]"; + Match match = Regex.Match(probe.StandardError, PATTERN); + if (match.Success) { - await probe.WaitForExitAsync(); - return probe.ExitCode == 0 - ? JsonConvert.DeserializeObject(output) - : BaseError.New($"FFprobe at {ffprobePath} exited with code {probe.ExitCode}"); - }); + string sar = match.Groups[1].Value; + string dar = match.Groups[2].Value; + foreach (FFprobeStream stream in ffprobe.streams.Where(s => s.codec_type == "video").ToList()) + { + FFprobeStream replacement = stream with { sample_aspect_ratio = sar, display_aspect_ratio = dar }; + ffprobe.streams.Remove(stream); + ffprobe.streams.Add(replacement); + } + } + } + + return ffprobe; } private async Task AnalyzeDuration(string ffmpegPath, string path, MediaVersion version) diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs index cd54b9b9d..4ff22dfa3 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs @@ -25,7 +25,7 @@ public class PipelineGeneratorTests var videoInputFile = new VideoInputFile( "/tmp/whatever.mkv", new List - { new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) }); + { new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "16:9", "24", false) }); var audioInputFile = new AudioInputFile( "/tmp/whatever.mkv", @@ -46,6 +46,7 @@ public class PipelineGeneratorTests new PixelFormatYuv420P(), new FrameSize(1920, 1080), new FrameSize(1920, 1080), + "16:9", Option.None, 2000, 4000, @@ -95,7 +96,7 @@ public class PipelineGeneratorTests var videoInputFile = new VideoInputFile( "/tmp/whatever.mkv", new List - { new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) }); + { new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "16:9", "24", false) }); var audioInputFile = new AudioInputFile( "/tmp/whatever.mkv", @@ -116,6 +117,7 @@ public class PipelineGeneratorTests new PixelFormatYuv420P(), new FrameSize(1920, 1080), new FrameSize(1920, 1080), + "16:9", Option.None, 2000, 4000, @@ -182,7 +184,7 @@ public class PipelineGeneratorTests var videoInputFile = new VideoInputFile( "/tmp/whatever.mkv", new List - { new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) }); + { new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "16:9", "24", false) }); var audioInputFile = new AudioInputFile( "/tmp/whatever.mkv", @@ -203,6 +205,7 @@ public class PipelineGeneratorTests new PixelFormatYuv420P(), new FrameSize(1920, 1080), new FrameSize(1920, 1080), + "16:9", Option.None, 2000, 4000, @@ -257,7 +260,7 @@ public class PipelineGeneratorTests "/test/input/file.png", new List { - new(0, string.Empty, Option.None, FrameSize.Unknown, Option.None, true) + new(0, string.Empty, Option.None, FrameSize.Unknown, string.Empty, Option.None, true) }); var pipelineBuilder = new PipelineBuilder( diff --git a/ErsatzTV.FFmpeg/Filter/SetDarFilter.cs b/ErsatzTV.FFmpeg/Filter/SetDarFilter.cs new file mode 100644 index 000000000..1f59fac5f --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/SetDarFilter.cs @@ -0,0 +1,14 @@ +namespace ErsatzTV.FFmpeg.Filter; + +public class SetDarFilter : BaseFilter +{ + private readonly string _displayAspectRatio; + + public SetDarFilter(string displayAspectRatio) + { + _displayAspectRatio = displayAspectRatio; + } + + public override string Filter => $"setdar=dar={_displayAspectRatio.Replace(':', '/')}"; + public override FrameState NextState(FrameState currentState) => currentState; +} diff --git a/ErsatzTV.FFmpeg/Filter/SetSarFilter.cs b/ErsatzTV.FFmpeg/Filter/SetSarFilter.cs deleted file mode 100644 index be669fe0d..000000000 --- a/ErsatzTV.FFmpeg/Filter/SetSarFilter.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ErsatzTV.FFmpeg.Filter; - -public class SetSarFilter : BaseFilter -{ - public override string Filter => "setsar=1"; - public override FrameState NextState(FrameState currentState) => currentState; -} diff --git a/ErsatzTV.FFmpeg/FrameState.cs b/ErsatzTV.FFmpeg/FrameState.cs index 6f9412d87..160a4945d 100644 --- a/ErsatzTV.FFmpeg/FrameState.cs +++ b/ErsatzTV.FFmpeg/FrameState.cs @@ -9,6 +9,7 @@ public record FrameState( Option PixelFormat, FrameSize ScaledSize, FrameSize PaddedSize, + string DisplayAspectRatio, Option FrameRate, Option VideoBitrate, Option VideoBufferSize, diff --git a/ErsatzTV.FFmpeg/InputFile.cs b/ErsatzTV.FFmpeg/InputFile.cs index d33756a51..4eb32987d 100644 --- a/ErsatzTV.FFmpeg/InputFile.cs +++ b/ErsatzTV.FFmpeg/InputFile.cs @@ -19,6 +19,7 @@ public record ConcatInputFile(string Url, FrameSize Resolution) : InputFile( string.Empty, Option.None, Resolution, + string.Empty, Option.None, false) }) diff --git a/ErsatzTV.FFmpeg/MediaStream.cs b/ErsatzTV.FFmpeg/MediaStream.cs index dc97a71e5..3c90b436b 100644 --- a/ErsatzTV.FFmpeg/MediaStream.cs +++ b/ErsatzTV.FFmpeg/MediaStream.cs @@ -14,6 +14,7 @@ public record VideoStream( string Codec, Option PixelFormat, FrameSize FrameSize, + string DisplayAspectRatio, Option FrameRate, bool StillImage) : MediaStream( Index, diff --git a/ErsatzTV.FFmpeg/PipelineBuilder.cs b/ErsatzTV.FFmpeg/PipelineBuilder.cs index 771f0b0b9..5b995b2d5 100644 --- a/ErsatzTV.FFmpeg/PipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/PipelineBuilder.cs @@ -188,6 +188,7 @@ public class PipelineBuilder videoStream.PixelFormat, videoStream.FrameSize, videoStream.FrameSize, + videoStream.DisplayAspectRatio, initialFrameRate, Option.None, Option.None, @@ -379,6 +380,13 @@ public class PipelineBuilder IPipelineFilterStep padStep = new PadFilter(currentState, desiredState.PaddedSize); currentState = padStep.NextState(currentState); _videoInputFile.Iter(f => f.FilterSteps.Add(padStep)); + + if (videoStream.DisplayAspectRatio == desiredState.DisplayAspectRatio) + { + IPipelineFilterStep darStep = new SetDarFilter(desiredState.DisplayAspectRatio); + currentState = darStep.NextState(currentState); + _videoInputFile.Iter(f => f.FilterSteps.Add(darStep)); + } } } else if (currentState.ScaledSize != desiredState.ScaledSize) @@ -398,6 +406,13 @@ public class PipelineBuilder currentState = padStep.NextState(currentState); _videoInputFile.Iter(f => f.FilterSteps.Add(padStep)); } + + if (videoStream.DisplayAspectRatio == desiredState.DisplayAspectRatio) + { + IPipelineFilterStep darStep = new SetDarFilter(desiredState.DisplayAspectRatio); + currentState = darStep.NextState(currentState); + _videoInputFile.Iter(f => f.FilterSteps.Add(darStep)); + } } else if (currentState.PaddedSize != desiredState.PaddedSize) { @@ -415,6 +430,13 @@ public class PipelineBuilder currentState = padStep.NextState(currentState); _videoInputFile.Iter(f => f.FilterSteps.Add(padStep)); } + + if (videoStream.DisplayAspectRatio == desiredState.DisplayAspectRatio) + { + IPipelineFilterStep darStep = new SetDarFilter(desiredState.DisplayAspectRatio); + currentState = darStep.NextState(currentState); + _videoInputFile.Iter(f => f.FilterSteps.Add(darStep)); + } } if (hasOverlay && currentState.PixelFormat.Map(pf => pf.FFmpegName) !=