354 lines
14 KiB
C#
354 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Threading;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.Services.BackgroundEffects;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
public class CameraManagerTests
|
|
{
|
|
private sealed class FakeFrameSource : ICameraFrameSource
|
|
{
|
|
private readonly List<string> _started;
|
|
private readonly List<string> _stopped;
|
|
private readonly VideoFrame? _pumpOnStart;
|
|
private readonly string? _failOnStart;
|
|
|
|
public string DeviceId { get; }
|
|
public event Action<VideoFrame>? FrameAvailable;
|
|
public event Action<string>? SourceFailed;
|
|
|
|
public FakeFrameSource(string deviceId, List<string> started, List<string> stopped,
|
|
VideoFrame? pumpOnStart = null, string? failOnStart = null)
|
|
{
|
|
DeviceId = deviceId;
|
|
_started = started;
|
|
_stopped = stopped;
|
|
_pumpOnStart = pumpOnStart;
|
|
_failOnStart = failOnStart;
|
|
}
|
|
|
|
public Task StartAsync()
|
|
{
|
|
if (_failOnStart != null) throw new InvalidOperationException(_failOnStart);
|
|
_started.Add(DeviceId);
|
|
if (_pumpOnStart != null) FrameAvailable?.Invoke(_pumpOnStart);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StopAsync()
|
|
{
|
|
_stopped.Add(DeviceId);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
|
public void Fail(string message) => SourceFailed?.Invoke(message);
|
|
}
|
|
|
|
// The refcount/coalescing tests model a camera whose frame pump is driven by
|
|
// the test after acquire, so they skip the first-frame proof (TimeSpan.Zero);
|
|
// the proof itself is exercised by the dedicated tests below.
|
|
private static CameraManager CreateManager(List<string> started, List<string> stopped)
|
|
=> new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped),
|
|
null, TimeSpan.Zero);
|
|
|
|
private sealed class FakeEnumerator : ICameraEnumerator
|
|
{
|
|
public Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
|
|
=> Task.FromResult<IReadOnlyList<CameraDeviceInfo>>(new[]
|
|
{
|
|
new CameraDeviceInfo("dev1", "Logitech C920"),
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refcount_TwoAcquires_OneCaptureUntilLastRelease()
|
|
{
|
|
var started = new List<string>();
|
|
var stopped = new List<string>();
|
|
var manager = CreateManager(started, stopped);
|
|
|
|
Assert.True(await manager.AcquireAsync("dev1"));
|
|
Assert.True(await manager.AcquireAsync("dev1"));
|
|
Assert.Single(started);
|
|
|
|
await manager.ReleaseAsync("dev1");
|
|
Assert.Empty(stopped);
|
|
|
|
await manager.ReleaseAsync("dev1");
|
|
Assert.Single(stopped);
|
|
Assert.Null(manager.GetLatestFrame("dev1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FramePump_UpdatesLatestFrame()
|
|
{
|
|
var started = new List<string>();
|
|
var stopped = new List<string>();
|
|
FakeFrameSource? captured = null;
|
|
var manager = new CameraManager(
|
|
new FakeEnumerator(),
|
|
id => captured = new FakeFrameSource(id, started, stopped),
|
|
null, TimeSpan.Zero);
|
|
|
|
await manager.AcquireAsync("dev1");
|
|
var frame = new VideoFrame(2, 2, new byte[16]);
|
|
captured!.Pump(frame);
|
|
|
|
Assert.Same(frame, manager.GetLatestFrame("dev1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Acquire_FailingCamera_ReturnsFalseAndStops()
|
|
{
|
|
var manager = new CameraManager(
|
|
new FakeEnumerator(),
|
|
id => new FakeFrameSource(id, new List<string>(), new List<string>(), failOnStart: "camera in use"),
|
|
null, TimeSpan.Zero);
|
|
string? failedDevice = null;
|
|
manager.CameraFailed += (device, _) => failedDevice = device;
|
|
|
|
Assert.False(await manager.AcquireAsync("dev1"));
|
|
Assert.Equal("dev1", failedDevice);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Acquire_EmptyDeviceId_ReturnsFalse()
|
|
{
|
|
var manager = CreateManager(new List<string>(), new List<string>());
|
|
Assert.False(await manager.AcquireAsync(" "));
|
|
}
|
|
|
|
// ─── First-frame proof (the integration test for this change) ───
|
|
|
|
[Fact]
|
|
public async Task Acquire_SilentCamera_NoFirstFrame_FailsAndSurfacesCameraFailed()
|
|
{
|
|
var stopped = new List<string>();
|
|
var manager = new CameraManager(
|
|
new FakeEnumerator(),
|
|
id => new FakeFrameSource(id, new List<string>(), stopped),
|
|
null, TimeSpan.FromMilliseconds(150));
|
|
string? failedDevice = null;
|
|
string? failedMessage = null;
|
|
manager.CameraFailed += (device, message) =>
|
|
{
|
|
failedDevice = device;
|
|
failedMessage = message;
|
|
};
|
|
|
|
// The reader "starts" fine but never delivers a frame — the exact
|
|
// silent-empty-box scenario. Must fail, be reported, and be rolled back.
|
|
Assert.False(await manager.AcquireAsync("dev1"));
|
|
Assert.Equal("dev1", failedDevice);
|
|
Assert.Contains("no frames", failedMessage);
|
|
Assert.Null(manager.GetLatestFrame("dev1"));
|
|
Assert.Contains("dev1", stopped);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Acquire_FirstFrameProvesAlive_ReturnsTrueWithoutWaitingTimeout()
|
|
{
|
|
var manager = new CameraManager(
|
|
new FakeEnumerator(),
|
|
id => new FakeFrameSource(id, new List<string>(), new List<string>(),
|
|
pumpOnStart: new VideoFrame(2, 2, new byte[16])),
|
|
null, TimeSpan.FromSeconds(10));
|
|
|
|
var started = await manager.AcquireAsync("dev1");
|
|
|
|
Assert.True(started);
|
|
Assert.NotNull(manager.GetLatestFrame("dev1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Acquire_SourceFailureAfterStart_SurfacesCameraFailedAndRollsBack()
|
|
{
|
|
var stopped = new List<string>();
|
|
FakeFrameSource? source = null;
|
|
var manager = new CameraManager(
|
|
new FakeEnumerator(),
|
|
id => source = new FakeFrameSource(id, new List<string>(), stopped,
|
|
pumpOnStart: new VideoFrame(2, 2, new byte[16])),
|
|
null, TimeSpan.Zero);
|
|
string? failedMessage = null;
|
|
manager.CameraFailed += (_, message) => failedMessage = message;
|
|
|
|
Assert.True(await manager.AcquireAsync("dev1"));
|
|
source!.Fail("capture failed (0x8007001F): device not available");
|
|
|
|
Assert.Contains("device not available", failedMessage);
|
|
Assert.Null(manager.GetLatestFrame("dev1"));
|
|
Assert.Contains("dev1", stopped);
|
|
}
|
|
|
|
private sealed class HalfMaskSegmenter : IPersonSegmenter
|
|
{
|
|
public int Calls;
|
|
public SegmentationMask? Segment(VideoFrame frame)
|
|
{
|
|
Interlocked.Increment(ref Calls);
|
|
return new SegmentationMask(2, 1, new[] { 1f, 0f }); // left person, right background
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns a FRESH mask per call — like the real segmenter, whose contract test
|
|
/// pins the raw per-frame output: {person, person, bg, bg} then {person, 0.2, bg, bg}.</summary>
|
|
private sealed class SteppingMaskSegmenter : IPersonSegmenter
|
|
{
|
|
public int Calls;
|
|
public SegmentationMask? Segment(VideoFrame _)
|
|
{
|
|
var n = Interlocked.Increment(ref Calls);
|
|
var v = n == 1 ? new[] { 1f, 1f, 0f, 0f } : new[] { 1f, 0.2f, 0f, 0f };
|
|
return new SegmentationMask(4, 1, v);
|
|
}
|
|
}
|
|
|
|
/// <summary>TASK 51 slice 4 integration test — the one processing point: with a Remove
|
|
/// variant requested, a raw camera frame reaches BOTH consumers processed — the
|
|
/// compositor's latest frame for that variant and the variant's preview bitmap on the UI
|
|
/// dispatcher — while the raw path keeps the untouched frame.</summary>
|
|
[Fact]
|
|
public async Task Requested_Remove_Variant_Reaches_Compositor_And_Preview_Raw_Stays_Raw()
|
|
{
|
|
Dispatcher? ui = null;
|
|
using var ready = new ManualResetEventSlim();
|
|
var uiThread = new Thread(() =>
|
|
{
|
|
ui = Dispatcher.CurrentDispatcher;
|
|
ready.Set();
|
|
Dispatcher.Run();
|
|
});
|
|
uiThread.SetApartmentState(ApartmentState.STA);
|
|
uiThread.IsBackground = true;
|
|
uiThread.Start();
|
|
ready.Wait();
|
|
|
|
try
|
|
{
|
|
FakeFrameSource? source = null;
|
|
var segmenter = new HalfMaskSegmenter();
|
|
var manager = new CameraManager(
|
|
new FakeEnumerator(),
|
|
id => source = new FakeFrameSource(id, new List<string>(), new List<string>()),
|
|
ui, TimeSpan.Zero, new BackgroundEffectProcessor(segmenter));
|
|
var key = WebcamEffectKey.For(BackgroundEffect.Remove, 0.5);
|
|
manager.SetRequestedEffects("dev1", new[] { key, WebcamEffectKey.None });
|
|
|
|
var previewArrived = new TaskCompletionSource<WriteableBitmap>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
manager.EffectPreviewBitmapChanged += (device, k, bitmap) =>
|
|
{
|
|
if (device == "dev1" && k == key) previewArrived.TrySetResult(bitmap);
|
|
};
|
|
|
|
Assert.True(await manager.AcquireAsync("dev1"));
|
|
const int w = 16, h = 4;
|
|
var pixels = new byte[w * h * 4];
|
|
for (var i = 0; i < pixels.Length; i += 4) { pixels[i] = 50; pixels[i + 1] = 60; pixels[i + 2] = 70; pixels[i + 3] = 255; }
|
|
var raw = new VideoFrame(w, h, pixels) { IsOpaque = true };
|
|
source!.Pump(raw);
|
|
|
|
var bitmap = await previewArrived.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
|
|
|
// Raw consumer: the very same frame, untouched.
|
|
Assert.Same(raw, manager.GetLatestFrame("dev1"));
|
|
Assert.Same(raw, manager.GetLatestFrame("dev1", WebcamEffectKey.None));
|
|
|
|
// Compositor consumer: the processed variant.
|
|
var processed = manager.GetLatestFrame("dev1", key);
|
|
Assert.NotNull(processed);
|
|
Assert.False(processed!.IsOpaque);
|
|
Assert.Equal(255, processed.BgraPixels[(1 * w + 1) * 4 + 3]); // person side
|
|
Assert.Equal(0, processed.BgraPixels[(1 * w + (w - 2)) * 4 + 3]); // background side
|
|
|
|
// Preview consumer: the variant's bitmap carries the same cut-out.
|
|
var previewPixels = new byte[w * h * 4];
|
|
var previewWidth = await ui!.InvokeAsync(() =>
|
|
{
|
|
bitmap.CopyPixels(previewPixels, w * 4, 0);
|
|
return bitmap.PixelWidth;
|
|
});
|
|
Assert.Equal(w, previewWidth);
|
|
Assert.Equal(255, previewPixels[(1 * w + 1) * 4 + 3]);
|
|
Assert.Equal(0, previewPixels[(1 * w + (w - 2)) * 4 + 3]);
|
|
Assert.Same(bitmap, manager.GetPreviewBitmap("dev1", key));
|
|
Assert.True(segmenter.Calls >= 1);
|
|
|
|
// Dropping the request stops the variant.
|
|
manager.SetRequestedEffects("dev1", new[] { WebcamEffectKey.None });
|
|
Assert.Null(manager.GetLatestFrame("dev1", key));
|
|
}
|
|
finally
|
|
{
|
|
ui?.InvokeShutdown();
|
|
}
|
|
}
|
|
|
|
/// <summary>TASK 51 jitter fix integration test — the one processing point: two consecutive
|
|
/// frames whose raw masks disagree at an edge. The first frame passes through untouched; the
|
|
/// second's blend with history (0.75/0.25) keeps a partial alpha where the raw mask would
|
|
/// have snapped to 0 — the per-frame judder the smoother damps.</summary>
|
|
[Fact]
|
|
public async Task Temporal_Smoothing_Damps_Segmentation_Jitter_Across_Frames()
|
|
{
|
|
FakeFrameSource? source = null;
|
|
var segmenter = new SteppingMaskSegmenter();
|
|
var manager = new CameraManager(
|
|
new FakeEnumerator(),
|
|
id => source = new FakeFrameSource(id, new List<string>(), new List<string>()),
|
|
null, TimeSpan.Zero, new BackgroundEffectProcessor(segmenter));
|
|
var key = WebcamEffectKey.For(BackgroundEffect.Remove, 0.5);
|
|
manager.SetRequestedEffects("dev1", new[] { key });
|
|
|
|
Assert.True(await manager.AcquireAsync("dev1"));
|
|
const int w = 16, h = 4;
|
|
var pixels = new byte[w * h * 4];
|
|
for (var i = 0; i < pixels.Length; i += 4) { pixels[i] = 50; pixels[i + 1] = 60; pixels[i + 2] = 70; pixels[i + 3] = 255; }
|
|
var frame = new VideoFrame(w, h, pixels) { IsOpaque = true };
|
|
var edge = (0 * w + 6) * 4 + 3; // row 0, x 6: the contested mask boundary
|
|
|
|
source!.Pump(frame);
|
|
var first = await PollLatestFrameAsync(manager, key, distinctFrom: null);
|
|
Assert.NotNull(first);
|
|
Assert.Equal(255, first!.BgraPixels[edge]); // maiden frame: raw mask, untouched
|
|
|
|
// A NEW instance — the worker's latest-frame-drop guard treats a re-queued
|
|
// identical reference as "no new frame", which is the real camera behavior.
|
|
source.Pump(new VideoFrame(w, h, pixels) { IsOpaque = true });
|
|
var second = await PollLatestFrameAsync(manager, key, distinctFrom: first);
|
|
Assert.NotNull(second);
|
|
Assert.Equal(2, segmenter.Calls);
|
|
Assert.False(second!.IsOpaque);
|
|
Assert.Equal(58, second.BgraPixels[edge]);
|
|
Assert.Equal(255, second.BgraPixels[(0 * w + 1) * 4 + 3]); // person core stays opaque
|
|
Assert.Equal(0, second.BgraPixels[(0 * w + 14) * 4 + 3]); // background stays cut
|
|
|
|
// The raw second mask alone would have cut the same pixel to 0 — exactly the flutter
|
|
// the smoother removes.
|
|
var rawSecond = BackgroundEffectProcessor.Render(frame,
|
|
new SegmentationMask(4, 1, new[] { 1f, 0.2f, 0f, 0f }), BackgroundEffect.Remove, 0.5);
|
|
Assert.Equal(0, rawSecond.BgraPixels[edge]);
|
|
}
|
|
|
|
private static async Task<VideoFrame?> PollLatestFrameAsync(CameraManager manager,
|
|
WebcamEffectKey key, VideoFrame? distinctFrom)
|
|
{
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
var frame = manager.GetLatestFrame("dev1", key);
|
|
if (frame != null && !ReferenceEquals(frame, distinctFrom)) return frame;
|
|
await Task.Delay(10);
|
|
}
|
|
return null;
|
|
}
|
|
}
|