song normalization (#1202)

* add tests to verify song normalization

* simplify song setup, include watermarks and album art

* fix song path

* update changelog
This commit is contained in:
Jason Dove
2023-03-09 10:40:59 -06:00
committed by GitHub
parent 4c67965b50
commit bd2f0f6236
10 changed files with 446 additions and 229 deletions
+2 -1
View File
@@ -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
@@ -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
? 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),
+6 -1
View File
@@ -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);
+1 -1
View File
@@ -130,7 +130,7 @@ public class FFmpegProcessService
None,
videoPath,
None)
.WithOutputFormat("apng", outputFile)
.WithOutputFormat("apng", outputFile, "-pix_fmt", "rgb24")
.Build();
_logger.LogInformation(
+1 -1
View File
@@ -170,7 +170,7 @@ public class SongVideoGenerator : ISongVideoGenerator
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
},
};
string customPath = _imageCache.GetPathForImage(
@@ -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)
{
+1 -1
View File
@@ -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]),
@@ -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<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
}
}
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
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
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<ChannelWatermark> GetWatermark(Watermark watermark)
{
"No support for codec",
"No usable",
"Provided device doesn't support",
"Current pixel format is unsupported"
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
};
var sb = new StringBuilder();
var timeoutSignal = new CancellationTokenSource(TimeSpan.FromSeconds(30));
string tempFile = Path.GetTempFileName();
try
case Watermark.IntermittentTransparent:
return new ChannelWatermark
{
CommandResult result;
try
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
{
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)
ImageSource = ChannelWatermarkImageSource.Custom,
Mode = ChannelWatermarkMode.Permanent,
Opacity = 100,
Size = WatermarkSize.Scaled,
WidthPercent = 15
};
case Watermark.PermanentOpaqueActualSize:
return new ChannelWatermark
{
var arguments = string.Join(
' ',
process.Arguments.Split(" ").Map(a => a.Contains('[') ? $"\"{a}\"" : a));
Assert.Fail($"Transcode failure (timeout): ffmpeg {arguments}");
return;
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
};
}
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 }
}
}
}
});
// 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);
}
}
return Option<ChannelWatermark>.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<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
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,
@@ -48,6 +48,9 @@
<Content Include="Resources\test.sup">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\song.mp3">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
Binary file not shown.