using System.Diagnostics; using System.Security.Cryptography; using System.Text; using Bugsnag; using CliWrap; using CliWrap.Buffered; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Metadata; using ErsatzTV.FFmpeg; using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Filter; using ErsatzTV.FFmpeg.Filter.Cuda; using ErsatzTV.FFmpeg.Filter.Qsv; using ErsatzTV.FFmpeg.Filter.Vaapi; using ErsatzTV.FFmpeg.Format; using ErsatzTV.FFmpeg.Pipeline; using ErsatzTV.FFmpeg.State; using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Metadata; using ErsatzTV.Infrastructure.Runtime; using Shouldly; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; using NUnit.Framework; using Serilog; using MediaStream = ErsatzTV.Core.Domain.MediaStream; namespace ErsatzTV.Scanner.Tests.Core.FFmpeg; [TestFixture] [Explicit] public class TranscodingTests { private static readonly ILoggerFactory LoggerFactory; private static readonly MemoryCache MemoryCache; static TranscodingTests() { Log.Logger = new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() .CreateLogger(); LoggerFactory = new LoggerFactory().AddSerilog(Log.Logger); MemoryCache = new MemoryCache(new MemoryCacheOptions()); if (!Directory.Exists(FileSystemLayout.TempFilePoolFolder)) { Directory.CreateDirectory(FileSystemLayout.TempFilePoolFolder); } } [Test] [Explicit] public void DeleteTestVideos() { foreach (string file in Directory.GetFiles(TestContext.CurrentContext.TestDirectory, "*.mkv")) { File.Delete(file); } Assert.Pass(); } public record InputFormat( string Encoder, string PixelFormat, string ColorRange = "tv", string ColorSpace = "bt709", string ColorTransfer = "bt709", string ColorPrimaries = "bt709"); public enum Padding { NoPadding, WithPadding } public enum Watermark { None, PermanentOpaqueScaled, PermanentOpaqueActualSize, PermanentTransparentScaled, PermanentTransparentActualSize, IntermittentOpaque, IntermittentTransparent // TODO: animated vs static } public enum Subtitle { None, Picture, Text } private class TestData { public static Watermark[] Watermarks = [ Watermark.None, Watermark.PermanentOpaqueScaled, Watermark.PermanentOpaqueActualSize, Watermark.PermanentTransparentScaled, Watermark.PermanentTransparentActualSize ]; public static Subtitle[] Subtitles = [ Subtitle.None, Subtitle.Picture, Subtitle.Text ]; public static Padding[] Paddings = [ Padding.NoPadding, Padding.WithPadding ]; public static ScalingBehavior[] ScalingBehaviors = [ ScalingBehavior.ScaleAndPad //ScalingBehavior.Crop, //ScalingBehavior.Stretch ]; public static VideoScanKind[] VideoScanKinds = [ VideoScanKind.Progressive //VideoScanKind.Interlaced ]; public static InputFormat[] InputFormats = [ // // // example format that requires colorspace filter // 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", "yuvj420p"), // new("libx264", "yuv420p10le"), // // // // new("libx264", "yuv444p10le"), // // // // // // // new("mpeg1video", "yuv420p"), // // // // // new("mpeg2video", "yuv420p"), // // //new InputFormat("libx265", "yuv420p"), new InputFormat("libx265", "yuv420p10le") // // new("mpeg4", "yuv420p"), // // new("libvpx-vp9", "yuv420p"), // new("libvpx-vp9", "yuv420p10le"), // // // // new("libaom-av1", "yuv420p") // // // av1 yuv420p10le 51 // // // new("msmpeg4v2", "yuv420p"), // new("msmpeg4v3", "yuv420p") // // // wmv3 yuv420p 1 ]; public static Resolution[] Resolutions = [ new Resolution { Width = 1920, Height = 1080 }, new Resolution { Width = 1280, Height = 720 }, new Resolution { Width = 640, Height = 480 } ]; public static FFmpegProfileBitDepth[] BitDepths = [ FFmpegProfileBitDepth.EightBit, FFmpegProfileBitDepth.TenBit ]; public static FFmpegProfileVideoFormat[] VideoFormats = [ //FFmpegProfileVideoFormat.H264, FFmpegProfileVideoFormat.Hevc // FFmpegProfileVideoFormat.Mpeg2Video ]; public static HardwareAccelerationKind[] TestAccelerations = [ //HardwareAccelerationKind.None, HardwareAccelerationKind.Nvenc, //HardwareAccelerationKind.Vaapi //HardwareAccelerationKind.Qsv, // HardwareAccelerationKind.VideoToolbox, // HardwareAccelerationKind.Amf ]; public static StreamingMode[] StreamingModes = [ StreamingMode.TransportStream, //StreamingMode.HttpLiveStreamingSegmenter, //StreamingMode.HttpLiveStreamingSegmenterV2 ]; public static string[] FilesToTest => [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, [ValueSource(typeof(TestData), nameof(TestData.StreamingModes))] StreamingMode streamingMode) { var localFileSystem = new LocalFileSystem( Substitute.For(), LoggerFactory.CreateLogger()); var tempFilePool = new TempFilePool(); ImageCache mockImageCache = Substitute.For(localFileSystem, tempFilePool); // always return the static watermark resource mockImageCache.GetPathForImage( Arg.Any(), Arg.Is(x => x == ArtworkKind.Watermark), Arg.Any>()) .Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png")); mockImageCache.GetPathForImage( Arg.Any(), Arg.Is(x => x == ArtworkKind.Thumbnail), Arg.Any>()) .Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "song_album_cover_512.png")); var oldService = new FFmpegProcessService( new FakeStreamSelector(), mockImageCache, tempFilePool, Substitute.For(), MemoryCache, LoggerFactory.CreateLogger()); var service = new FFmpegLibraryProcessService( oldService, new FakeStreamSelector(), tempFilePool, new PipelineBuilderFactory( //new FakeNvidiaCapabilitiesFactory(), new HardwareCapabilitiesFactory( MemoryCache, new RuntimeInfo(), LoggerFactory.CreateLogger()), LoggerFactory.CreateLogger()), Substitute.For(), LoggerFactory.CreateLogger()); var songVideoGenerator = new SongVideoGenerator(tempFilePool, mockImageCache, 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, SubtitleMode = ChannelSubtitleMode.None }; string file = Path.Combine(TestContext.CurrentContext.TestDirectory, Path.Combine("Resources", "song.mp3")); var songVersion = new MediaVersion { MediaFiles = [new MediaFile { Path = file }], Streams = [] }; var song = new Song { SongMetadata = [ new SongMetadata { Title = "Song Title", Artists = ["Song Artist"], AlbumArtists = [], Artwork = [] } ], MediaVersions = [songVersion] }; (string videoPath, MediaVersion videoVersion) = await songVideoGenerator.GenerateSongVideo( song, channel, None, // playout item watermark None, // global watermark ExecutableName("ffmpeg"), ExecutableName("ffprobe"), CancellationToken.None); IMetadataRepository metadataRepository = Substitute.For(); metadataRepository.When(x => x.UpdateStatistics(Arg.Any(), Arg.Any(), Arg.Any())) .Do( x => { MediaVersion version = x.Arg(); 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, new LocalFileSystem(Substitute.For(), LoggerFactory.CreateLogger()), Substitute.For(), 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), "drm", VaapiDriver.RadeonSI, "/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, VaapiDriver.RadeonSI, localStatisticsProvider, streamingMode, () => videoVersion); } [Test] [Combinatorial] public async Task Transcode( [ValueSource(typeof(TestData), nameof(TestData.FilesToTest))] string fileToTest, [ValueSource(typeof(TestData), nameof(TestData.InputFormats))] InputFormat inputFormat, [ValueSource(typeof(TestData), nameof(TestData.Resolutions))] Resolution profileResolution, [ValueSource(typeof(TestData), nameof(TestData.BitDepths))] FFmpegProfileBitDepth profileBitDepth, [ValueSource(typeof(TestData), nameof(TestData.Paddings))] Padding padding, [ValueSource(typeof(TestData), nameof(TestData.ScalingBehaviors))] ScalingBehavior scalingBehavior, [ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))] VideoScanKind videoScanKind, [ValueSource(typeof(TestData), nameof(TestData.Watermarks))] Watermark watermark, [ValueSource(typeof(TestData), nameof(TestData.Subtitles))] Subtitle subtitle, [ValueSource(typeof(TestData), nameof(TestData.VideoFormats))] FFmpegProfileVideoFormat profileVideoFormat, [ValueSource(typeof(TestData), nameof(TestData.TestAccelerations))] HardwareAccelerationKind profileAcceleration, [ValueSource(typeof(TestData), nameof(TestData.StreamingModes))] StreamingMode streamingMode) { string file = fileToTest; if (string.IsNullOrWhiteSpace(file)) { // some formats don't support interlaced content (mpeg1video, msmpeg4v2, msmpeg4v3) // others (libx265, any 10-bit) are unlikely to have interlaced content, so don't bother testing if (inputFormat.Encoder is "mpeg1video" or "msmpeg4v2" or "msmpeg4v3" or "libx265" || inputFormat.PixelFormat.Contains("10")) { if (videoScanKind == VideoScanKind.Interlaced) { Assert.Inconclusive( $"{inputFormat.Encoder}/{inputFormat.PixelFormat} does not support interlaced content"); return; } } string name = GetStringSha256Hash($"{inputFormat}_{videoScanKind}_{padding}_{scalingBehavior}_{subtitle}"); file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv"); if (!File.Exists(file)) { await GenerateTestFile(inputFormat, padding, scalingBehavior, videoScanKind, subtitle, file); } } var v = new MediaVersion { MediaFiles = [new MediaFile { Path = file }], Streams = [] }; IMetadataRepository? metadataRepository = Substitute.For(); metadataRepository .When(r => r.UpdateStatistics(Arg.Any(), Arg.Any(), Arg.Any())) .Do( args => { MediaVersion? version = args.Arg(); version.MediaFiles = v.MediaFiles; v = version; }); var localStatisticsProvider = new LocalStatisticsProvider( metadataRepository, new LocalFileSystem(Substitute.For(), LoggerFactory.CreateLogger()), Substitute.For(), LoggerFactory.CreateLogger()); await localStatisticsProvider.RefreshStatistics( ExecutableName("ffmpeg"), ExecutableName("ffprobe"), new Movie { MediaVersions = new List { new() { MediaFiles = new List { new() { Path = file } } } } }); if (videoScanKind == VideoScanKind.Interlaced) { v.VideoScanKind.ShouldBe(VideoScanKind.Interlaced, file); } var subtitleStreams = v.Streams .Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle) .ToList(); var subtitles = new List(); if (subtitle != Subtitle.None) { foreach (MediaStream stream in subtitleStreams) { var s = new ErsatzTV.Core.Domain.Subtitle { Codec = stream.Codec, Default = stream.Default, Forced = stream.Forced, Language = stream.Language, StreamIndex = stream.Index, SubtitleKind = SubtitleKind.Embedded, DateAdded = DateTime.UtcNow, DateUpdated = DateTime.UtcNow, Path = "test.srt", IsExtracted = true }; subtitles.Add(s); } } DateTimeOffset now = DateTimeOffset.Now; Option channelWatermark = GetWatermark(watermark); ChannelSubtitleMode subtitleMode = subtitle switch { Subtitle.Picture or Subtitle.Text => ChannelSubtitleMode.Any, _ => ChannelSubtitleMode.None }; string srtFile = Path.Combine(FileSystemLayout.SubtitleCacheFolder, "test.srt"); if (subtitle == Subtitle.Text && !File.Exists(srtFile)) { string sourceFile = Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "test.srt"); Directory.CreateDirectory(FileSystemLayout.SubtitleCacheFolder); File.Copy(sourceFile, srtFile, true); } void PipelineAction(FFmpegPipeline pipeline) { // validate pipeline matches expectations (at a high level) ComplexFilter complexFilter = pipeline.PipelineSteps.OfType().First(); FilterChain filterChain = complexFilter.FilterChain; if (profileBitDepth == FFmpegProfileBitDepth.TenBit) // process.Arguments.Contains("=nv12") && // !process.Arguments.Contains("format=nv12,format=p010le[") && // !process.Arguments.Contains("hwdownload,format=nv12,subtitle") && // !process.Arguments.Contains("format=nv12,hwupload_cuda[st]") && // !process.Arguments.Contains("format=nv12,hwupload_cuda[wm]")) { var videoFilters = string.Join(",", filterChain.VideoFilterSteps.Map(f => f.Filter)); var pixelFormatFilters = string.Join(",", filterChain.PixelFormatFilterSteps.Map(f => f.Filter)); if (videoFilters.Contains("nv12") || pixelFormatFilters.Contains("nv12") && !pixelFormatFilters.EndsWith("format=nv12,format=p010le")) { // Assert.Fail("10-bit shouldn't use NV12!"); } } bool hasDeinterlaceFilter = filterChain.VideoFilterSteps.Any( s => s is YadifFilter or YadifCudaFilter or DeinterlaceQsvFilter or DeinterlaceVaapiFilter); hasDeinterlaceFilter.ShouldBe(videoScanKind == VideoScanKind.Interlaced); bool hasScaling = filterChain.VideoFilterSteps.Filter( s => s is ScaleFilter or ScaleCudaFilter or ScaleQsvFilter or ScaleVaapiFilter) .Filter(s => s is not ScaleCudaFilter cuda || !cuda.Filter.Contains("scale_cuda=format=")) .Any(); // TODO: sometimes scaling is used for pixel format, so this is harder to assert the absence if (profileResolution.Width != 1920 && profileResolution.Width != 640) { hasScaling.ShouldBeTrue(); } // TODO: bit depth bool hasPadding = filterChain.VideoFilterSteps.Any(s => s is PadFilter); // TODO: optimize out padding // hasPadding.ShouldBe(padding == Padding.WithPadding); if (padding is Padding.WithPadding && scalingBehavior is not ScalingBehavior.Crop) { hasPadding.ShouldBeTrue(); } bool hasCrop = filterChain.VideoFilterSteps.Any(s => s is CropFilter); if (scalingBehavior is ScalingBehavior.Crop) { hasCrop.ShouldBeTrue(); } bool hasSubtitleFilters = filterChain.VideoFilterSteps.Any(s => s is SubtitlesFilter) || filterChain.SubtitleOverlayFilterSteps.Any( s => s is OverlaySubtitleFilter or OverlaySubtitleCudaFilter or OverlaySubtitleQsvFilter or OverlaySubtitleVaapiFilter); hasSubtitleFilters.ShouldBe(subtitle != Subtitle.None); bool hasWatermarkFilters = filterChain.WatermarkOverlayFilterSteps.Any( s => s is OverlayWatermarkFilter or OverlayWatermarkCudaFilter or OverlayWatermarkQsvFilter); hasWatermarkFilters.ShouldBe(watermark != Watermark.None); } FFmpegLibraryProcessService service = GetService(); Command process = await service.ForPlayoutItem( ExecutableName("ffmpeg"), ExecutableName("ffprobe"), false, new Channel(Guid.NewGuid()) { Number = "1", FFmpegProfile = FFmpegProfile.New("test", profileResolution) with { HardwareAcceleration = profileAcceleration, VideoFormat = profileVideoFormat, AudioFormat = FFmpegProfileAudioFormat.Aac, DeinterlaceVideo = true, BitDepth = profileBitDepth, ScalingBehavior = scalingBehavior }, StreamingMode = streamingMode, SubtitleMode = subtitleMode }, v, new MediaItemAudioVersion(null, v), file, file, _ => subtitles.AsTask(), string.Empty, string.Empty, string.Empty, subtitleMode, now, now + TimeSpan.FromSeconds(3), now, Option.None, channelWatermark, "drm", VaapiDriver.RadeonSI, "/dev/dri/renderD128", Option.None, false, FillerKind.None, TimeSpan.Zero, TimeSpan.FromSeconds(3), 0, None, false, PipelineAction); // Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}"); await TranscodeAndVerify( process, profileResolution, profileBitDepth, profileVideoFormat, profileAcceleration, VaapiDriver.RadeonSI, localStatisticsProvider, streamingMode, () => v); } private Option GetWatermark(Watermark watermark) { switch (watermark) { case Watermark.None: break; case Watermark.IntermittentOpaque: return new ChannelWatermark { 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 { 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 { 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( InputFormat inputFormat, Padding padding, ScalingBehavior scalingBehavior, VideoScanKind videoScanKind, Subtitle subtitle, string file) { string resolution = (scalingBehavior, padding) switch { (ScalingBehavior.Crop, Padding.NoPadding) => "1920x1080", // TODO: (ScalingBehavior.Crop, Padding.WithPadding) => "632x480", (ScalingBehavior.Stretch or ScalingBehavior.ScaleAndPad, Padding.WithPadding) => "1920x1060", _ => "1920x1080" }; string videoFilter = videoScanKind == VideoScanKind.Interlaced ? "-vf interlace=scan=tff:lowpass=complex" : string.Empty; string flags = videoScanKind == VideoScanKind.Interlaced ? "-field_order tt -flags +ildct+ilme" : string.Empty; string colorRange = !string.IsNullOrWhiteSpace(inputFormat.ColorRange) ? $" -color_range {inputFormat.ColorRange}" : string.Empty; string colorSpace = !string.IsNullOrWhiteSpace(inputFormat.ColorSpace) ? $" -colorspace {inputFormat.ColorSpace}" : string.Empty; string colorTransfer = !string.IsNullOrWhiteSpace(inputFormat.ColorTransfer) ? $" -color_trc {inputFormat.ColorTransfer}" : string.Empty; string colorPrimaries = !string.IsNullOrWhiteSpace(inputFormat.ColorPrimaries) ? $" -color_primaries {inputFormat.ColorPrimaries}" : string.Empty; var args = $"-y -f lavfi -i anoisesrc=color=brown -f lavfi -i testsrc=duration=1:size={resolution}:rate=30 {videoFilter} -c:a aac -c:v {inputFormat.Encoder}{colorRange}{colorSpace}{colorTransfer}{colorPrimaries} -shortest -pix_fmt {inputFormat.PixelFormat} -strict -2 {flags} {file}"; BufferedCommandResult p1 = await Cli.Wrap(ExecutableName("ffmpeg")) .WithArguments(args) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(); string output = string.IsNullOrWhiteSpace(p1.StandardOutput) ? p1.StandardError : p1.StandardOutput; p1.ExitCode.ShouldBe(0, output); switch (subtitle) { case Subtitle.Text or Subtitle.Picture: string sourceFile = Path.GetTempFileName() + ".mkv"; File.Move(file, sourceFile, true); string tempFileName = Path.GetTempFileName() + ".mkv"; string subPath = Path.Combine( TestContext.CurrentContext.TestDirectory, "Resources", subtitle == Subtitle.Picture ? "test.sup" : "test.srt"); BufferedCommandResult p2 = await new Command(ExecutableName("mkvmerge")) .WithArguments( $"-o {tempFileName} {sourceFile} --field-order 0:{(videoScanKind == VideoScanKind.Interlaced ? '1' : '0')} {subPath}") .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(); if (p2.ExitCode != 0) { if (File.Exists(sourceFile)) { File.Delete(sourceFile); } if (File.Exists(file)) { File.Delete(file); } } p2.ExitCode.ShouldBe(0); await SetInterlacedFlag(tempFileName, sourceFile, file, videoScanKind == VideoScanKind.Interlaced); File.Move(tempFileName, file, true); break; } } private static async Task SetInterlacedFlag(string tempFileName, string sourceFile, string file, bool interlaced) { var p = new Process { StartInfo = new ProcessStartInfo { FileName = ExecutableName("mkvpropedit"), Arguments = $"{tempFileName} --edit track:v1 --set interlaced={(interlaced ? '1' : '0')}" } }; p.Start(); await p.WaitForExitAsync(); // ReSharper disable once MethodHasAsyncOverload p.WaitForExit(); if (p.ExitCode != 0) { if (File.Exists(sourceFile)) { File.Delete(sourceFile); } if (File.Exists(file)) { File.Delete(file); } } p.ExitCode.ShouldBe(0); } private static string GetStringSha256Hash(string text) { if (string.IsNullOrEmpty(text)) { return string.Empty; } using var sha = SHA256.Create(); byte[] textData = Encoding.UTF8.GetBytes(text); byte[] hash = sha.ComputeHash(textData); return BitConverter.ToString(hash).Replace("-", string.Empty); } private static FFmpegLibraryProcessService GetService() { IImageCache? imageCache = Substitute.For(); // always return the static watermark resource imageCache.GetPathForImage( Arg.Any(), Arg.Is(x => x == ArtworkKind.Watermark), Arg.Any>()) .Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png")); imageCache.GetPathForImage( Arg.Any(), Arg.Is(x => x == ArtworkKind.Thumbnail), Arg.Any>()) .Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "song_album_cover_512.png")); var oldService = new FFmpegProcessService( new FakeStreamSelector(), imageCache, Substitute.For(), Substitute.For(), MemoryCache, LoggerFactory.CreateLogger()); var service = new FFmpegLibraryProcessService( oldService, new FakeStreamSelector(), Substitute.For(), new PipelineBuilderFactory( //new FakeNvidiaCapabilitiesFactory(), new HardwareCapabilitiesFactory( MemoryCache, new RuntimeInfo(), LoggerFactory.CreateLogger()), LoggerFactory.CreateLogger()), Substitute.For(), LoggerFactory.CreateLogger()); return service; } private async Task TranscodeAndVerify( Command process, Resolution profileResolution, FFmpegProfileBitDepth profileBitDepth, FFmpegProfileVideoFormat profileVideoFormat, HardwareAccelerationKind profileAcceleration, VaapiDriver vaapiDriver, ILocalStatisticsProvider localStatisticsProvider, StreamingMode streamingMode, 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.ShouldBe(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.ShouldBe(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 MediaVersion { MediaFiles = [new MediaFile { Path = tempFile }] } ] }); MediaVersion v = getFinalMediaVersion(); // verify de-interlace v.VideoScanKind.ShouldNotBe(VideoScanKind.Interlaced); // verify resolution v.Height.ShouldBe(profileResolution.Height); v.Width.ShouldBe(profileResolution.Width); foreach (MediaStream videoStream in v.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Video)) { // verify pixel format string expectedPixelFormat = (profileBitDepth, streamingMode) switch { //(FFmpegProfileBitDepth.TenBit, StreamingMode.HttpLiveStreamingSegmenterV2) => PixelFormat.RGB555LE, (FFmpegProfileBitDepth.TenBit, _) => PixelFormat.YUV420P10LE, _ => PixelFormat.YUV420P }; videoStream.PixelFormat.ShouldBe(expectedPixelFormat); // 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 // RADEONSI driver doesn't set this properly // NUT doesn't set this properly if (profileAcceleration != HardwareAccelerationKind.Amf && profileVideoFormat != FFmpegProfileVideoFormat.Mpeg2Video && (profileAcceleration != HardwareAccelerationKind.Vaapi || vaapiDriver != VaapiDriver.RadeonSI) && streamingMode != StreamingMode.HttpLiveStreamingSegmenterV2) { colorParams.IsBt709.ShouldBeTrue($"{colorParams}"); } } } finally { if (File.Exists(tempFile)) { File.Delete(tempFile); } } } private class FakeStreamSelector : IFFmpegStreamSelector { public Task SelectVideoStream(MediaVersion version) => version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); public Task> SelectAudioStream( MediaItemAudioVersion version, StreamingMode streamingMode, Channel channel, string preferredAudioLanguage, string preferredAudioTitle) => Optional(version.MediaVersion.Streams.FirstOrDefault(s => s.MediaStreamKind == MediaStreamKind.Audio)) .AsTask(); public Task> SelectSubtitleStream( List subtitles, Channel channel, string preferredSubtitleLanguage, ChannelSubtitleMode subtitleMode) => subtitles.HeadOrNone().AsTask(); } private static string ExecutableName(string baseName) => OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName; }