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).
76 lines
3.4 KiB
C#
76 lines
3.4 KiB
C#
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);
|
|
}
|