Files
LlamaCasty/ytLive.Tests/ChromaKeyApplyTests.cs
T
gramps 7bb1207fb2 feat(chroma): opt-in auto key, and alert clips key like any other clip
TASK 64 slice F — the two things left after the feature worked by hand.

- App Settings → **Video clips → Auto Green/Blue Screen** (off by default). When on, a
  high-confidence detected screen is keyed as soon as the clip is added; the panel says
  "Removed automatically" so an automatic change is never silent. Medium confidence still
  asks, low says nothing — a wrong automatic key eats the picture.
- **A clip is a clip:** the alert box's own replacement clip keys through the same settings
  block (one alert box per layout, so its settings are the ones the next alert uses), and
  choosing an alert clip runs the same detector. The Green/Blue Screen panel now appears for
  a video layer *or* an alert box with a custom clip (`Source.ShowsChromaKey`).
- Spill ships as the creator-facing **Fringe** switch; nothing further to tune behind it.

Test: `ChromaKeyApplyTests` gains the auto-apply case (no click, still explicit in the panel)
and an alert-clip case (its pipe asks ffmpeg for colorkey+despill with the blue screen type).

Docs: TASK 64 complete in TASKS.md, known limits recorded in the task file, HANDOFF rewritten.
verify.sh: 546 passed, 0 failed, 0 warnings.
2026-11-05 08:20:33 -08:00

285 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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.
/// </summary>
[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<string>();
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<FakeMediaSource>();
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<Source>().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 */ }
}
});
}
// ─── the opt-in setting ───
[Fact]
public void AutoGreenBlueScreen_KeysAHighConfidenceClipWithoutAsking()
{
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
var videoPath = Path.Combine(Path.GetTempPath(), "auto-keyed.mp4");
var decoders = new List<FakeMediaSource>();
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();
vm.AutoChromaScreen = true;
vm.AddMediaSource(videoPath);
var layer = vm.StagedScene!.Elements.OfType<Source>().Single(s => s.IsMediaSource);
Assert.True(layer.ChromaKeyEnabled, "the auto setting did not key a high-confidence screen");
Assert.True(layer.ChromaKeyAutoApplied, "an automatic key must say that it was automatic");
Assert.True(layer.HasAutoChromaKey);
Assert.Equal(2, decoders.Count); // plain session released, keyed one started
}
finally
{
window.Close();
MainViewModel.ChromaProbeOverride = null;
MainViewModel.MediaFrameSourceFactoryOverride = null;
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
});
}
// ─── an alert clip is a clip ───
[Fact]
public async Task AlertClip_KeyedThroughTheSameSettingsBlock()
{
var seen = new List<string>();
using var decoder = new AlertClipDecoder(
"alert-clip.mp4", 320, 180, new FakeLocator(),
() => new CapturingProcess(seen),
delay: (_, _) => Task.CompletedTask,
chromaKey: new ChromaKeyOptions(true, "#0047BB", 0.1, 0.05, true, ChromaScreen.Blue));
decoder.Start();
await Task.Delay(150);
decoder.Stop();
Assert.Contains(seen, args => args.Contains("colorkey=0x0047BB") && args.Contains("despill=type=blue"));
}
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;
}
/// <summary>A 160×90 frame: green at the edges, a face-ish block in the middle.</summary>
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<VideoFrame?> 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<VideoFrame>? FrameAvailable;
public event Action<ytLive.Services.Audio.AudioSample>? 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<string> _seen;
public CapturingProcess(List<string> 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<string> LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
}
}