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 000000000..5ebf59a0a Binary files /dev/null and b/ErsatzTV.Scanner.Tests/Resources/song.mp3 differ