Browse Source

song normalization (#1202)

* add tests to verify song normalization

* simplify song setup, include watermarks and album art

* fix song path

* update changelog
pull/1203/head
Jason Dove 3 years ago committed by GitHub
parent
commit
bd2f0f6236
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      CHANGELOG.md
  2. 11
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  3. 7
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  4. 2
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  5. 2
      ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs
  6. 3
      ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs
  7. 2
      ErsatzTV.Infrastructure/Images/ImageCache.cs
  8. 640
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  9. 3
      ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
  10. BIN
      ErsatzTV.Scanner.Tests/Resources/song.mp3

3
CHANGELOG.md

@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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/). @@ -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

11
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -140,7 +140,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -140,7 +140,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None,
playbackSettings.NormalizeLoudness);
IPixelFormat pixelFormat = await AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger)
// don't log generated images which are expected to have unknown format
ILogger<FFmpegLibraryProcessService> pixelFormatLogger = videoPath == audioPath ? _logger : null;
IPixelFormat pixelFormat = await AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, pixelFormatLogger)
.IfNoneAsync(
() =>
{
@ -222,16 +225,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -222,16 +225,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts")
: Option<string>.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),

7
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -129,8 +129,13 @@ internal class FFmpegProcessBuilder @@ -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);

2
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -130,7 +130,7 @@ public class FFmpegProcessService @@ -130,7 +130,7 @@ public class FFmpegProcessService
None,
videoPath,
None)
.WithOutputFormat("apng", outputFile)
.WithOutputFormat("apng", outputFile, "-pix_fmt", "rgb24")
.Build();
_logger.LogInformation(

2
ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs

@ -170,7 +170,7 @@ public class SongVideoGenerator : ISongVideoGenerator @@ -170,7 +170,7 @@ public class SongVideoGenerator : ISongVideoGenerator
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
},
};
string customPath = _imageCache.GetPathForImage(

3
ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs

@ -95,7 +95,8 @@ public class NvidiaHardwareCapabilities : IHardwareCapabilities @@ -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<string, bool> check)
{

2
ErsatzTV.Infrastructure/Images/ImageCache.cs

@ -97,7 +97,7 @@ public class ImageCache : IImageCache @@ -97,7 +97,7 @@ public class ImageCache : IImageCache
}
}
public string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight)
public virtual string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight)
{
string subfolder = maybeMaxHeight.Match(
maxHeight => Path.Combine(maxHeight.ToString(), fileName[..2]),

640
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -21,7 +21,9 @@ using ErsatzTV.FFmpeg.Filter.Vaapi; @@ -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 @@ -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 @@ -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<IClient>().Object, LoggerFactory.CreateLogger<LocalFileSystem>());
var tempFilePool = new TempFilePool();
var mockImageCache = new Mock<ImageCache>(localFileSystem, tempFilePool);
// always return the static watermark resource
mockImageCache.Setup(
ic => ic.GetPathForImage(
It.IsAny<string>(),
It.Is<ArtworkKind>(x => x == ArtworkKind.Watermark),
It.IsAny<Option<int>>()))
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png"));
var oldService = new FFmpegProcessService(
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
mockImageCache.Object,
tempFilePool,
new Mock<IClient>().Object,
MemoryCache,
LoggerFactory.CreateLogger<FFmpegProcessService>());
var service = new FFmpegLibraryProcessService(
oldService,
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
tempFilePool,
new PipelineBuilderFactory(
new RuntimeInfo(),
//new FakeNvidiaCapabilitiesFactory(),
new HardwareCapabilitiesFactory(
MemoryCache,
LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()),
LoggerFactory.CreateLogger<PipelineBuilderFactory>()),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
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<MediaFile>
{
new() { Path = file }
},
Streams = new List<MediaStream>()
};
var song = new Song
{
SongMetadata = new List<SongMetadata>
{
new()
{
Title = "Song Title",
Artist = "Song Artist",
Artwork = new List<Artwork>()
}
},
MediaVersions = new List<MediaVersion> { 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<IMetadataRepository>();
metadataRepository
.Setup(r => r.UpdateStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<MediaItem, MediaVersion, bool>(
(_, 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<IClient>().Object, LoggerFactory.CreateLogger<LocalFileSystem>()),
new Mock<IClient>().Object,
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),
VaapiDriver.Default,
"/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,
localStatisticsProvider,
() => videoVersion);
}
[Test]
[Combinatorial]
public async Task Transcode(
@ -239,39 +418,6 @@ public class TranscodingTests @@ -239,39 +418,6 @@ public class TranscodingTests
}
}
var imageCache = new Mock<IImageCache>();
// always return the static watermark resource
imageCache.Setup(
ic => ic.GetPathForImage(
It.IsAny<string>(),
It.Is<ArtworkKind>(x => x == ArtworkKind.Watermark),
It.IsAny<Option<int>>()))
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png"));
var oldService = new FFmpegProcessService(
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
imageCache.Object,
new Mock<ITempFilePool>().Object,
new Mock<IClient>().Object,
MemoryCache,
LoggerFactory.CreateLogger<FFmpegProcessService>());
var service = new FFmpegLibraryProcessService(
oldService,
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
new Mock<ITempFilePool>().Object,
new PipelineBuilderFactory(
new RuntimeInfo(),
//new FakeNvidiaCapabilitiesFactory(),
new HardwareCapabilitiesFactory(
MemoryCache,
LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()),
LoggerFactory.CreateLogger<PipelineBuilderFactory>()),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
var v = new MediaVersion
{
MediaFiles = new List<MediaFile>
@ -349,72 +495,7 @@ public class TranscodingTests @@ -349,72 +495,7 @@ public class TranscodingTests
DateTimeOffset now = DateTimeOffset.Now;
Option<ChannelWatermark> channelWatermark = Option<ChannelWatermark>.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> channelWatermark = GetWatermark(watermark);
ChannelSubtitleMode subtitleMode = subtitle switch
{
@ -495,6 +576,8 @@ public class TranscodingTests @@ -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 @@ -541,127 +624,79 @@ public class TranscodingTests
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
string[] unsupportedMessages =
{
"No support for codec",
"No usable",
"Provided device doesn't support",
"Current pixel format is unsupported"
};
await TranscodeAndVerify(
process,
profileResolution,
profileBitDepth,
profileVideoFormat,
profileAcceleration,
localStatisticsProvider,
() => v);
}
var sb = new StringBuilder();
var timeoutSignal = new CancellationTokenSource(TimeSpan.FromSeconds(30));
string tempFile = Path.GetTempFileName();
try
private Option<ChannelWatermark> GetWatermark(Watermark watermark)
{
switch (watermark)
{
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<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
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<ChannelWatermark>.None;
}
private static async Task GenerateTestFile(
@ -795,6 +830,178 @@ public class TranscodingTests @@ -795,6 +830,178 @@ public class TranscodingTests
return BitConverter.ToString(hash).Replace("-", string.Empty);
}
private static FFmpegLibraryProcessService GetService()
{
var imageCache = new Mock<IImageCache>();
// always return the static watermark resource
imageCache.Setup(
ic => ic.GetPathForImage(
It.IsAny<string>(),
It.Is<ArtworkKind>(x => x == ArtworkKind.Watermark),
It.IsAny<Option<int>>()))
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png"));
var oldService = new FFmpegProcessService(
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
imageCache.Object,
new Mock<ITempFilePool>().Object,
new Mock<IClient>().Object,
MemoryCache,
LoggerFactory.CreateLogger<FFmpegProcessService>());
var service = new FFmpegLibraryProcessService(
oldService,
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
new Mock<ITempFilePool>().Object,
new PipelineBuilderFactory(
new RuntimeInfo(),
//new FakeNvidiaCapabilitiesFactory(),
new HardwareCapabilitiesFactory(
MemoryCache,
LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()),
LoggerFactory.CreateLogger<PipelineBuilderFactory>()),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
return service;
}
private async Task TranscodeAndVerify(
Command process,
Resolution profileResolution,
FFmpegProfileBitDepth profileBitDepth,
FFmpegProfileVideoFormat profileVideoFormat,
HardwareAccelerationKind profileAcceleration,
ILocalStatisticsProvider localStatisticsProvider,
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.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<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
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<MediaStream> SelectVideoStream(MediaVersion version) =>
@ -806,7 +1013,8 @@ public class TranscodingTests @@ -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<Option<ErsatzTV.Core.Domain.Subtitle>> SelectSubtitleStream(
List<ErsatzTV.Core.Domain.Subtitle> subtitles,
@ -815,7 +1023,7 @@ public class TranscodingTests @@ -815,7 +1023,7 @@ public class TranscodingTests
ChannelSubtitleMode subtitleMode) =>
subtitles.HeadOrNone().AsTask();
}
private static string ExecutableName(string baseName) =>
OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
}

3
ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj

@ -48,6 +48,9 @@ @@ -48,6 +48,9 @@
<Content Include="Resources\test.sup">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\song.mp3">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

BIN
ErsatzTV.Scanner.Tests/Resources/song.mp3

Binary file not shown.
Loading…
Cancel
Save