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:
+2
-1
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ public class FFmpegProcessService
|
||||
None,
|
||||
videoPath,
|
||||
None)
|
||||
.WithOutputFormat("apng", outputFile)
|
||||
.WithOutputFormat("apng", outputFile, "-pix_fmt", "rgb24")
|
||||
.Build();
|
||||
|
||||
_logger.LogInformation(
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user