From 21db8a17b8d4402b39a98063ba87a7449d9f5545 Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 5 Nov 2026 08:05:18 -0800 Subject: [PATCH] feat(chroma): detect a green or blue screen and build the key filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK 64 units B and C — the two pure halves, no wiring yet. - `Services/ChromaKeyDetector.cs`: samples the frame's outer ring (the screen is at the edges, the subject in the middle), reports Green/Blue/None plus the colour actually shot and a confidence. Confidence is coverage × hue tightness, which is what keeps a lawn or a football pitch from being offered as a keyable screen. Letterbox bars are excluded from the denominator rather than counted against the screen. Never decides anything: an offer is the creator's to accept. - `Services/VideoFilterChain.cs`: options → `-vf` (scale → colorkey → despill → format=bgra) plus a `Signature` so two layers keyed identically can share one decoder. Numbers clamp and an unparseable colour falls back to the app default, so ffmpeg is never handed a filter it rejects at spawn. - Keying is done by the bundled ffmpeg, not by us: verified against the shipped binary that `colorkey=…,format=bgra` emits straight-alpha BGRA (alpha 0 on keyed pixels), which is what the compositor already blends. No per-pixel loop lands in the 16.67 ms render budget. Test: `ChromaKeyDetectorTests` — green, blue, letterboxed, lawn (must say None), mixed scene, dark frame, plus the chain strings and signature sharing. Docs: TASK 64 queued in TASKS.md with its slice table; the decisions.md chroma-key reversal recorded as a reversal (it was buried for webcam background removal, not for supplied clips). --- Services/ChromaKeyDetector.cs | 155 ++++++++++++++++++++++++ Services/VideoFilterChain.cs | 75 ++++++++++++ TASKS.md | 3 +- TASKS/task-64-green-blue-screen.md | 63 ++++++++++ docs/ai/decisions.md | 5 + ytLive.Tests/ChromaKeyDetectorTests.cs | 157 +++++++++++++++++++++++++ 6 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 Services/ChromaKeyDetector.cs create mode 100644 Services/VideoFilterChain.cs create mode 100644 TASKS/task-64-green-blue-screen.md create mode 100644 ytLive.Tests/ChromaKeyDetectorTests.cs diff --git a/Services/ChromaKeyDetector.cs b/Services/ChromaKeyDetector.cs new file mode 100644 index 0000000..21358f0 --- /dev/null +++ b/Services/ChromaKeyDetector.cs @@ -0,0 +1,155 @@ +namespace ytLive.Services; + +/// What a clip's background looks like, as far as keying goes. +public enum ChromaScreen +{ + /// No usable backdrop — say nothing to the creator. + None, + Green, + Blue, +} + +/// +/// TASK 64: "this clip was shot against a green screen". Coverage × Tightness: +/// how much of the frame is the colour, and how consistent that colour is. A painted screen +/// scores near 1; a lawn or a football pitch scores low because the greens disagree. +/// +public readonly record struct ChromaKeySuggestion( + ChromaScreen Screen, + string? ColorHex, + double Confidence) +{ + /// The screen is uniform enough to key without the creator touching a slider. + public bool IsHighConfidence => Screen != ChromaScreen.None && Confidence >= HighConfidenceFloor; + + public const double HighConfidenceFloor = 0.85; + public const double SuggestFloor = 0.65; +} + +/// +/// TASK 64: finds a green or blue screen in a decoded frame — the "clueless creator" half of +/// the feature. Pure and allocation-light (it only samples an outer ring and a coarse grid), so +/// it runs on the probe frame without touching ffmpeg twice. +/// It never decides anything. A suggestion is offered to the creator; auto-apply is +/// an explicit setting and only fires at . +/// Wrongly keying a lawn is destructive, so "None" is a normal, expected answer. +/// +public static class ChromaKeyDetector +{ + // Hue windows in degrees. Green sits 75–165 (a real screen reads ~120–145), blue 185–265. + private const double GreenHueFrom = 75, GreenHueTo = 165; + private const double BlueHueFrom = 185, BlueHueTo = 265; + + // A screen is saturated and not dark. These also exclude letterbox bars (near-black, + // no saturation) and blown-out highlights from the denominator, so black bars do not + // count against an otherwise perfect screen. + private const double MinSaturation = 0.35; + private const double MinValue = 0.15; + private const double MaxValue = 0.98; + + // The screen's own pixels must agree with each other: a painted surface is flat, a lawn + // is not. This is what keeps "green field" from being keyed. + private const double HueTightness = 12.0; + + /// Samples an outer ring (the subject sits in the middle, the screen fills the + /// edges) and reports what it saw. + public static ChromaKeySuggestion Detect(VideoFrame frame) + { + if (frame.Width < 8 || frame.Height < 8) return Empty; + + var ring = Math.Max(2, (int)Math.Round(Math.Min(frame.Width, frame.Height) * 0.15)); + var step = Math.Max(1, Math.Min(frame.Width, frame.Height) / 96); + + var hues = new List(); + var sameHue = 0; + double hueSum = 0; + long rSum = 0, gSum = 0, bSum = 0; + var considered = 0; + var green = 0; + var blue = 0; + + for (var y = 0; y < frame.Height; y += step) + { + var inVerticalRing = y < ring || y >= frame.Height - ring; + for (var x = 0; x < frame.Width; x += step) + { + if (!inVerticalRing && x >= ring && x < frame.Width - ring) continue; + + var i = y * frame.Stride + x * 4; + var b = frame.BgraPixels[i]; + var g = frame.BgraPixels[i + 1]; + var r = frame.BgraPixels[i + 2]; + if (frame.BgraPixels[i + 3] < 8) continue; // transparent: not the backdrop + + var (hue, saturation, value) = ToHsv(r, g, b); + if (saturation < MinSaturation || value < MinValue || value > MaxValue) continue; + + considered++; + if (hue is >= GreenHueFrom and <= GreenHueTo) green++; + else if (hue is >= BlueHueFrom and <= BlueHueTo) blue++; + else continue; + + hues.Add(hue); + hueSum += hue; + rSum += r; + gSum += g; + bSum += b; + } + } + + if (considered < 64) return Empty; + + var screen = green >= blue + ? (green == 0 ? ChromaScreen.None : ChromaScreen.Green) + : ChromaScreen.Blue; + if (screen == ChromaScreen.None) return Empty; + + var accepted = hues.Count; + var coverage = (double)accepted / considered; + + // Tint stability: how many of the accepted pixels sit within ±HueTightness of the + // median hue. A painted screen ≈ 1.0; grass, foliage and sunlight ≈ far less. + hues.Sort(); + var median = hues[hues.Count / 2]; + foreach (var hue in hues) + { + var delta = Math.Abs(hue - median); + if (delta > 180) delta = 360 - delta; + if (delta <= HueTightness) sameHue++; + } + var tightness = (double)sameHue / accepted; + + var confidence = coverage * tightness; + if (confidence < ChromaKeySuggestion.SuggestFloor) return Empty; + + // The keyed colour is the screen as it was actually shot, not a canonical green: + // similarity is a distance, so the median of the sampled pixels keys far better. + var keyed = Math.Max(1, accepted); + var color = $"#{rSum / keyed:X2}{gSum / keyed:X2}{bSum / keyed:X2}"; + return new ChromaKeySuggestion(screen, color, confidence); + } + + private static readonly ChromaKeySuggestion Empty = new(ChromaScreen.None, null, 0); + + private static (double Hue, double Saturation, double Value) ToHsv(byte r, byte g, byte b) + { + var rf = r / 255.0; + var gf = g / 255.0; + var bf = b / 255.0; + var max = Math.Max(rf, Math.Max(gf, bf)); + var min = Math.Min(rf, Math.Min(gf, bf)); + var delta = max - min; + + double hue = 0; + if (delta > 1e-6) + { + if (max == rf) hue = 60 * (((gf - bf) / delta) % 6); + else if (max == gf) hue = 60 * (((bf - rf) / delta) + 2); + else hue = 60 * (((rf - gf) / delta) + 4); + } + if (hue < 0) hue += 360; + + var saturation = max <= 0 ? 0 : delta / max; + return (hue, saturation, max); + } +} diff --git a/Services/VideoFilterChain.cs b/Services/VideoFilterChain.cs new file mode 100644 index 0000000..3041280 --- /dev/null +++ b/Services/VideoFilterChain.cs @@ -0,0 +1,75 @@ +using System.Globalization; + +namespace ytLive.Services; + +/// +/// TASK 64: the key's settings for one layer, in the app's own terms. is the +/// screen as the creator's camera saw it (whatever the detector measured), not a canonical green. +/// +public sealed record ChromaKeyOptions( + bool Enabled, + string Color, + double Similarity, + double Softness, + bool Spill, + ChromaScreen Screen = ChromaScreen.Green) +{ + public static readonly ChromaKeyOptions Off = new(false, "#00B140", 0.12, 0.08, true); + + /// Creator-facing defaults when a detector suggestion is accepted: a little + /// tolerance around the measured colour, a soft edge, and spill cleanup on. + public static ChromaKeyOptions For(ChromaKeySuggestion suggestion) + => new(true, suggestion.ColorHex ?? "#00B140", 0.12, 0.08, true, suggestion.Screen); + + /// Identifies this look, so decode sessions can be shared per look rather than + /// duplicated per layer (two layers keyed identically reuse one decoder). + public string Signature => Enabled + ? FormattableString.Invariant( + $"{Color.ToUpperInvariant()}|{Similarity:0.###}|{Softness:0.###}|{Spill}|{Screen}") + : "off"; +} + +/// +/// TASK 64: builds the ffmpeg filter chain for a clip. Keying happens IN THE DECODE PIPE, not in +/// this process: the bundled ffmpeg keys with slices of its own CPU cost, emits straight-alpha +/// BGRA (verified against the shipped binary: keyed pixels come out with alpha 0), and — the +/// point — the pipe is upstream of BOTH consumers (the shared preview bitmap and the compositor's +/// frame), so the creator's preview and the stream cannot disagree. +/// Order matters: scale first (cheaper, and the edge is computed at output size), then key, +/// then despill, then force BGRA so the alpha survives the pipe. +/// +public static class VideoFilterChain +{ + /// The video half of a clip's `-vf`. Unkeyed clips keep the old single-scale chain. + public static string BuildVideo(int width, int height, ChromaKeyOptions? key = null) + { + var scale = $"scale={width}:{height}"; + if (key is not { Enabled: true }) return scale; + + var parts = new List(4) + { + scale, + $"colorkey={NormalizeColor(key.Color)}:{Number(key.Similarity)}:{Number(key.Softness)}", + }; + if (key.Spill) + parts.Add($"despill=type={(key.Screen == ChromaScreen.Blue ? "blue" : "green")}"); + parts.Add("format=bgra"); + return string.Join(",", parts); + } + + /// ffmpeg takes `0xRRGGBB`. Anything unparseable falls back to the app default + /// rather than producing a filter string ffmpeg would reject at spawn time. + private static string NormalizeColor(string? hex) + { + var value = (hex ?? string.Empty).Trim(); + if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) value = value[2..]; + if (value.StartsWith('#')) value = value[1..]; + if (value.Length != 6) return "0x00B140"; + foreach (var c in value) + if (!Uri.IsHexDigit(c)) return "0x00B140"; + return "0x" + value.ToUpperInvariant(); + } + + private static string Number(double value) + => Math.Clamp(value, 0, 1).ToString("0.###", CultureInfo.InvariantCulture); +} diff --git a/TASKS.md b/TASKS.md index f6de53e..573d143 100644 --- a/TASKS.md +++ b/TASKS.md @@ -42,7 +42,8 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block). | 53 | ✅ | Output-format contract — six size/FPS modes, vertical YouTube playback, and recordings visually accepted | [`task-53`](TASKS/task-53-output-formats.md) | | — | ✅ | Output entitlement code: free at 30 fps / paid at 60 fps regardless of resolution; Steam App ID is still pending | [`commercial`](docs/ai/commercial.md) | | 3·17 | ✅ | **Text layer** — content, font family/size/weight/style, colour, alignment, drag/resize, persisted (done 2026-11-04) | [`task-03`](TASKS/task-03-capture-pipeline.md) | -| 21 | ✅ | Media source: video file added from the (+) menu decodes, composites, loops, and stops with its last reference (done 2026-11-05) | [`task-21`](TASKS/task-21-media-source.md) | +| 21 | ✅ | Media source: video file added from the (+) menu decodes, composites, plays its soundtrack, loops, and stops with its last reference (done 2026-11-05) | [`task-21`](TASKS/task-21-media-source.md) | +| 64 | 🔶 **NEXT** | **Green/Blue Screen** — detect a green/blue backdrop in a clip and key it out through the decode pipe; suggest-only, opt-in auto-apply, alert clips included | [`task-64`](TASKS/task-64-green-blue-screen.md) | | — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) | | 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) | | 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) | diff --git a/TASKS/task-64-green-blue-screen.md b/TASKS/task-64-green-blue-screen.md new file mode 100644 index 0000000..d91b444 --- /dev/null +++ b/TASKS/task-64-green-blue-screen.md @@ -0,0 +1,63 @@ +# TASK 64 — Green/blue screen (chroma) for video clips + +> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here. +> Queued 2026-11-05 at the creator's direction, straight after TASK 21's audio slice. + +**Goal:** a creator who drops in a clip shot against a green or blue screen gets the background +removed — without knowing what "chroma key", "similarity" or "spill" mean. The app *notices* the +screen and offers to remove it; the creator never has to pick a colour. + +**Why this is not the retired "colour-key" item:** `docs/ai/decisions.md` buried chroma key in the +webcam background-removal context (ML model, lighting, NVIDIA gives it free — support hell). This is a +different job: the creator supplies the clip, ffmpeg does the arithmetic, there is no model and no +lighting to blame. The reversal is recorded there. + +--- + +## 0. What already exists (verified 2026-11-05, bundled ffmpeg N-126342) + +- `colorkey` / `chromakey` (colour, `similarity`, `blend`) and `despill` (`green`/`blue`, `mix`) are all + present in the shipped binary and slice-threaded. +- A probe of the bundled binary showed keyed pixels come out of the pipe with **alpha 0** and straight + (un-premultiplied) colour — `colorkey=…,format=bgra` → `63 175 0 0` vs `63 175 0 255` unkeyed. The + compositor already blends straight alpha, and media frames are not flagged `IsOpaque`, so no + compositor change is needed. +- Consequence: keying costs **zero app-side CPU** — it is one more term in the decode pipe's `-vf`, + which is also the only place that feeds **both** consumers (the shared preview bitmap and the + compositor's `LatestFrame`). A C# post-process would have to be wired twice. + +## 1. Decisions (creator, 2026-11-05) + +- **Suggest, never override.** Detection offers; the creator clicks. Opt-in setting for auto-apply. +- **Green and blue.** The keyed colour is the *detected* screen colour (real screens read 0x00B140, + not 0x00FF00), not a canonical green. +- **"Auto Green/Blue Screen"** is the setting name. No engineering words in the UI (never "chroma", + never "colour key"): the panel section is **Green/Blue Screen**, the sliders are **Amount**, + **Softness**, **Fringe**. +- **A clip is a clip** — alert clips get the same treatment from the same settings block. +- **High confidence only** may auto-apply; medium shows the offer; low says nothing. +- Settings are **per layer** (they belong to the layer in the UI). Sessions are keyed by + **path + settings signature**, so two layers with identical settings still share one decoder. + +## 2. Slices (one Good Dog each) + +| # | Slice | Test | +|---|---|---| +| A | ✅ media audio + shared clip pipe (TASK 21) | shipped `2670ac0` | +| B | ✅ Pure `Services/ChromaKeyDetector.cs`: sample a `VideoFrame` → green / blue / none + suggested colour + confidence | synthetic frames: green, blue, gradient, letterboxed, lawn (must say none) | +| C | ✅ Pure `Services/VideoFilterChain.cs`: options → `-vf` string (`scale → colorkey → despill → format=bgra`) | exact string assertions (encoder `FfmpegArgs` style) | +| D | Detect on add: one `-frames:v 1` probe (mirror `IFrameRateProbe`) → suggestion in the panel; no rendering change | add a green-screen file → suggestion appears with its colour | +| E | Apply: session key `path|key-settings`, filter chain in the pipe, level/mute untouched | keyed layer over art shows the art through keyed pixels **and** the preview bitmap carries alpha | +| F | Alert clips (falls out of E), tuning sliders, "Auto Green/Blue Screen" setting (App Settings), spill | slider reaches the chain and the restart preserves loop state | + +## 3. Landmines for whoever builds this + +- **Two consumers.** The preview bitmap and `LatestFrame` fork inside `MediaVideoSourceManager`; keying + must happen upstream (in the pipe), or one path shows green. +- **Parameter change = decoder restart** (`-vf` is fixed at spawn). Debounce, and carry loop + refcount + across the restart instead of tearing the session down. +- **Session identity.** `ResolveOutputFrame` and the preview-bitmap adoption must derive the session key + from the same helper as the panel, or a keyed and an unkeyed layer on one file fight over one decoder. +- **Detection is a second reader of the file** — one frame, async, never on the UI thread; a failed + probe means "no suggestion", never a broken layer. +- **Letterboxing** (black bars) breaks naive border sampling — detect and exclude them first. diff --git a/docs/ai/decisions.md b/docs/ai/decisions.md index abfab21..c7e8a0a 100644 --- a/docs/ai/decisions.md +++ b/docs/ai/decisions.md @@ -204,6 +204,11 @@ Do not build these; each was explicitly ruled out. - Per-source audio sync offset — global offset shipped (TASK 22); per-source is the OBS mixer rabbit hole and breaks the one-knob audio rule. - Chroma key / NVIDIA-only background removal (the bundled ONNX path is the one shipped). + **Partly reversed 2026-11-05 (TASK 64):** a *video clip* may be keyed — green or blue, detected + from the clip, performed by the bundled ffmpeg `colorkey`/`despill` in the decode pipe, offered + never forced. The original objection was webcam background removal (ML model, lighting, support + weight); none of it applies to a clip the creator supplied. The webcam still gets the shipped ONNX + path, and NVIDIA-only effects stay out. - Virtual camera output — a different product and a device-driver swamp; our output is one YouTube stream. - Replay buffer, stream clipping, multi-destination restreaming, an in-app bug reporter, and an diff --git a/ytLive.Tests/ChromaKeyDetectorTests.cs b/ytLive.Tests/ChromaKeyDetectorTests.cs new file mode 100644 index 0000000..0173816 --- /dev/null +++ b/ytLive.Tests/ChromaKeyDetectorTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using Xunit; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 64 unit tests (pure): does the detector recognise a real green/blue screen, and does it +/// keep its mouth shut about things that merely contain green? Plus the decoder filter chain the +/// key turns into — asserted as a string, the same way the encoder's ffmpeg args are pinned. +/// +public class ChromaKeyDetectorTests +{ + private const byte GreenR = 0x00, GreenG = 0xB1, GreenB = 0x40; // #00B140 — a real screen + private const byte BlueR = 0x00, BlueG = 0x47, BlueB = 0xBB; // #0047BB + + [Fact] + public void GreenScreen_IsDetected_WithTheColourActuallyShot() + { + var frame = Build(120, 68, (x, y) => InCentre(x, y, 120, 68) ? Skin() : (GreenR, GreenG, GreenB)); + + var suggestion = ChromaKeyDetector.Detect(frame); + + Assert.Equal(ChromaScreen.Green, suggestion.Screen); + Assert.True(suggestion.IsHighConfidence, $"confidence {suggestion.Confidence:0.00} should be high"); + Assert.Equal("#00B140", suggestion.ColorHex); + } + + [Fact] + public void BlueScreen_IsDetected() + { + var frame = Build(120, 68, (x, y) => InCentre(x, y, 120, 68) ? Skin() : (BlueR, BlueG, BlueB)); + + var suggestion = ChromaKeyDetector.Detect(frame); + + Assert.Equal(ChromaScreen.Blue, suggestion.Screen); + Assert.True(suggestion.IsHighConfidence); + } + + [Fact] + public void LetterboxedScreen_IsStillDetected() + { + // The clip is wider than the screen: black bars top and bottom. Bars must not count + // against the screen, or every letterboxed clip reads as "no backdrop". + var frame = Build(120, 68, (x, y) => + y < 12 || y >= 56 ? ((byte)0, (byte)0, (byte)0) : (GreenR, GreenG, GreenB)); + + var suggestion = ChromaKeyDetector.Detect(frame); + + Assert.Equal(ChromaScreen.Green, suggestion.Screen); + Assert.True(suggestion.Confidence >= ChromaKeySuggestion.SuggestFloor, + $"letterboxing dropped confidence to {suggestion.Confidence:0.00}"); + } + + [Fact] + public void LawnOrPitch_SaysNothing() + { + // Green, but the greens disagree with each other (hue 100 vs 145 alternating). Keying + // this would eat the picture, so "None" is the correct answer. + var frame = Build(120, 68, (x, y) => (x / 4 + y / 4) % 2 == 0 + ? ((byte)20, (byte)150, (byte)30) // hue ≈ 125, dark-ish grass + : ((byte)80, (byte)180, (byte)10)); // hue ≈ 95, sunlit grass + + var suggestion = ChromaKeyDetector.Detect(frame); + + Assert.Equal(ChromaScreen.None, suggestion.Screen); + } + + [Fact] + public void MixedScene_SaysNothing() + { + var frame = Build(120, 68, (x, y) => ((byte)(x % 255), (byte)(y % 255), (byte)((x + y) % 255))); + + var suggestion = ChromaKeyDetector.Detect(frame); + + Assert.Equal(ChromaScreen.None, suggestion.Screen); + } + + [Fact] + public void DarkFrames_AreNotAScreen() + { + var frame = Build(120, 68, (x, y) => ((byte)0, (byte)(y % 20), (byte)0)); + + Assert.Equal(ChromaScreen.None, ChromaKeyDetector.Detect(frame).Screen); + } + + // ─── the filter chain the key turns into ─── + + [Fact] + public void UnkeyedClip_KeepsThePlainScaleChain() + => Assert.Equal("scale=1920:1080", VideoFilterChain.BuildVideo(1920, 1080, ChromaKeyOptions.Off)); + + [Fact] + public void KeyedClip_ScalesThenKeysThenDespillsThenForcesBgra() + { + var options = new ChromaKeyOptions(true, "#00B140", 0.12, 0.08, Spill: true); + + Assert.Equal( + "scale=1920:1080,colorkey=0x00B140:0.12:0.08,despill=type=green,format=bgra", + VideoFilterChain.BuildVideo(1920, 1080, options)); + } + + [Fact] + public void BlueScreen_DespillsBlue() + { + var options = new ChromaKeyOptions(true, "#0047BB", 0.1, 0.05, Spill: true, ChromaScreen.Blue); + + Assert.Contains("despill=type=blue", VideoFilterChain.BuildVideo(1920, 1080, options)); + } + + [Fact] + public void SpillOff_AndBadColour_StillProduceAValidChain() + { + var noSpill = new ChromaKeyOptions(true, "not-a-colour", 2.0, -1, Spill: false); + var chain = VideoFilterChain.BuildVideo(1280, 720, noSpill); + + // Out-of-range numbers clamp, an unparseable colour falls back to the app default, and + // no despill term is appended — ffmpeg must never be handed a filter it will reject. + Assert.Equal("scale=1280:720,colorkey=0x00B140:1:0,format=bgra", chain); + } + + [Fact] + public void Signature_IsTheSameForIdenticalLooks_AndDiffersOtherwise() + { + var a = new ChromaKeyOptions(true, "#00b140", 0.12, 0.08, true); + var b = new ChromaKeyOptions(true, "#00B140", 0.12, 0.08, true); + var c = new ChromaKeyOptions(true, "#00B140", 0.30, 0.08, true); + + Assert.Equal(a.Signature, b.Signature); // shared decoder for identical looks + Assert.NotEqual(a.Signature, c.Signature); + Assert.Equal("off", ChromaKeyOptions.Off.Signature); + } + + // ─── helpers ─── + + private static bool InCentre(int x, int y, int w, int h) + => x > w * 0.35 && x < w * 0.65 && y > h * 0.25 && y < h * 0.75; + + private static (byte R, byte G, byte B) Skin() => (0xE0, 0xB0, 0x90); + + private static VideoFrame Build(int width, int height, Func pixel) + { + var bytes = new byte[width * height * 4]; + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + { + var (r, g, b) = pixel(x, y); + var i = (y * width + x) * 4; + bytes[i] = b; + bytes[i + 1] = g; + bytes[i + 2] = r; + bytes[i + 3] = 255; + } + return new VideoFrame(width, height, bytes); + } +}