using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Models; using ytLive.Services; using ytLive.Services.Encoder; using ytLive.ViewModels; namespace ytLive.Tests; /// /// TASK 64 Good Dog (2026-11-05): a clip with a green backdrop gets offered a key, accepting the /// offer changes the DECODE (a keyed session, not the plain one), and the layer's frames come from /// that keyed session — which is the whole feature: the key lives in ffmpeg's pipe, so the preview /// and the stream cannot disagree about it. /// [Collection("RealApp")] public sealed class ChromaKeyApplyTests { private readonly RealAppHost _app; public ChromaKeyApplyTests(RealAppHost app) => _app = app; // ─── the pipe actually asks ffmpeg to key ─── [Fact] public async Task KeyedClip_AsksFfmpegToKey_UnkeyedDoesNot() { var seen = new List(); var keyed = new VideoClipPipe( "screen.mp4", 320, 180, new FakeLocator(), () => new CapturingProcess(seen), delay: (_, _) => Task.CompletedTask, audio: false, chromaKey: new ChromaKeyOptions(true, "#00B140", 0.12, 0.08, true)); var plain = new VideoClipPipe( "plain.mp4", 320, 180, new FakeLocator(), () => new CapturingProcess(seen), delay: (_, _) => Task.CompletedTask, audio: false); await keyed.StartAsync(); await Task.Delay(150); await keyed.StopAsync(); keyed.Dispose(); await plain.StartAsync(); await Task.Delay(150); await plain.StopAsync(); plain.Dispose(); Assert.Contains(seen, args => args.Contains("colorkey=0x00B140:0.12:0.08") && args.Contains("despill=type=green")); Assert.Contains(seen, args => args.Contains("scale=320:180") && !args.Contains("colorkey")); } // ─── the offer, and what accepting it does ─── [Fact] public void GreenScreenClip_IsOffered_AndAcceptingItKeysTheDecode() { _app.Run(() => { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db"); var videoPath = Path.Combine(Path.GetTempPath(), "talking-head.mp4"); var decoders = new List(); MainViewModel.LayoutPathOverride = tempDb; MainViewModel.MediaFrameSourceFactoryOverride = key => { var decoder = new FakeMediaSource(key); decoders.Add(decoder); return decoder; }; MainViewModel.ChromaProbeOverride = new FakeProbe(GreenScreenFrame()); var window = new MainWindow(); var vm = (MainViewModel)window.DataContext; try { window.Show(); window.UpdateLayout(); vm.AddMediaSource(videoPath); var layer = vm.StagedScene!.Elements.OfType().Single(s => s.IsMediaSource); // The app noticed, and said so without doing anything. Assert.True(layer.HasChromaSuggestion, "the detector's offer never reached the layer"); Assert.Contains("green", layer.ChromaSuggestionText, StringComparison.OrdinalIgnoreCase); Assert.Equal("#00B140", layer.ChromaSuggestionColor); Assert.False(layer.ChromaKeyEnabled); Assert.Single(decoders); // The plain session feeds the resolver before the offer is accepted. var plainFrame = SolidFrame(1); decoders[0].Emit(plainFrame); Assert.Same(plainFrame, vm.ResolveOutputFrame(layer)); vm.AcceptChromaSuggestionCommand.Execute(layer); Assert.True(layer.ChromaKeyEnabled); Assert.False(layer.HasChromaSuggestion); // accepted, so the offer is gone Assert.Equal("#00B140", layer.ChromaKeyColor); Assert.Equal(ChromaScreen.Green, layer.ChromaScreen); // A different look is a different decode: the plain session is released and a // keyed one starts, because the key lives in the pipe. Assert.Equal(2, decoders.Count); Assert.Equal(1, decoders[0].StopCount); // And the layer now reads the keyed session, not the old one. var keyedFrame = SolidFrame(2); decoders[1].Emit(keyedFrame); Assert.Same(keyedFrame, vm.ResolveOutputFrame(layer)); // The settings survive a save/reload — a keyed layer must come back keyed. vm.SaveLayoutNow(); Assert.True(ReadChromaEnabled(tempDb)); } finally { window.Close(); MainViewModel.ChromaProbeOverride = null; MainViewModel.MediaFrameSourceFactoryOverride = null; MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } }); } private static bool ReadChromaEnabled(string dbPath) { using var connection = new SqliteConnection($"Data Source={dbPath}"); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM Source WHERE ChromaKeyEnabled = 1;"; return Convert.ToInt32(cmd.ExecuteScalar()) > 0; } /// A 160×90 frame: green at the edges, a face-ish block in the middle. private static VideoFrame GreenScreenFrame() { const int w = 160, h = 90; var bytes = new byte[w * h * 4]; for (var y = 0; y < h; y++) for (var x = 0; x < w; x++) { var inCentre = x > w * 0.4 && x < w * 0.6 && y > h * 0.2 && y < h * 0.8; var i = (y * w + x) * 4; bytes[i] = inCentre ? (byte)0x90 : (byte)0x40; // B bytes[i + 1] = inCentre ? (byte)0xB0 : (byte)0xB1; // G bytes[i + 2] = inCentre ? (byte)0xE0 : (byte)0x00; // R bytes[i + 3] = 255; } return new VideoFrame(w, h, bytes); } private static VideoFrame SolidFrame(byte value) => new(4, 4, Enumerable.Repeat(value, 4 * 4 * 4).Select(b => b).ToArray()); private sealed class FakeProbe : IChromaProbe { private readonly VideoFrame? _frame; public FakeProbe(VideoFrame? frame) => _frame = frame; public Task ProbeAsync(string path, CancellationToken cancellationToken = default) => Task.FromResult(_frame); } private sealed class FakeMediaSource : IMediaFrameSource { public FakeMediaSource(string key) => Key = key; public string Key { get; } public bool Looping { get; set; } public int StopCount { get; private set; } public event Action? FrameAvailable; public event Action? AudioAvailable { add { } remove { } } public event Action? Completed { add { } remove { } } public void Emit(VideoFrame frame) => FrameAvailable?.Invoke(frame); public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task StopAsync() { StopCount++; return Task.CompletedTask; } public void Dispose() { } } private sealed class CapturingProcess : IDecodeProcess { private readonly List _seen; public CapturingProcess(List seen) => _seen = seen; public void Start(ProcessStartInfo startInfo) => _seen.Add(startInfo.Arguments); public Stream StandardOutput => Stream.Null; public bool HasExited => true; public int ExitCode => 0; public void Kill() { } public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask; public void Dispose() { } } private sealed class FakeLocator : IFfmpegLocator { public Task LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe"); } }