From bd2f0f623611d425f41af5cfa46577d4f12f5d5a Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Thu, 9 Mar 2023 10:40:59 -0600 Subject: [PATCH] song normalization (#1202) * add tests to verify song normalization * simplify song setup, include watermarks and album art * fix song path * update changelog --- CHANGELOG.md | 3 +- .../FFmpeg/FFmpegLibraryProcessService.cs | 11 +- ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs | 7 +- ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs | 2 +- ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs | 2 +- .../NvidiaHardwareCapabilities.cs | 3 +- ErsatzTV.Infrastructure/Images/ImageCache.cs | 2 +- .../Core/FFmpeg/TranscodingTests.cs | 642 ++++++++++++------ .../ErsatzTV.Scanner.Tests.csproj | 3 + ErsatzTV.Scanner.Tests/Resources/song.mp3 | Bin 0 -> 12103 bytes 10 files changed, 446 insertions(+), 229 deletions(-) create mode 100644 ErsatzTV.Scanner.Tests/Resources/song.mp3 diff --git a/CHANGELOG.md b/CHANGELOG.md index e7109a9e9..958f2bcf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix direct streaming content from Jellyfin that has external subtitles - Note that these subtitles are not currently supported in ETV, but they did cause a playback issue - Fix Jellyfin, Emby and Plex library scans that wouldn't work in certain timezones +- Fix song normalization to match FFmpeg Profile bit depth ### Changed - Ignore case of video and audio file extensions in local folder scanner @@ -16,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Include multiple `display-name` entries in generated XMLTV - Plex should now display the channel number instead of the channel id (e.g. `1.2` instead of `1.2.etv`) - Rework concurrency a bit - - Playouts builds are no longer blocked by library scans + - Playout builds are no longer blocked by library scans - Adding Trakt lists is no longer blocked by library scans - All library scans (local and media servers) run sequentially diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 3b13c5582..1b2ab0f4c 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -140,7 +140,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService videoPath == audioPath ? playbackSettings.AudioDuration : Option.None, playbackSettings.NormalizeLoudness); - IPixelFormat pixelFormat = await AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger) + // don't log generated images which are expected to have unknown format + ILogger pixelFormatLogger = videoPath == audioPath ? _logger : null; + + IPixelFormat pixelFormat = await AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, pixelFormatLogger) .IfNoneAsync( () => { @@ -222,16 +225,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts") : Option.None; - // normalize songs to yuv420p - IPixelFormat desiredPixelFormat = - videoPath == audioPath ? playbackSettings.PixelFormat : new PixelFormatYuv420P(); - var desiredState = new FrameState( playbackSettings.RealtimeOutput, fillerKind == FillerKind.Fallback, videoFormat, Optional(videoStream.Profile), - Optional(desiredPixelFormat), + Optional(playbackSettings.PixelFormat), ffmpegVideoStream.SquarePixelFrameSize( new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)), new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height), diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs index df9da2aa1..63adf63b6 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs @@ -129,8 +129,13 @@ internal class FFmpegProcessBuilder return this; } - public FFmpegProcessBuilder WithOutputFormat(string format, string output) + public FFmpegProcessBuilder WithOutputFormat(string format, string output, params string[] options) { + foreach (string option in options) + { + _arguments.Add(option); + } + _arguments.Add("-f"); _arguments.Add(format); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 091bb9d5f..a9adaa919 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -130,7 +130,7 @@ public class FFmpegProcessService None, videoPath, None) - .WithOutputFormat("apng", outputFile) + .WithOutputFormat("apng", outputFile, "-pix_fmt", "rgb24") .Build(); _logger.LogInformation( diff --git a/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs b/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs index 1b6e2cc0b..e4829e790 100644 --- a/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs +++ b/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs @@ -170,7 +170,7 @@ public class SongVideoGenerator : ISongVideoGenerator Streams = new List { new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 } - } + }, }; string customPath = _imageCache.GetPathForImage( diff --git a/ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs b/ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs index 79a5e218c..23959052c 100644 --- a/ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs +++ b/ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs @@ -95,7 +95,8 @@ public class NvidiaHardwareCapabilities : IHardwareCapabilities return isHardware ? FFmpegCapability.Hardware : FFmpegCapability.Software; } - public bool HevcBFrames => _architecture >= 75; + // this fails with some 1650 cards, so let's try greater than 75 + public bool HevcBFrames => _architecture > 75; private FFmpegCapability CheckHardwareCodec(string codec, Func check) { diff --git a/ErsatzTV.Infrastructure/Images/ImageCache.cs b/ErsatzTV.Infrastructure/Images/ImageCache.cs index f4f7f0a86..5242f4bc5 100644 --- a/ErsatzTV.Infrastructure/Images/ImageCache.cs +++ b/ErsatzTV.Infrastructure/Images/ImageCache.cs @@ -97,7 +97,7 @@ public class ImageCache : IImageCache } } - public string GetPathForImage(string fileName, ArtworkKind artworkKind, Option maybeMaxHeight) + public virtual string GetPathForImage(string fileName, ArtworkKind artworkKind, Option maybeMaxHeight) { string subfolder = maybeMaxHeight.Match( maxHeight => Path.Combine(maxHeight.ToString(), fileName[..2]), diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index 5508bbb54..48da5db3f 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -21,7 +21,9 @@ using ErsatzTV.FFmpeg.Filter.Vaapi; using ErsatzTV.FFmpeg.Format; using ErsatzTV.FFmpeg.Pipeline; using ErsatzTV.FFmpeg.State; +using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Runtime; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Scanner.Core.Metadata; using FluentAssertions; using Microsoft.Extensions.Caching.Memory; @@ -50,6 +52,11 @@ public class TranscodingTests LoggerFactory = new LoggerFactory().AddSerilog(Log.Logger); MemoryCache = new MemoryCache(new MemoryCacheOptions()); + + if (!Directory.Exists(FileSystemLayout.TempFilePoolFolder)) + { + Directory.CreateDirectory(FileSystemLayout.TempFilePoolFolder); + } } [Test] @@ -193,6 +200,178 @@ public class TranscodingTests public static string[] FilesToTest => new[] { string.Empty }; } + [Test] + [Combinatorial] + public async Task TranscodeSong( + [ValueSource(typeof(TestData), nameof(TestData.Watermarks))] + Watermark watermark, + [ValueSource(typeof(TestData), nameof(TestData.Resolutions))] + Resolution profileResolution, + [ValueSource(typeof(TestData), nameof(TestData.BitDepths))] + FFmpegProfileBitDepth profileBitDepth, + [ValueSource(typeof(TestData), nameof(TestData.VideoFormats))] + FFmpegProfileVideoFormat profileVideoFormat, + [ValueSource(typeof(TestData), nameof(TestData.TestAccelerations))] + HardwareAccelerationKind profileAcceleration) + { + var localFileSystem = new LocalFileSystem(new Mock().Object, LoggerFactory.CreateLogger()); + var tempFilePool = new TempFilePool(); + + var mockImageCache = new Mock(localFileSystem, tempFilePool); + + // always return the static watermark resource + mockImageCache.Setup( + ic => ic.GetPathForImage( + It.IsAny(), + It.Is(x => x == ArtworkKind.Watermark), + It.IsAny>())) + .Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png")); + + var oldService = new FFmpegProcessService( + new FFmpegPlaybackSettingsCalculator(), + new FakeStreamSelector(), + mockImageCache.Object, + tempFilePool, + new Mock().Object, + MemoryCache, + LoggerFactory.CreateLogger()); + + var service = new FFmpegLibraryProcessService( + oldService, + new FFmpegPlaybackSettingsCalculator(), + new FakeStreamSelector(), + tempFilePool, + new PipelineBuilderFactory( + new RuntimeInfo(), + //new FakeNvidiaCapabilitiesFactory(), + new HardwareCapabilitiesFactory( + MemoryCache, + LoggerFactory.CreateLogger()), + LoggerFactory.CreateLogger()), + LoggerFactory.CreateLogger()); + + var songVideoGenerator = new SongVideoGenerator(tempFilePool, mockImageCache.Object, service); + + var channel = new Channel(Guid.NewGuid()) + { + Number = "1", + FFmpegProfile = FFmpegProfile.New("test", profileResolution) with + { + HardwareAcceleration = profileAcceleration, + VideoFormat = profileVideoFormat, + AudioFormat = FFmpegProfileAudioFormat.Aac, + DeinterlaceVideo = true, + BitDepth = profileBitDepth + }, + StreamingMode = StreamingMode.TransportStream, + SubtitleMode = ChannelSubtitleMode.None + }; + + string file = Path.Combine(TestContext.CurrentContext.TestDirectory, Path.Combine("Resources", "song.mp3")); + var songVersion = new MediaVersion + { + MediaFiles = new List + { + new() { Path = file } + }, + + Streams = new List() + }; + + var song = new Song + { + SongMetadata = new List + { + new() + { + Title = "Song Title", + Artist = "Song Artist", + Artwork = new List() + } + }, + MediaVersions = new List { songVersion } + }; + + (string videoPath, MediaVersion videoVersion) = await songVideoGenerator.GenerateSongVideo( + song, + channel, + None, // playout item watermark + None, // global watermark + ExecutableName("ffmpeg"), + ExecutableName("ffprobe"), + CancellationToken.None); + + var metadataRepository = new Mock(); + metadataRepository + .Setup(r => r.UpdateStatistics(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (_, version, _) => + { + if (version.Streams.Any(s => s.MediaStreamKind == MediaStreamKind.Video && s.AttachedPic == false)) + { + version.MediaFiles = videoVersion.MediaFiles; + videoVersion = version; + } + else + { + version.MediaFiles = songVersion.MediaFiles; + songVersion = version; + } + }); + + var localStatisticsProvider = new LocalStatisticsProvider( + metadataRepository.Object, + new LocalFileSystem(new Mock().Object, LoggerFactory.CreateLogger()), + new Mock().Object, + LoggerFactory.CreateLogger()); + + await localStatisticsProvider.RefreshStatistics(ExecutableName("ffmpeg"), ExecutableName("ffprobe"), song); + + DateTimeOffset now = DateTimeOffset.Now; + + Command process = await service.ForPlayoutItem( + ExecutableName("ffmpeg"), + ExecutableName("ffprobe"), + false, + channel, + videoVersion, + new MediaItemAudioVersion(song, songVersion), + videoPath, + file, + _ => Task.FromResult(new List()), + string.Empty, + string.Empty, + string.Empty, + ChannelSubtitleMode.None, + now, + now + TimeSpan.FromSeconds(3), + now, + Option.None, + GetWatermark(watermark), + VaapiDriver.Default, + "/dev/dri/renderD128", + Option.None, + false, + FillerKind.None, + TimeSpan.Zero, + TimeSpan.FromSeconds(3), + 0, + None, + false, + _ => { }); + + // Console.WriteLine($"ffmpeg arguments {process.Arguments}"); + + await TranscodeAndVerify( + process, + profileResolution, + profileBitDepth, + profileVideoFormat, + profileAcceleration, + localStatisticsProvider, + () => videoVersion); + } + [Test] [Combinatorial] public async Task Transcode( @@ -239,39 +418,6 @@ public class TranscodingTests } } - var imageCache = new Mock(); - - // always return the static watermark resource - imageCache.Setup( - ic => ic.GetPathForImage( - It.IsAny(), - It.Is(x => x == ArtworkKind.Watermark), - It.IsAny>())) - .Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png")); - - var oldService = new FFmpegProcessService( - new FFmpegPlaybackSettingsCalculator(), - new FakeStreamSelector(), - imageCache.Object, - new Mock().Object, - new Mock().Object, - MemoryCache, - LoggerFactory.CreateLogger()); - - var service = new FFmpegLibraryProcessService( - oldService, - new FFmpegPlaybackSettingsCalculator(), - new FakeStreamSelector(), - new Mock().Object, - new PipelineBuilderFactory( - new RuntimeInfo(), - //new FakeNvidiaCapabilitiesFactory(), - new HardwareCapabilitiesFactory( - MemoryCache, - LoggerFactory.CreateLogger()), - LoggerFactory.CreateLogger()), - LoggerFactory.CreateLogger()); - var v = new MediaVersion { MediaFiles = new List @@ -349,72 +495,7 @@ public class TranscodingTests DateTimeOffset now = DateTimeOffset.Now; - Option channelWatermark = Option.None; - switch (watermark) - { - case Watermark.None: - break; - case Watermark.IntermittentOpaque: - channelWatermark = new ChannelWatermark - { - ImageSource = ChannelWatermarkImageSource.Custom, - Mode = ChannelWatermarkMode.Intermittent, - // TODO: how do we make sure this actually appears - FrequencyMinutes = 1, - DurationSeconds = 2, - Opacity = 100 - }; - break; - case Watermark.IntermittentTransparent: - channelWatermark = new ChannelWatermark - { - ImageSource = ChannelWatermarkImageSource.Custom, - Mode = ChannelWatermarkMode.Intermittent, - // TODO: how do we make sure this actually appears - FrequencyMinutes = 1, - DurationSeconds = 2, - Opacity = 80 - }; - break; - case Watermark.PermanentOpaqueScaled: - channelWatermark = new ChannelWatermark - { - ImageSource = ChannelWatermarkImageSource.Custom, - Mode = ChannelWatermarkMode.Permanent, - Opacity = 100, - Size = WatermarkSize.Scaled, - WidthPercent = 15 - }; - break; - case Watermark.PermanentOpaqueActualSize: - channelWatermark = new ChannelWatermark - { - ImageSource = ChannelWatermarkImageSource.Custom, - Mode = ChannelWatermarkMode.Permanent, - Opacity = 100, - Size = WatermarkSize.ActualSize - }; - break; - case Watermark.PermanentTransparentScaled: - channelWatermark = new ChannelWatermark - { - ImageSource = ChannelWatermarkImageSource.Custom, - Mode = ChannelWatermarkMode.Permanent, - Opacity = 80, - Size = WatermarkSize.Scaled, - WidthPercent = 15 - }; - break; - case Watermark.PermanentTransparentActualSize: - channelWatermark = new ChannelWatermark - { - ImageSource = ChannelWatermarkImageSource.Custom, - Mode = ChannelWatermarkMode.Permanent, - Opacity = 80, - Size = WatermarkSize.ActualSize - }; - break; - } + Option channelWatermark = GetWatermark(watermark); ChannelSubtitleMode subtitleMode = subtitle switch { @@ -495,6 +576,8 @@ public class TranscodingTests hasWatermarkFilters.Should().Be(watermark != Watermark.None); } + FFmpegLibraryProcessService service = GetService(); + Command process = await service.ForPlayoutItem( ExecutableName("ffmpeg"), ExecutableName("ffprobe"), @@ -541,127 +624,79 @@ public class TranscodingTests // Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}"); - string[] unsupportedMessages = + await TranscodeAndVerify( + process, + profileResolution, + profileBitDepth, + profileVideoFormat, + profileAcceleration, + localStatisticsProvider, + () => v); + } + + private Option GetWatermark(Watermark watermark) + { + switch (watermark) { - "No support for codec", - "No usable", - "Provided device doesn't support", - "Current pixel format is unsupported" - }; - - var sb = new StringBuilder(); - var timeoutSignal = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - string tempFile = Path.GetTempFileName(); - try - { - CommandResult result; - - try - { - result = await process - .WithStandardOutputPipe(PipeTarget.ToFile(tempFile)) - .WithStandardErrorPipe(PipeTarget.ToStringBuilder(sb)) - .ExecuteAsync(timeoutSignal.Token); - - // var arguments = string.Join( - // ' ', - // process.Arguments.Split(" ").Map(a => a.Contains('[') ? $"\"{a}\"" : a)); - // - // Log.Logger.Debug(arguments); - } - catch (OperationCanceledException) - { - var arguments = string.Join( - ' ', - process.Arguments.Split(" ").Map(a => a.Contains('[') ? $"\"{a}\"" : a)); - - Assert.Fail($"Transcode failure (timeout): ffmpeg {arguments}"); - return; - } - - var error = sb.ToString(); - bool isUnsupported = unsupportedMessages.Any(error.Contains); - - if (profileAcceleration != HardwareAccelerationKind.None && isUnsupported) - { - result.ExitCode.Should().Be(1, $"Error message with successful exit code? {process.Arguments}"); - Assert.Warn($"Unsupported on this hardware: ffmpeg {process.Arguments}"); - } - else if (error.Contains("Impossible to convert between")) - { - var arguments = string.Join( - ' ', - process.Arguments.Split(" ").Map(a => a.Contains('[') ? $"\"{a}\"" : a)); - - Assert.Fail($"Transcode failure: ffmpeg {arguments}"); - } - else - { - var arguments = string.Join( - ' ', - process.Arguments.Split(" ").Map(a => a.Contains('[') ? $"\"{a}\"" : a)); - - result.ExitCode.Should().Be(0, error + Environment.NewLine + arguments); - if (result.ExitCode == 0) + case Watermark.None: + break; + case Watermark.IntermittentOpaque: + return new ChannelWatermark { - Console.WriteLine(process.Arguments); - } - } - - // additional checks on resulting file - await localStatisticsProvider.RefreshStatistics( - ExecutableName("ffmpeg"), - ExecutableName("ffprobe"), - new Movie + ImageSource = ChannelWatermarkImageSource.Custom, + Mode = ChannelWatermarkMode.Intermittent, + // TODO: how do we make sure this actually appears + FrequencyMinutes = 1, + DurationSeconds = 2, + Opacity = 100 + }; + case Watermark.IntermittentTransparent: + return new ChannelWatermark { - MediaVersions = new List - { - new() - { - MediaFiles = new List - { - new() { Path = tempFile } - } - } - } - }); - - // verify de-interlace - v.VideoScanKind.Should().NotBe(VideoScanKind.Interlaced); - - // verify resolution - v.Height.Should().Be(profileResolution.Height); - v.Width.Should().Be(profileResolution.Width); - - foreach (MediaStream videoStream in v.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Video)) - { - // verify pixel format - videoStream.PixelFormat.Should().Be( - profileBitDepth == FFmpegProfileBitDepth.TenBit ? PixelFormat.YUV420P10LE : PixelFormat.YUV420P); - - // verify colors - var colorParams = new ColorParams( - videoStream.ColorRange, - videoStream.ColorSpace, - videoStream.ColorTransfer, - videoStream.ColorPrimaries); - - // AMF doesn't seem to set this metadata properly - // MPEG2Video doesn't always seem to set this properly - if (profileAcceleration != HardwareAccelerationKind.Amf && - profileVideoFormat != FFmpegProfileVideoFormat.Mpeg2Video) + ImageSource = ChannelWatermarkImageSource.Custom, + Mode = ChannelWatermarkMode.Intermittent, + // TODO: how do we make sure this actually appears + FrequencyMinutes = 1, + DurationSeconds = 2, + Opacity = 80 + }; + case Watermark.PermanentOpaqueScaled: + return new ChannelWatermark { - colorParams.IsBt709.Should().BeTrue($"{colorParams}"); - } - } - } - finally - { - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } + ImageSource = ChannelWatermarkImageSource.Custom, + Mode = ChannelWatermarkMode.Permanent, + Opacity = 100, + Size = WatermarkSize.Scaled, + WidthPercent = 15 + }; + case Watermark.PermanentOpaqueActualSize: + return new ChannelWatermark + { + ImageSource = ChannelWatermarkImageSource.Custom, + Mode = ChannelWatermarkMode.Permanent, + Opacity = 100, + Size = WatermarkSize.ActualSize + }; + case Watermark.PermanentTransparentScaled: + return new ChannelWatermark + { + ImageSource = ChannelWatermarkImageSource.Custom, + Mode = ChannelWatermarkMode.Permanent, + Opacity = 80, + Size = WatermarkSize.Scaled, + WidthPercent = 15 + }; + case Watermark.PermanentTransparentActualSize: + return new ChannelWatermark + { + ImageSource = ChannelWatermarkImageSource.Custom, + Mode = ChannelWatermarkMode.Permanent, + Opacity = 80, + Size = WatermarkSize.ActualSize + }; } + + return Option.None; } private static async Task GenerateTestFile( @@ -795,6 +830,178 @@ public class TranscodingTests return BitConverter.ToString(hash).Replace("-", string.Empty); } + private static FFmpegLibraryProcessService GetService() + { + var imageCache = new Mock(); + + // always return the static watermark resource + imageCache.Setup( + ic => ic.GetPathForImage( + It.IsAny(), + It.Is(x => x == ArtworkKind.Watermark), + It.IsAny>())) + .Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png")); + + var oldService = new FFmpegProcessService( + new FFmpegPlaybackSettingsCalculator(), + new FakeStreamSelector(), + imageCache.Object, + new Mock().Object, + new Mock().Object, + MemoryCache, + LoggerFactory.CreateLogger()); + + var service = new FFmpegLibraryProcessService( + oldService, + new FFmpegPlaybackSettingsCalculator(), + new FakeStreamSelector(), + new Mock().Object, + new PipelineBuilderFactory( + new RuntimeInfo(), + //new FakeNvidiaCapabilitiesFactory(), + new HardwareCapabilitiesFactory( + MemoryCache, + LoggerFactory.CreateLogger()), + LoggerFactory.CreateLogger()), + LoggerFactory.CreateLogger()); + + return service; + } + + private async Task TranscodeAndVerify( + Command process, + Resolution profileResolution, + FFmpegProfileBitDepth profileBitDepth, + FFmpegProfileVideoFormat profileVideoFormat, + HardwareAccelerationKind profileAcceleration, + ILocalStatisticsProvider localStatisticsProvider, + Func getFinalMediaVersion) + { + string[] unsupportedMessages = + { + "No support for codec", + "No usable", + "Provided device doesn't support", + "Current pixel format is unsupported" + }; + + var sb = new StringBuilder(); + var timeoutSignal = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + string tempFile = Path.GetTempFileName(); + try + { + CommandResult result; + + try + { + result = await process + .WithStandardOutputPipe(PipeTarget.ToFile(tempFile)) + .WithStandardErrorPipe(PipeTarget.ToStringBuilder(sb)) + .ExecuteAsync(timeoutSignal.Token); + + // var arguments = string.Join( + // ' ', + // process.Arguments.Split(" ").Map(a => a.Contains('[') ? $"\"{a}\"" : a)); + // + // Log.Logger.Debug(arguments); + } + catch (OperationCanceledException) + { + var arguments = string.Join( + ' ', + process.Arguments.Split(" ").Map(a => a.Contains('[') ? $"\"{a}\"" : a)); + + Assert.Fail($"Transcode failure (timeout): ffmpeg {arguments}"); + return; + } + + var error = sb.ToString(); + bool isUnsupported = unsupportedMessages.Any(error.Contains); + + if (profileAcceleration != HardwareAccelerationKind.None && isUnsupported) + { + result.ExitCode.Should().Be(1, $"Error message with successful exit code? {process.Arguments}"); + Assert.Warn($"Unsupported on this hardware: ffmpeg {process.Arguments}"); + } + else if (error.Contains("Impossible to convert between")) + { + var arguments = string.Join( + ' ', + process.Arguments.Split(" ").Map(a => a.Contains('[') ? $"\"{a}\"" : a)); + + Assert.Fail($"Transcode failure: ffmpeg {arguments}"); + } + else + { + var arguments = string.Join( + ' ', + process.Arguments.Split(" ").Map(a => a.Contains('[') ? $"\"{a}\"" : a)); + + result.ExitCode.Should().Be(0, error + Environment.NewLine + arguments); + if (result.ExitCode == 0) + { + Console.WriteLine(process.Arguments); + } + } + + // additional checks on resulting file + await localStatisticsProvider.RefreshStatistics( + ExecutableName("ffmpeg"), + ExecutableName("ffprobe"), + new Movie + { + MediaVersions = new List + { + new() + { + MediaFiles = new List + { + new() { Path = tempFile } + } + } + } + }); + + MediaVersion v = getFinalMediaVersion(); + + // verify de-interlace + v.VideoScanKind.Should().NotBe(VideoScanKind.Interlaced); + + // verify resolution + v.Height.Should().Be(profileResolution.Height); + v.Width.Should().Be(profileResolution.Width); + + foreach (MediaStream videoStream in v.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Video)) + { + // verify pixel format + videoStream.PixelFormat.Should().Be( + profileBitDepth == FFmpegProfileBitDepth.TenBit ? PixelFormat.YUV420P10LE : PixelFormat.YUV420P); + + // verify colors + var colorParams = new ColorParams( + videoStream.ColorRange, + videoStream.ColorSpace, + videoStream.ColorTransfer, + videoStream.ColorPrimaries); + + // AMF doesn't seem to set this metadata properly + // MPEG2Video doesn't always seem to set this properly + if (profileAcceleration != HardwareAccelerationKind.Amf && + profileVideoFormat != FFmpegProfileVideoFormat.Mpeg2Video) + { + colorParams.IsBt709.Should().BeTrue($"{colorParams}"); + } + } + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + private class FakeStreamSelector : IFFmpegStreamSelector { public Task SelectVideoStream(MediaVersion version) => @@ -806,7 +1013,8 @@ public class TranscodingTests Channel channel, string preferredAudioLanguage, string preferredAudioTitle) => - Optional(version.MediaVersion.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask(); + Optional(version.MediaVersion.Streams.FirstOrDefault(s => s.MediaStreamKind == MediaStreamKind.Audio)) + .AsTask(); public Task> SelectSubtitleStream( List subtitles, @@ -815,7 +1023,7 @@ public class TranscodingTests ChannelSubtitleMode subtitleMode) => subtitles.HeadOrNone().AsTask(); } - + private static string ExecutableName(string baseName) => OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName; } diff --git a/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj b/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj index 5a374b713..57e041c99 100644 --- a/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj +++ b/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj @@ -48,6 +48,9 @@ Always + + Always + diff --git a/ErsatzTV.Scanner.Tests/Resources/song.mp3 b/ErsatzTV.Scanner.Tests/Resources/song.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..5ebf59a0a6dcc7ad7a46db229986a35f54dc326c GIT binary patch literal 12103 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 DRZf*^ literal 0 HcmV?d00001