881addb5b4
- IMediaFrameSource gains bool Looping. - MediaVideoSource ctor takes Func<IDecodeProcess> processFactory instead of a single IDecodeProcess: a System.Diagnostics.Process can't be re-Start()ed, so each loop pass creates a fresh decoder. Decode wrapped in do-while(Looping): restart on natural EOF instead of raising Completed. - Production wiring (MainViewModel media factory): passes the process factory AND FfmpegFrameRateProbe -- closes the slice-2b gap where production had no probe and therefore no pacing. - Tests: loop test (single frame re-emits across passes, Completed only when loop cleared); fakes updated for the new interface member. Media tests 12/12, build 0 warnings. Wiring Source.MediaIsLooping into the flag needs a manager-level per-path loop provider -> lands with the UI-picker (acquisition) slice. Derivative reference: looping media by restarting decode on EOF, standard in playback/overlay tooling (OBS media source repeat).
164 lines
6.0 KiB
C#
164 lines
6.0 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using Xunit;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Encoder;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 21: the media decoder path. <see cref="RawVideoFrameReader"/> is pure
|
|
/// (synthetic bytes, no ffmpeg); the one integration test drives the real
|
|
/// <see cref="MediaVideoSource"/> source loop against a fake decoder process.
|
|
/// </summary>
|
|
public class MediaVideoSourceTests
|
|
{
|
|
// ─── Pure reader (unit) ───
|
|
|
|
[Fact]
|
|
public void Reader_EmitsOneFramePerFrameBytes()
|
|
{
|
|
var w = 2; var h = 2;
|
|
var reader = new RawVideoFrameReader(w, h);
|
|
var frameBytes = new byte[w * h * 4];
|
|
|
|
var frames = reader.Feed(frameBytes);
|
|
Assert.Single(frames);
|
|
Assert.Equal(w, frames[0].Width);
|
|
Assert.Equal(h, frames[0].Height);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reader_SplitsMultipleFramesFromOneFeed()
|
|
{
|
|
var reader = new RawVideoFrameReader(1, 1); // 4 bytes each
|
|
var frames = reader.Feed(new byte[12]); // 3 frames
|
|
Assert.Equal(3, frames.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reader_KeepsPartialBytesAcrossFeeds()
|
|
{
|
|
var reader = new RawVideoFrameReader(1, 1);
|
|
Assert.Empty(reader.Feed(new byte[2])); // half a frame
|
|
Assert.Empty(reader.Feed(new byte[1])); // 3/4
|
|
var frames = reader.Feed(new byte[1]); // completes the frame
|
|
Assert.Single(frames);
|
|
Assert.Empty(reader.Feed(new byte[0])); // no leftover
|
|
}
|
|
|
|
// ─── The one integration test: source loop against a fake decode process ───
|
|
|
|
[Fact]
|
|
public async Task MediaVideoSource_EmitsFramesInOrderFromFakeDecoder()
|
|
{
|
|
const int w = 2, h = 1; // frame bytes = 8
|
|
var frameA = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
|
var frameB = new byte[] { 9, 10, 11, 12, 13, 14, 15, 16 };
|
|
var payload = new byte[frameA.Length + frameB.Length];
|
|
Buffer.BlockCopy(frameA, 0, payload, 0, frameA.Length);
|
|
Buffer.BlockCopy(frameB, 0, payload, frameA.Length, frameB.Length);
|
|
|
|
using var source = new MediaVideoSource(
|
|
"clip.mp4", w, h,
|
|
new FakeLocator(),
|
|
() => new FakeDecodeProcess(payload));
|
|
|
|
var seen = new List<byte[]>();
|
|
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
source.FrameAvailable += f => seen.Add(((byte[])f.BgraPixels.Clone()));
|
|
source.Completed += () => completed.TrySetResult(true);
|
|
|
|
await source.StartAsync();
|
|
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Equal(2, seen.Count);
|
|
Assert.Equal(frameA, seen[0]);
|
|
Assert.Equal(frameB, seen[1]);
|
|
}
|
|
|
|
private sealed class FakeDecodeProcess : IDecodeProcess
|
|
{
|
|
private readonly MemoryStream _stream;
|
|
public FakeDecodeProcess(byte[] bytes) => _stream = new MemoryStream(bytes);
|
|
public void Start(ProcessStartInfo startInfo) { }
|
|
public Stream StandardOutput => _stream;
|
|
public bool HasExited => _stream.Position >= _stream.Length;
|
|
public int ExitCode => 0;
|
|
public void Kill() { }
|
|
public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask;
|
|
public void Dispose() => _stream.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MediaVideoSource_PacesFramesByProbedFps()
|
|
{
|
|
const int w = 2, h = 1; // frame bytes = 8
|
|
var payload = new byte[16]; // two frames
|
|
var delays = new List<TimeSpan>();
|
|
|
|
using var source = new MediaVideoSource(
|
|
"clip.mp4", w, h,
|
|
new FakeLocator(),
|
|
() => new FakeDecodeProcess(payload),
|
|
frameRateProbe: new FakeFrameRateProbe(1000.0),
|
|
delay: (_d, _) => { delays.Add(_d); return Task.CompletedTask; });
|
|
|
|
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
source.Completed += () => completed.TrySetResult(true);
|
|
|
|
await source.StartAsync();
|
|
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
// One pacing delay per emitted frame, each = 1/1000s.
|
|
Assert.Equal(2, delays.Count);
|
|
foreach (var d in delays)
|
|
Assert.True(Math.Abs((d - TimeSpan.FromMilliseconds(1)).TotalMilliseconds) < 0.001,
|
|
$"expected ~1ms, got {d.TotalMilliseconds}ms");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MediaVideoSource_LoopsUntilLoopDisabled()
|
|
{
|
|
const int w = 1, h = 1; // frame bytes = 4
|
|
var payload = new byte[4];
|
|
var passCount = 0;
|
|
var frames = 0;
|
|
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
using var source = new MediaVideoSource(
|
|
"clip.mp4", w, h,
|
|
new FakeLocator(),
|
|
() => { passCount++; return new FakeDecodeProcess(payload); });
|
|
source.Looping = true;
|
|
source.Completed += () => completed.TrySetResult(true);
|
|
source.FrameAvailable += _ =>
|
|
{
|
|
frames++;
|
|
if (frames >= 3) source.Looping = false;
|
|
};
|
|
|
|
await source.StartAsync();
|
|
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
await source.StopAsync();
|
|
|
|
// The single frame re-emits across passes (restart) and Completed only
|
|
// fires once Looping is cleared.
|
|
Assert.True(frames >= 3, $"expected the single frame to re-emit across passes, got {frames}");
|
|
Assert.True(passCount >= 3, "Looping should have restarted the decode process");
|
|
}
|
|
|
|
private sealed class FakeFrameRateProbe : IFrameRateProbe
|
|
{
|
|
private readonly double? _fps;
|
|
public FakeFrameRateProbe(double? fps) => _fps = fps;
|
|
public Task<double?> ProbeAsync(string path, CancellationToken ct = default)
|
|
=> Task.FromResult(_fps);
|
|
}
|
|
|
|
private sealed class FakeLocator : IFfmpegLocator
|
|
{
|
|
public Task<string> LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
|
|
}
|
|
}
|