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");
}
}