Files
LlamaCasty/ytLive.Tests/ChromaKeyApplyTests.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

220 lines
8.3 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 */ }
}
});
}
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");
}
}