mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1084 lines
39 KiB
1084 lines
39 KiB
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<IClient>(), |
|
LoggerFactory.CreateLogger<LocalFileSystem>()); |
|
var tempFilePool = new TempFilePool(); |
|
|
|
ImageCache mockImageCache = Substitute.For<ImageCache>(localFileSystem, tempFilePool); |
|
|
|
// always return the static watermark resource |
|
mockImageCache.GetPathForImage( |
|
Arg.Any<string>(), |
|
Arg.Is<ArtworkKind>(x => x == ArtworkKind.Watermark), |
|
Arg.Any<Option<int>>()) |
|
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png")); |
|
|
|
mockImageCache.GetPathForImage( |
|
Arg.Any<string>(), |
|
Arg.Is<ArtworkKind>(x => x == ArtworkKind.Thumbnail), |
|
Arg.Any<Option<int>>()) |
|
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "song_album_cover_512.png")); |
|
|
|
var oldService = new FFmpegProcessService( |
|
new FakeStreamSelector(), |
|
mockImageCache, |
|
tempFilePool, |
|
Substitute.For<IClient>(), |
|
MemoryCache, |
|
LoggerFactory.CreateLogger<FFmpegProcessService>()); |
|
|
|
var service = new FFmpegLibraryProcessService( |
|
oldService, |
|
new FakeStreamSelector(), |
|
tempFilePool, |
|
new PipelineBuilderFactory( |
|
//new FakeNvidiaCapabilitiesFactory(), |
|
new HardwareCapabilitiesFactory( |
|
MemoryCache, |
|
new RuntimeInfo(), |
|
LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()), |
|
LoggerFactory.CreateLogger<PipelineBuilderFactory>()), |
|
Substitute.For<IConfigElementRepository>(), |
|
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>()); |
|
|
|
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<IMetadataRepository>(); |
|
metadataRepository.When(x => x.UpdateStatistics(Arg.Any<MediaItem>(), Arg.Any<MediaVersion>(), Arg.Any<bool>())) |
|
.Do( |
|
x => |
|
{ |
|
MediaVersion version = x.Arg<MediaVersion>(); |
|
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<IClient>(), LoggerFactory.CreateLogger<LocalFileSystem>()), |
|
Substitute.For<IClient>(), |
|
LoggerFactory.CreateLogger<LocalStatisticsProvider>()); |
|
|
|
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<ErsatzTV.Core.Domain.Subtitle>()), |
|
string.Empty, |
|
string.Empty, |
|
string.Empty, |
|
ChannelSubtitleMode.None, |
|
now, |
|
now + TimeSpan.FromSeconds(3), |
|
now, |
|
Option<ChannelWatermark>.None, |
|
GetWatermark(watermark), |
|
"drm", |
|
VaapiDriver.RadeonSI, |
|
"/dev/dri/renderD128", |
|
Option<int>.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<IMetadataRepository>(); |
|
metadataRepository |
|
.When(r => r.UpdateStatistics(Arg.Any<MediaItem>(), Arg.Any<MediaVersion>(), Arg.Any<bool>())) |
|
.Do( |
|
args => |
|
{ |
|
MediaVersion? version = args.Arg<MediaVersion>(); |
|
version.MediaFiles = v.MediaFiles; |
|
v = version; |
|
}); |
|
|
|
var localStatisticsProvider = new LocalStatisticsProvider( |
|
metadataRepository, |
|
new LocalFileSystem(Substitute.For<IClient>(), LoggerFactory.CreateLogger<LocalFileSystem>()), |
|
Substitute.For<IClient>(), |
|
LoggerFactory.CreateLogger<LocalStatisticsProvider>()); |
|
|
|
await localStatisticsProvider.RefreshStatistics( |
|
ExecutableName("ffmpeg"), |
|
ExecutableName("ffprobe"), |
|
new Movie |
|
{ |
|
MediaVersions = new List<MediaVersion> |
|
{ |
|
new() |
|
{ |
|
MediaFiles = new List<MediaFile> |
|
{ |
|
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<ErsatzTV.Core.Domain.Subtitle>(); |
|
|
|
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> 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<ComplexFilter>().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<ChannelWatermark>.None, |
|
channelWatermark, |
|
"drm", |
|
VaapiDriver.RadeonSI, |
|
"/dev/dri/renderD128", |
|
Option<int>.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<ChannelWatermark> 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<ChannelWatermark>.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<IImageCache>(); |
|
|
|
// always return the static watermark resource |
|
imageCache.GetPathForImage( |
|
Arg.Any<string>(), |
|
Arg.Is<ArtworkKind>(x => x == ArtworkKind.Watermark), |
|
Arg.Any<Option<int>>()) |
|
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png")); |
|
|
|
imageCache.GetPathForImage( |
|
Arg.Any<string>(), |
|
Arg.Is<ArtworkKind>(x => x == ArtworkKind.Thumbnail), |
|
Arg.Any<Option<int>>()) |
|
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "song_album_cover_512.png")); |
|
|
|
var oldService = new FFmpegProcessService( |
|
new FakeStreamSelector(), |
|
imageCache, |
|
Substitute.For<ITempFilePool>(), |
|
Substitute.For<IClient>(), |
|
MemoryCache, |
|
LoggerFactory.CreateLogger<FFmpegProcessService>()); |
|
|
|
var service = new FFmpegLibraryProcessService( |
|
oldService, |
|
new FakeStreamSelector(), |
|
Substitute.For<ITempFilePool>(), |
|
new PipelineBuilderFactory( |
|
//new FakeNvidiaCapabilitiesFactory(), |
|
new HardwareCapabilitiesFactory( |
|
MemoryCache, |
|
new RuntimeInfo(), |
|
LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()), |
|
LoggerFactory.CreateLogger<PipelineBuilderFactory>()), |
|
Substitute.For<IConfigElementRepository>(), |
|
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>()); |
|
|
|
return service; |
|
} |
|
|
|
private async Task TranscodeAndVerify( |
|
Command process, |
|
Resolution profileResolution, |
|
FFmpegProfileBitDepth profileBitDepth, |
|
FFmpegProfileVideoFormat profileVideoFormat, |
|
HardwareAccelerationKind profileAcceleration, |
|
VaapiDriver vaapiDriver, |
|
ILocalStatisticsProvider localStatisticsProvider, |
|
StreamingMode streamingMode, |
|
Func<MediaVersion> 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<MediaStream> SelectVideoStream(MediaVersion version) => |
|
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); |
|
|
|
public Task<Option<MediaStream>> SelectAudioStream( |
|
MediaItemAudioVersion version, |
|
StreamingMode streamingMode, |
|
Channel channel, |
|
string preferredAudioLanguage, |
|
string preferredAudioTitle) => |
|
Optional(version.MediaVersion.Streams.FirstOrDefault(s => s.MediaStreamKind == MediaStreamKind.Audio)) |
|
.AsTask(); |
|
|
|
public Task<Option<ErsatzTV.Core.Domain.Subtitle>> SelectSubtitleStream( |
|
List<ErsatzTV.Core.Domain.Subtitle> subtitles, |
|
Channel channel, |
|
string preferredSubtitleLanguage, |
|
ChannelSubtitleMode subtitleMode) => |
|
subtitles.HeadOrNone().AsTask(); |
|
} |
|
|
|
private static string ExecutableName(string baseName) => |
|
OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName; |
|
}
|
|
|