feat(chroma): detect a green or blue screen and build the key filter
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).
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>What a clip's background looks like, as far as keying goes.</summary>
|
||||
public enum ChromaScreen
|
||||
{
|
||||
/// <summary>No usable backdrop — say nothing to the creator.</summary>
|
||||
None,
|
||||
Green,
|
||||
Blue,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TASK 64: "this clip was shot against a green screen". <c>Coverage</c> × <c>Tightness</c>:
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public readonly record struct ChromaKeySuggestion(
|
||||
ChromaScreen Screen,
|
||||
string? ColorHex,
|
||||
double Confidence)
|
||||
{
|
||||
/// <summary>The screen is uniform enough to key without the creator touching a slider.</summary>
|
||||
public bool IsHighConfidence => Screen != ChromaScreen.None && Confidence >= HighConfidenceFloor;
|
||||
|
||||
public const double HighConfidenceFloor = 0.85;
|
||||
public const double SuggestFloor = 0.65;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para><b>It never decides anything.</b> A suggestion is offered to the creator; auto-apply is
|
||||
/// an explicit setting and only fires at <see cref="ChromaKeySuggestion.IsHighConfidence"/>.
|
||||
/// Wrongly keying a lawn is destructive, so "None" is a normal, expected answer.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Samples an outer ring (the subject sits in the middle, the screen fills the
|
||||
/// edges) and reports what it saw.</summary>
|
||||
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<double>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 64: the key's settings for one layer, in the app's own terms. <see cref="Color"/> is the
|
||||
/// screen as the creator's camera saw it (whatever the detector measured), not a canonical green.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Creator-facing defaults when a detector suggestion is accepted: a little
|
||||
/// tolerance around the measured colour, a soft edge, and spill cleanup on.</summary>
|
||||
public static ChromaKeyOptions For(ChromaKeySuggestion suggestion)
|
||||
=> new(true, suggestion.ColorHex ?? "#00B140", 0.12, 0.08, true, suggestion.Screen);
|
||||
|
||||
/// <summary>Identifies this look, so decode sessions can be shared per look rather than
|
||||
/// duplicated per layer (two layers keyed identically reuse one decoder).</summary>
|
||||
public string Signature => Enabled
|
||||
? FormattableString.Invariant(
|
||||
$"{Color.ToUpperInvariant()}|{Similarity:0.###}|{Softness:0.###}|{Spill}|{Screen}")
|
||||
: "off";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public static class VideoFilterChain
|
||||
{
|
||||
/// <summary>The video half of a clip's `-vf`. Unkeyed clips keep the old single-scale chain.</summary>
|
||||
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<string>(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);
|
||||
}
|
||||
|
||||
/// <summary>ffmpeg takes `0xRRGGBB`. Anything unparseable falls back to the app default
|
||||
/// rather than producing a filter string ffmpeg would reject at spawn time.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<int, int, (byte R, byte G, byte B)> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user