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); }