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.
181 lines
6.9 KiB
181 lines
6.9 KiB
using System.ComponentModel; |
|
using CliWrap; |
|
using ErsatzTV.Core; |
|
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.Graphics; |
|
using ErsatzTV.Core.Interfaces.Metadata; |
|
using ErsatzTV.Core.Interfaces.Streaming; |
|
using ErsatzTV.FFmpeg; |
|
using ErsatzTV.Infrastructure.Streaming.Graphics; |
|
using LanguageExt; |
|
using Microsoft.Extensions.Logging.Abstractions; |
|
using NSubstitute; |
|
using NUnit.Framework; |
|
using Shouldly; |
|
using SkiaSharp; |
|
|
|
namespace ErsatzTV.Infrastructure.Tests.Streaming; |
|
|
|
[TestFixture] |
|
public class MotionElementTests |
|
{ |
|
private string _folder = null!; |
|
private string _videoPath = null!; |
|
|
|
[OneTimeSetUp] |
|
public async Task CreateAnimation() |
|
{ |
|
_folder = Path.Combine(Path.GetTempPath(), $"etv-motion-tests-{Guid.NewGuid():N}"); |
|
Directory.CreateDirectory(_folder); |
|
Directory.CreateDirectory(FileSystemLayout.TempFilePoolFolder); |
|
_videoPath = Path.Combine(_folder, "animation.mov"); |
|
try |
|
{ |
|
// One second red, then one second blue, with an alpha-capable codec. |
|
await Cli.Wrap("ffmpeg").WithArguments(new[] |
|
{ |
|
"-nostdin", "-v", "error", "-f", "lavfi", "-i", "color=red:s=16x16:r=10:d=1", |
|
"-f", "lavfi", "-i", "color=blue:s=16x16:r=10:d=1", |
|
"-filter_complex", "[0:v][1:v]concat=n=2:v=1:a=0", |
|
"-c:v", "prores_ks", "-profile:v", "4", "-pix_fmt", "yuva444p10le", _videoPath |
|
}).ExecuteAsync(); |
|
} |
|
catch (Win32Exception) |
|
{ |
|
Assert.Ignore("Motion integration tests require ffmpeg on PATH."); |
|
} |
|
} |
|
|
|
[OneTimeTearDown] |
|
public void RemoveAnimation() => Directory.Delete(_folder, true); |
|
|
|
[TestCase(0, 5, false)] |
|
[TestCase(5, 5, false)] |
|
[TestCase(5.5, 5.5, false)] |
|
[TestCase(6.5, 6.5, true)] |
|
[TestCase(6.95, 6.95, true)] |
|
[TestCase(6.99, 6.99, true)] |
|
[TestCase(7, 7, true)] |
|
[TestCase(600, 600, true)] |
|
public async Task Hold_should_show_the_correct_frame_when_joining(double seek, double time, bool blue) |
|
{ |
|
using var element = await CreateElement(MotionEndBehavior.Hold, seek); |
|
await AssertFrame(element, time, blue); |
|
(await Prepare(element, 3607)).IsNone.ShouldBeTrue(); |
|
element.IsFinished.ShouldBeTrue(); |
|
} |
|
|
|
[Test] |
|
public async Task Hold_should_resume_mid_clip_then_retain_the_final_frame() |
|
{ |
|
using var element = await CreateElement(MotionEndBehavior.Hold, 5.5); |
|
for (var frame = 0; frame < 20; frame++) |
|
{ |
|
await AssertFrame(element, 5.5 + frame / 10.0, frame >= 5); |
|
} |
|
|
|
await AssertFrame(element, 3606.9, true); |
|
(await Prepare(element, 3607)).IsNone.ShouldBeTrue(); |
|
} |
|
|
|
[Test] |
|
public async Task Hold_should_play_then_retain_the_final_frame() |
|
{ |
|
using var element = await CreateElement(MotionEndBehavior.Hold, 0); |
|
(await Prepare(element, 4.9)).IsNone.ShouldBeTrue(); |
|
for (var frame = 0; frame < 25; frame++) |
|
{ |
|
await AssertFrame(element, 5 + frame / 10.0, frame >= 10); |
|
} |
|
} |
|
|
|
[TestCase(MotionEndBehavior.Hold, 3607)] |
|
[TestCase(MotionEndBehavior.Hold, 3608)] |
|
[TestCase(MotionEndBehavior.Disappear, 7)] |
|
public async Task Should_be_finished_when_joining_at_or_after_end(MotionEndBehavior behavior, double seek) |
|
{ |
|
using var element = await CreateElement(behavior, seek); |
|
element.IsFinished.ShouldBeTrue(); |
|
(await Prepare(element, seek)).IsNone.ShouldBeTrue(); |
|
} |
|
|
|
[TestCase(MotionEndBehavior.Hold)] |
|
[TestCase(MotionEndBehavior.Disappear)] |
|
public async Task Should_enforce_end_time_even_while_playing(MotionEndBehavior behavior) |
|
{ |
|
using var element = await CreateElement(behavior, 5, holdSeconds: 0); |
|
await AssertFrame(element, 5, false); |
|
(await Prepare(element, 7)).IsNone.ShouldBeTrue(); |
|
element.IsFinished.ShouldBeTrue(); |
|
} |
|
|
|
[TestCase(600, true)] |
|
[TestCase(601, false)] |
|
public async Task Loop_should_wrap_seek_and_continue_across_the_source_end(double seek, bool blue) |
|
{ |
|
using var element = await CreateElement(MotionEndBehavior.Loop, seek); |
|
for (var frame = 0; frame < 25; frame++) |
|
{ |
|
bool expectedBlue = ((blue ? 10 : 0) + frame) % 20 >= 10; |
|
await AssertFrame(element, seek + frame / 10.0, expectedBlue); |
|
} |
|
} |
|
|
|
[Test] |
|
public async Task Loop_should_end_at_the_stream_end_without_adding_start_seconds() |
|
{ |
|
using var element = await CreateElement(MotionEndBehavior.Loop, 0, duration: 8); |
|
await AssertFrame(element, 5, false); |
|
(await Prepare(element, 8)).IsNone.ShouldBeTrue(); |
|
element.IsFinished.ShouldBeTrue(); |
|
} |
|
|
|
[Test] |
|
public async Task Should_skip_elements_starting_after_the_stream() |
|
{ |
|
using var element = await CreateElement(MotionEndBehavior.Loop, 0, duration: 5); |
|
element.IsFinished.ShouldBeTrue(); |
|
} |
|
|
|
private async Task<MotionElement> CreateElement( |
|
MotionEndBehavior behavior, double seek, double holdSeconds = 3600, double duration = 4000) |
|
{ |
|
var statistics = Substitute.For<ILocalStatisticsProvider>(); |
|
statistics.GetStatistics(Arg.Any<string>(), _videoPath).Returns( |
|
Task.FromResult<Either<BaseError, MediaVersion>>(new MediaVersion |
|
{ |
|
Width = 16, Height = 16, Duration = TimeSpan.FromSeconds(2), Streams = [] |
|
})); |
|
var element = new MotionElement(new MotionGraphicsElement |
|
{ |
|
VideoPath = _videoPath, StartSeconds = 5, EndBehavior = behavior, HoldSeconds = holdSeconds |
|
}, "ffprobe", "ffmpeg", statistics, NullLogger.Instance); |
|
var size = new Resolution { Width = 16, Height = 16 }; |
|
await element.InitializeAsync(new GraphicsEngineContext( |
|
"1", new Movie(), [], [], size, size, new FrameRate("10"), |
|
DateTimeOffset.Now, DateTimeOffset.Now, DateTimeOffset.Now, |
|
TimeSpan.FromSeconds(seek), TimeSpan.FromSeconds(duration), TimeSpan.FromSeconds(seek + duration)), |
|
CancellationToken.None); |
|
return element; |
|
} |
|
|
|
private static async Task AssertFrame(MotionElement element, double time, bool blue) |
|
{ |
|
Option<PreparedElementImage> image = await Prepare(element, time); |
|
image.IsSome.ShouldBeTrue(); |
|
foreach (PreparedElementImage frame in image) |
|
{ |
|
SKColor pixel = frame.Image.GetPixel(8, 8); |
|
(blue ? pixel.Blue : pixel.Red).ShouldBeGreaterThan((byte)200); |
|
(blue ? pixel.Red : pixel.Blue).ShouldBeLessThan((byte)30); |
|
pixel.Alpha.ShouldBe((byte)255); |
|
} |
|
} |
|
|
|
private static async Task<Option<PreparedElementImage>> Prepare(MotionElement element, double time) |
|
{ |
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); |
|
return await element.PrepareImage(TimeSpan.Zero, TimeSpan.FromSeconds(time), TimeSpan.Zero, |
|
TimeSpan.Zero, timeout.Token); |
|
} |
|
}
|
|
|