Files
LlamaCasty/ytLive.Tests/MediaVideoSourceManagerTests.cs
T
gramps ea24405ba2 feat(chroma): offer to remove a green or blue screen, and key it in the decode
TASK 64 slices D and E: the feature is usable end to end.

- Add a video file → one frame is probed (new `IChromaProbe`/`FfmpegChromaProbe`, one ffmpeg
  pass at 160x90, off the UI thread) and the layer gets an offer: "Looks like a green screen —
  Remove it", with the measured colour as a swatch.
- Accepting it turns the offer into settings (Amount / Softness / Fringe) that reach ffmpeg as
  `colorkey` + `despill` + `format=bgra`, so the key is computed in the pipe and both consumers
  (the shared preview bitmap and the compositor's frame) carry the same alpha.
- Media sessions are now keyed by **path + look** (`MainViewModel.MediaSessionKey`), so two
  layers with the same settings still share one decoder, and changing the look releases the old
  session and starts the new one. Removal, load reconciliation, loop and audio settings all move
  to the same key.
- Settings persist on the `Source` row (six ALTER TABLE guards + SELECT/INSERT), so a keyed layer
  comes back keyed.
- Panel section is plain creator words: Green/Blue Screen, Amount, Softness, Fringe.

Test: `ChromaKeyApplyTests` — the pipe asks ffmpeg to key (and does not when off); a green-screen
clip is offered, accepting it starts a keyed session, the layer's resolver then reads that session
rather than the old one; the setting survives a save.

Docs: TASK 64 D/E ticked with slice F (auto-apply setting + alert clips) left open; frame-pipeline
records the decode-option design; HANDOFF rewritten. verify.sh: 544 passed, 0 failed, 0 warnings.
2026-11-05 08:15:32 -08:00

217 lines
7.8 KiB
C#

using System.Threading;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Xunit;
using ytLive.Services;
using ytLive.Services.Audio;
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 bool Looping { get; set; }
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioAvailable;
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 EmitAudio(AudioSample sample) => AudioAvailable?.Invoke(sample);
public void Complete() => Completed?.Invoke();
public void Dispose() { }
}
private sealed class FailingMediaSource : IMediaFrameSource
{
public string Key { get; }
public bool Looping { get; set; }
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioAvailable;
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 EmitAudio(AudioSample sample) => AudioAvailable?.Invoke(sample);
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);
}
}