TASK 21 slice 1 step 3: IMediaFrameSource + MediaVideoSourceManager
Refactor MediaVideoSource onto IMediaFrameSource (Key/FrameAvailable/Completed/ StartAsync/StopAsync, renaming Start->StartAsync, FrameReady->FrameAvailable), behavior preserved. Add MediaVideoSourceManager: app-wide decode-session owner refcounted by MediaPath with Func<string,IMediaFrameSource?> factory seam, Acquire/Release/ReleaseAll/GetLatestFrame, coalescing each file's frames onto the UI dispatcher onto one shared WriteableBitmap; MediaFailed + PreviewBitmapChanged. Mirrors ScreenCaptureManager (screen-capture session ownership + dispatcher coalescing), per the codebase precedent and derivative-work rule. Tests: MediaVideoSourceManagerTests (5 unit + 1 integration: single shared bitmap, coalesce-to-latest); MediaVideoSourceTests updated for renames. 10/10 media tests pass, build 0 warnings. Docs (TASKS/HANDOFF/ai.md) updated.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
using System.Threading;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// MediaVideoSourceManager owns media-file decode sessions, refcounted by file
|
||||
/// path and coalesced onto the UI dispatcher — a structural mirror of
|
||||
/// ScreenCaptureManager. These tests pin that contract with a fake
|
||||
/// IMediaFrameSource (the real ffmpeg decode is exercised on Windows at runtime).
|
||||
/// </summary>
|
||||
public class MediaVideoSourceManagerTests
|
||||
{
|
||||
private sealed class FakeMediaSource : IMediaFrameSource
|
||||
{
|
||||
private readonly List<string>? _started;
|
||||
private readonly List<string>? _stopped;
|
||||
|
||||
public string Key { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action? Completed;
|
||||
|
||||
public FakeMediaSource(string key, List<string>? started = null, List<string>? stopped = null)
|
||||
{
|
||||
Key = key;
|
||||
_started = started;
|
||||
_stopped = stopped;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_started?.Add(Key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
_stopped?.Add(Key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||
public void Complete() => Completed?.Invoke();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class FailingMediaSource : IMediaFrameSource
|
||||
{
|
||||
public string Key { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action? Completed;
|
||||
public FailingMediaSource(string key) => Key = key;
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("access denied");
|
||||
public Task StopAsync() => Task.CompletedTask;
|
||||
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||
public void Complete() => Completed?.Invoke();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class DispatcherPump : IDisposable
|
||||
{
|
||||
private readonly Thread _thread;
|
||||
private readonly ManualResetEventSlim _ready = new(false);
|
||||
private Dispatcher? _dispatcher;
|
||||
|
||||
public Dispatcher Dispatcher => _dispatcher!;
|
||||
|
||||
public DispatcherPump()
|
||||
{
|
||||
_thread = new Thread(() =>
|
||||
{
|
||||
_dispatcher = Dispatcher.CurrentDispatcher;
|
||||
_ready.Set();
|
||||
Dispatcher.Run();
|
||||
});
|
||||
_thread.IsBackground = true;
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
_ready.Wait();
|
||||
}
|
||||
|
||||
public void Drain() => Dispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispatcher.InvokeShutdown();
|
||||
_thread.Join(3000);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Pixels(params byte[] raw) => raw;
|
||||
|
||||
[Fact]
|
||||
public async Task Refcount_TwoAcquires_OneDecoderUntilLastRelease()
|
||||
{
|
||||
var started = new List<string>();
|
||||
var stopped = new List<string>();
|
||||
var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key, started, stopped));
|
||||
|
||||
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
|
||||
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
|
||||
Assert.Single(started);
|
||||
|
||||
await manager.ReleaseAsync(@"C:\videos\clip.mp4");
|
||||
Assert.Empty(stopped);
|
||||
|
||||
await manager.ReleaseAsync(@"C:\videos\clip.mp4");
|
||||
Assert.Single(stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleaseAll_StopsEvenWithMultipleRefs()
|
||||
{
|
||||
var started = new List<string>();
|
||||
var stopped = new List<string>();
|
||||
var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key, started, stopped));
|
||||
|
||||
await manager.AcquireAsync(@"C:\videos\clip.mp4");
|
||||
await manager.AcquireAsync(@"C:\videos\clip.mp4");
|
||||
await manager.ReleaseAllAsync(@"C:\videos\clip.mp4");
|
||||
|
||||
Assert.Single(stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_FailingSource_ReturnsFalseAndRaisesMediaFailed()
|
||||
{
|
||||
var manager = new MediaVideoSourceManager(key => new FailingMediaSource(key));
|
||||
string? failedKey = null;
|
||||
manager.MediaFailed += (key, _) => failedKey = key;
|
||||
|
||||
Assert.False(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
|
||||
Assert.Equal(@"C:\videos\clip.mp4", failedKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_EmptyKey_ReturnsFalse()
|
||||
{
|
||||
var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key));
|
||||
Assert.False(await manager.AcquireAsync(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestFrame_ReturnsLatestPump_UntilReleased()
|
||||
{
|
||||
FakeMediaSource? captured = null;
|
||||
var manager = new MediaVideoSourceManager(key => captured = new FakeMediaSource(key));
|
||||
|
||||
Assert.Null(manager.GetLatestFrame(@"C:\videos\clip.mp4"));
|
||||
|
||||
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
|
||||
Assert.Null(manager.GetLatestFrame(@"C:\videos\clip.mp4"));
|
||||
|
||||
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
|
||||
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
|
||||
captured!.Pump(first);
|
||||
captured.Pump(second);
|
||||
|
||||
Assert.Same(second, manager.GetLatestFrame(@"C:\videos\clip.mp4"));
|
||||
|
||||
await manager.ReleaseAsync(@"C:\videos\clip.mp4");
|
||||
Assert.Null(manager.GetLatestFrame(@"C:\videos\clip.mp4"));
|
||||
}
|
||||
|
||||
// The one integration test for this branch: one file creates one shared
|
||||
// WriteableBitmap, published once, and back-to-back frames coalesce to the
|
||||
// latest (a single pending UI copy per session) — mirror of ScreenCapture.
|
||||
[Fact]
|
||||
public async Task Frames_ShareOneBitmap_AndCoalesceToLatest()
|
||||
{
|
||||
using var pump = new DispatcherPump();
|
||||
FakeMediaSource? captured = null;
|
||||
var manager = new MediaVideoSourceManager(key => captured = new FakeMediaSource(key), pump.Dispatcher);
|
||||
|
||||
WriteableBitmap? published = null;
|
||||
var publishedCount = 0;
|
||||
manager.PreviewBitmapChanged += (_, bitmap) => { published = bitmap; publishedCount++; };
|
||||
|
||||
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
|
||||
|
||||
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
|
||||
captured!.Pump(first);
|
||||
pump.Drain();
|
||||
|
||||
Assert.NotNull(published);
|
||||
Assert.Equal(1, publishedCount);
|
||||
|
||||
var dims = pump.Dispatcher.Invoke(() => new[] { published!.PixelWidth, published.PixelHeight });
|
||||
Assert.Equal(new[] { 2, 2 }, dims);
|
||||
|
||||
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
|
||||
var third = new VideoFrame(2, 2, Pixels(9, 0, 0, 255, 10, 0, 0, 255, 11, 0, 0, 255, 12, 0, 0, 255));
|
||||
captured.Pump(second);
|
||||
captured.Pump(third);
|
||||
pump.Drain();
|
||||
|
||||
var result = pump.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
published!.CopyPixels(new System.Windows.Int32Rect(0, 0, 2, 2), bytes, 8, 0);
|
||||
return bytes;
|
||||
});
|
||||
Assert.Equal(third.BgraPixels, result);
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,10 @@ public class MediaVideoSourceTests
|
||||
|
||||
var seen = new List<byte[]>();
|
||||
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
source.FrameReady += f => seen.Add(((byte[])f.BgraPixels.Clone()));
|
||||
source.FrameAvailable += f => seen.Add(((byte[])f.BgraPixels.Clone()));
|
||||
source.Completed += () => completed.TrySetResult(true);
|
||||
|
||||
source.Start();
|
||||
await source.StartAsync();
|
||||
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(2, seen.Count);
|
||||
|
||||
Reference in New Issue
Block a user