Files
LlamaCasty/ytLive.Tests/ChromaKeyRealClipTests.cs
T
gramps 0f4a035376 fix(preview): draw video layers, unlink Play from Loop, geometry last
Three reports from the creator, plus two bugs the real clip exposed.

1. A video layer was INVISIBLE in the preview. The pane's per-layer Image is Collapsed by
   default and switched on by a whitelist of DataTriggers; MediaSource matched none, so the clip
   decoded into a bitmap that nothing displayed. Same defect the alert box hit on 2026-09-26 and
   the scoreboard after it — now recorded at the top of docs/ai/ui-surface.md with a test that
   asserts the bound Image is Visible (`MediaPreviewDisplayTests`).

2. Play is no longer tied to Loop: the button is always enabled (creator ruling — Loop must not
   toggle Play). Loop still restarts a finished clip, which is the behaviour that was broken.

3. Geometry is now the LAST section of the properties list for every draggable element, with
   opacity at the top; the web source's duplicate geometry block is gone (it now uses the shared
   one, which had been sitting above the media panel).

4. The physical mouse-drag test is deleted. It injected real cursor movement, hijacked whatever
   machine ran it, and failed whenever the desktop was busy. The arithmetic it covered now lives
   in `Helpers/DragReorderMath.cs` — pure, and covered by `DragReorderMathTests` (threshold,
   up/down moves, no-op and out-of-range). The two persistence tests were already synthetic.

5. Real-clip check (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080): the detector
   REJECTED it — a fully saturated green sits at value 1.0 and the detector's 0.98 brightness
   ceiling threw it away. White highlights are excluded by the saturation floor, which is the
   property that actually separates them, so the ceiling is gone. With it fixed, the clip is
   detected as green (high confidence), and keyed through our own pipe 97.8% of pixels go
   transparent. Measured: keying with the canonical #00B140 keys 0.0% of this clip — which is why
   the key colour comes from the measured screen.

Tests: 567 passed, 0 failed, clean build 0 warnings. The gate's scope check flags
`Assets/Videos/test_greenscreen_moving_subject.mp4` — the creator's working copy, deliberately not
committed; the fixture lives under ytLive.Tests/Fixtures.
2026-11-05 09:38:18 -08:00

88 lines
4.0 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.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// TASK 64 against a REAL clip (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080, 5 s, a
/// subject moving over a green field). The detector had only ever seen synthetic frames; this runs
/// the bundled ffmpeg, our probe, our detector, and our keyed decode on actual video.
/// <para>It also pins the decision that the key colour comes from the measured screen rather than a
/// canonical green — on this clip the field is #00FE00 and a hardcoded #00B140 keys <b>nothing</b>
/// (measured: 0.0% of pixels transparent), while the detected colour keys 97.8%.</para>
/// </summary>
public class ChromaKeyRealClipTests
{
private const string ClipName = "test_greenscreen_moving_subject.mp4";
private static string ClipPath => Path.Combine(AppContext.BaseDirectory, "Fixtures", ClipName);
private static string FfmpegPath => Path.Combine(AppContext.BaseDirectory, "ffmpeg", "ffmpeg.exe");
[Fact]
public async Task RealGreenScreenClip_IsDetected_AndKeysWithTheMeasuredColour()
{
Assert.True(File.Exists(ClipPath), $"missing fixture: {ClipPath}");
Assert.True(File.Exists(FfmpegPath), $"the bundled ffmpeg should sit beside the test binary: {FfmpegPath}");
// 1. The probe reads one real frame of the clip.
var probe = new FfmpegChromaProbe(new FixedLocator(FfmpegPath), () => new FfmpegDecodeProcess());
var frame = await probe.ProbeAsync(ClipPath);
Assert.NotNull(frame);
// 2. The detector says "green screen", with high confidence.
var suggestion = ChromaKeyDetector.Detect(frame!);
Assert.Equal(ChromaScreen.Green, suggestion.Screen);
Assert.True(suggestion.IsHighConfidence,
$"a clean green field should be high confidence, got {suggestion.Confidence:0.00}");
Assert.NotNull(suggestion.ColorHex);
// …and the colour it names is the field as shot: green-dominant, almost no red or blue.
var (r, g, b) = ParseHex(suggestion.ColorHex!);
Assert.True(g > 0xE0 && r < 0x20 && b < 0x20,
$"the detected colour should be the measured field, got {suggestion.ColorHex}");
// 3. That colour actually keys the clip through our own pipe: most pixels go transparent,
// the moving subject survives.
var keyed = ChromaKeyOptions.For(suggestion);
var firstFrame = new TaskCompletionSource<VideoFrame>(TaskCreationOptions.RunContinuationsAsynchronously);
using var pipe = new VideoClipPipe(
ClipPath, 320, 180, new FixedLocator(FfmpegPath), () => new FfmpegDecodeProcess(),
audio: false, chromaKey: keyed);
pipe.FrameAvailable += f => firstFrame.TrySetResult(f);
await pipe.StartAsync();
var keyedFrame = await firstFrame.Task.WaitAsync(TimeSpan.FromSeconds(30));
await pipe.StopAsync();
var transparent = 0;
for (var i = 3; i < keyedFrame.BgraPixels.Length; i += 4)
if (keyedFrame.BgraPixels[i] == 0) transparent++;
var ratio = (double)transparent / (keyedFrame.Width * keyedFrame.Height);
Assert.True(ratio > 0.5,
$"the keyed decode left only {ratio:P1} transparent — the filter chain is not keying this clip");
Assert.True(ratio < 1.0, "everything vanished; the subject should survive the key");
}
private static (int R, int G, int B) ParseHex(string hex)
{
var value = hex.TrimStart('#');
return (Convert.ToInt32(value[..2], 16), Convert.ToInt32(value.Substring(2, 2), 16),
Convert.ToInt32(value.Substring(4, 2), 16));
}
private sealed class FixedLocator : IFfmpegLocator
{
private readonly string _path;
public FixedLocator(string path) => _path = path;
public Task<string> LocateAsync(CancellationToken ct = default) => Task.FromResult(_path);
}
}