TASK 4 ship step 5: live frame pipeline — FramePump paces the active scene's composite into the encoder, ScreenCaptureManager.GetLatestFrame, MainViewModel resolver/option-builder/pump wiring, FramePumpTests (7) + GetLatestFrame test — 147 tests passing, 0 warnings
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The live frame producer (TASK 4 ship step 5): composites the active scene at
|
||||
/// the tier's FPS and paces frames into the encoder. The integration test drives
|
||||
/// the full lifecycle against fakes — real SceneCompositor + real FramePump, fake
|
||||
/// IFfmpegEncoder — proving the composite frame actually reaches the encoder and
|
||||
/// that stop tears the pump down cleanly. The units pin the failure edges: the
|
||||
/// no-URL skip (the TASK 5 seam), re-entrancy, and encoder death.
|
||||
/// </summary>
|
||||
public class FramePumpTests
|
||||
{
|
||||
private sealed class FakeEncoder : IFfmpegEncoder
|
||||
{
|
||||
public readonly List<VideoFrame> Frames = new();
|
||||
public int StartCount;
|
||||
public int StopCount;
|
||||
public bool Disposed;
|
||||
public EncoderOptions? LastOptions;
|
||||
public Exception? StartError;
|
||||
public TaskCompletionSource FrameArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
public event EventHandler<string>? ProcessFailed;
|
||||
|
||||
public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
StartCount++;
|
||||
LastOptions = options;
|
||||
if (StartError != null) throw StartError;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Frames.Add(frame);
|
||||
FrameArrived.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
StopCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
|
||||
public void RaiseProcessFailed(string message) => ProcessFailed?.Invoke(this, message);
|
||||
|
||||
public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||
}
|
||||
|
||||
private static Scene BackdropScene()
|
||||
{
|
||||
var scene = new Scene { Name = "Live" };
|
||||
scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" });
|
||||
return scene;
|
||||
}
|
||||
|
||||
private static FramePump NewPump(FakeEncoder encoder, Func<EncoderOptions?>? options = null,
|
||||
Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
|
||||
List<string>? log = null)
|
||||
{
|
||||
return new FramePump(
|
||||
sceneProvider: scene ?? (() => BackdropScene()),
|
||||
frameResolver: resolve ?? (_ => null),
|
||||
compositorOptions: () => new CompositorOptions
|
||||
{
|
||||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48,
|
||||
OutputWidth = 64, OutputHeight = 48,
|
||||
},
|
||||
encoderOptions: options ?? (() => new EncoderOptions
|
||||
{
|
||||
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
|
||||
Width = 64, Height = 48, Fps = 60,
|
||||
}),
|
||||
encoderFactory: () => encoder,
|
||||
log: log != null ? m => log.Add(m) : null,
|
||||
pacingDelay: async (_, _) => await Task.Yield()); // deterministic: no real waits
|
||||
}
|
||||
|
||||
private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
|
||||
{
|
||||
var i = (y * frame.Width + x) * 4;
|
||||
var br = frame.BgraPixels[i + 2];
|
||||
var bg = frame.BgraPixels[i + 1];
|
||||
var bb = frame.BgraPixels[i];
|
||||
Assert.True(
|
||||
Math.Abs(br - r) <= 2 && Math.Abs(bg - g) <= 2 && Math.Abs(bb - b) <= 2,
|
||||
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_CompositesScene_FeedsEncoder_StopsCleanly()
|
||||
{
|
||||
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder,
|
||||
resolve: e => e is Source { IsBackdrop: true } ? red : null);
|
||||
|
||||
await pump.StartAsync();
|
||||
Assert.True(pump.IsRunning);
|
||||
Assert.Equal(1, encoder.StartCount);
|
||||
|
||||
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.NotEmpty(encoder.Frames);
|
||||
var frame = encoder.Frames[0];
|
||||
Assert.Equal(64, frame.Width);
|
||||
Assert.Equal(48, frame.Height);
|
||||
AssertColor(frame, 0, 0, 255, 0, 0); // the backdrop really was composited in
|
||||
|
||||
await pump.StopAsync();
|
||||
Assert.False(pump.IsRunning);
|
||||
Assert.Equal(1, encoder.StopCount);
|
||||
Assert.True(encoder.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_WithoutRtmpUrl_SkipsEncoder()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
var log = new List<string>();
|
||||
using var pump = NewPump(encoder, options: () => null, log: log);
|
||||
|
||||
await pump.StartAsync();
|
||||
|
||||
Assert.False(pump.IsRunning);
|
||||
Assert.Equal(0, encoder.StartCount);
|
||||
Assert.Contains(log, m => m.Contains("RTMP"));
|
||||
|
||||
await pump.StopAsync(); // no-op after a skipped start
|
||||
Assert.Equal(0, encoder.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_WhileRunning_IsNoop()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder);
|
||||
|
||||
await pump.StartAsync();
|
||||
await pump.StartAsync();
|
||||
|
||||
Assert.Equal(1, encoder.StartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_WithoutStart_IsNoop()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder);
|
||||
|
||||
await pump.StopAsync();
|
||||
|
||||
Assert.Equal(0, encoder.StopCount);
|
||||
Assert.False(pump.IsRunning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_EncoderThrows_RaisesFailed_AndDisposes()
|
||||
{
|
||||
var encoder = new FakeEncoder { StartError = new InvalidOperationException("access denied") };
|
||||
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var pump = NewPump(encoder);
|
||||
pump.Failed += (_, m) => failed.TrySetResult(m);
|
||||
|
||||
await pump.StartAsync();
|
||||
|
||||
Assert.False(pump.IsRunning);
|
||||
var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("access denied", message);
|
||||
Assert.True(encoder.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessDeath_StopsPump_AndRaisesFailed()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var pump = NewPump(encoder);
|
||||
pump.Failed += (_, m) => failed.TrySetResult(m);
|
||||
|
||||
await pump.StartAsync();
|
||||
encoder.RaiseProcessFailed("FFmpeg exited with code 1");
|
||||
|
||||
var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("1", message);
|
||||
|
||||
// The pump self-stops (fire-and-forget); poll the definitive teardown
|
||||
// marker — the encoder's disposal — rather than the IsRunning flag, which
|
||||
// StopAsync clears before the loop has fully drained.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!encoder.Disposed && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
Assert.True(encoder.Disposed);
|
||||
Assert.False(pump.IsRunning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthUpdated_ForwardsEncoderHealth()
|
||||
{
|
||||
var encoder = new FakeEncoder();
|
||||
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var pump = NewPump(encoder);
|
||||
pump.HealthUpdated += (_, h) => health.TrySetResult(h);
|
||||
|
||||
await pump.StartAsync();
|
||||
encoder.RaiseHealth(new StreamHealth { Status = StreamStatus.Streaming, FPS = 59.9 });
|
||||
|
||||
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(StreamStatus.Streaming, h.Status);
|
||||
Assert.Equal(59.9, h.FPS, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user