using System; using System.Globalization; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; namespace ytLive.Services.Compositor; /// /// The free-tier branding flash: a "made with LlamaCasty!" credit that is COMPOSITED /// INTO THE OUTPUT (recording or stream), not merely shown in the preview. /// 2026-09-26. This used to exist only as a WPF BrandFlashLayer in /// PreviewPane.xaml — a 0.25-opacity centred TextBlock the creator could see and /// no viewer ever could. The compositor has carried an unused flashFrame slot /// since then; this type is the producer that finally feeds it. It is wired on the /// PER-FRAME path only: SceneGraph.GetBakedBase also accepts a flashFrame, /// and that one is a trap — SceneRegion.Matches compares element ids only, so a /// flash folded into the baked static base would freeze there and never expire. /// The credit is deliberately obnoxious (creator directive 2026-09-26): full-opacity /// neon, a different random position every presentation, and a hard sell every 30–60s. /// It is gated on LICENCE state, never on a UI toggle — see . /// public sealed class BrandFlashPresenter : IDisposable { /// The credit line, kept identical to the old preview TextBlock so the /// wording can never drift between the preview and the broadcast. public const string Text = "made with LlamaCasty!"; // Presentation envelope (creator-specified 2026-09-26): 500ms fade in, the credit // held at full opacity for 1000ms, then a 500ms fade out — 2000ms end to end. public const double FadeInSeconds = 0.5; public const double HoldSeconds = 1.0; public const double FadeOutSeconds = 0.5; public const double PresentationSeconds = FadeInSeconds + HoldSeconds + FadeOutSeconds; /// Cadence: rand(0, 30s) + 30s ⇒ a flash every 30–60s, so it never /// lands on a beat a viewer could tune out. public const int IntervalFloorMs = 30_000; public const int IntervalJitterMs = 30_000; /// The first flash lands shortly after go-live (the stream-start /// impression); the random cadence takes over from there. public const double FirstFlashDelaySeconds = 5.0; /// Master-space canvas. Global overlays in this codebase are authored at /// master size and scaled by the compositor. public const int MasterWidth = 1920; public const int MasterHeight = 1080; /// Neon recipe taken from the established overlay look (see commit /// message): a bright near-white CORE plus a feathered multi-radius HALO, with the /// red and blue channels pushed in OPPOSITE directions — Maxon's "Chromatic Glow" /// notes the green channel is all but invisible behind white text once R and B are /// split, which is exactly the look. private static readonly Color NeonRed = Color.FromRgb(0xe9, 0x45, 0x60); // brand red, exact private static readonly Color NeonBlue = Color.FromRgb(0x4d, 0x8b, 0xff); // brand blue #0f3460 lifted for luminance private static readonly Color CoreColor = Colors.White; /// Font size chosen so the credit spans roughly half the master width: /// big enough to read as advertising, small enough that the random placement has /// real travel room (a full-width line could only ever sit at x≈0). private const double FontSize = 120; private const double GlowPadding = 34; private const double TickSeconds = 0.033; private readonly Random _random; private readonly DispatcherTimer? _timer; private readonly object _gate = new(); private bool _enabled = true; private bool _running; private double _sinceStart; private double _nextFlashAt; // absolute time of the next flash, _sinceStart's reference private double _elapsed; // seconds into the presentation; >= PresentationSeconds means idle private VideoFrame? _raster; // tight, full-opacity glowing credit (straight alpha) private byte[]? _buffer; // reused master-space BGRA — hence the Epoch bump below private int _atX, _atY; private long _epoch; public BrandFlashPresenter(Random? random = null) { _random = random ?? new Random(); if (Application.Current is { } app) { _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(TickSeconds) }; _timer.Tick += (_, _) => Advance(TickSeconds); } } /// The licence gate, driven by MainViewModel.IsPremium (Polar /// validation) and NOT by a user-facing toggle: once this credit is composited into /// the broadcast it is the thing standing between a paying customer's stream and an /// advertisement, so it is re-checked on every read. A premium user mid-presentation /// loses the flash on the very next tick rather than at the end of the credit. public bool Enabled { get { lock (_gate) return _enabled; } set { lock (_gate) { if (_enabled == value) return; _enabled = value; if (!value) { // Licence state only ever goes premium → no flash. Cut the current // presentation short instead of running it out ungated. _elapsed = PresentationSeconds; _timer?.Stop(); } } } } /// True while a credit is on screen (drives the preview's visibility). public bool IsPresenting { get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; } } /// Begin the cadence: first flash /// after the call, then every rand(30s) + 30s. public void Start() { EnsureRaster(); lock (_gate) { _running = true; _sinceStart = 0; _nextFlashAt = FirstFlashDelaySeconds; } if (_enabled) _timer?.Start(); } public void Stop() { lock (_gate) { _running = false; _elapsed = PresentationSeconds; } _timer?.Stop(); } /// Advance the presentation clock. Also the deterministic test seam: tests /// drive this directly and never call , so no timer is running /// and the clock only moves when the test moves it (mirrors AlertOverlayLayer.Advance). public void Advance(double deltaSeconds) { // Rasterise BEFORE taking the lock. WPF text formatting is UI-thread affine, so // this can hop to the dispatcher — and a blocking hop while holding _gate would // deadlock against a timer tick that wants the same lock. EnsureRaster(); lock (_gate) { if (!_enabled) return; if (_running) { _sinceStart += deltaSeconds; if (_sinceStart >= _nextFlashAt) { // The interval runs FROM THIS FLASH, not from Start(). An absolute // deadline here made the gaps shrink every cycle (5s, then whatever // was left of the previous interval) and collapse toward zero. _nextFlashAt = _sinceStart + NextIntervalSeconds(); BeginPresentation(); } } if (_elapsed < PresentationSeconds) { _elapsed += deltaSeconds; if (_elapsed >= PresentationSeconds) _elapsed = PresentationSeconds; } } Notify(); } /// Invoked on the UI thread at the end of every tick with the frame that /// tick produced (null when nothing is due) — the preview's repaint hook, so the /// preview and the broadcast are driven by ONE clock. public Action? OnFrame { get; set; } private void Notify() { var cb = OnFrame; if (cb == null) return; // Read OUTSIDE the state lock: the callback is arbitrary code and must never // run while _gate is held. var frame = Frame; if (frame == null) { cb(null); return; } if (Application.Current is { } app && !app.Dispatcher.CheckAccess()) { // Off the UI thread (a test driving the clock). The frame's buffer is // recycled, so hand the deferred copy its own pixels. var copy = new byte[frame.BgraPixels.Length]; Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length); app.Dispatcher.BeginInvoke(() => cb(new VideoFrame(frame.Width, frame.Height, copy))); } else { cb(frame); } } /// Force a presentation now — the "show me the flash" path and the test seam. public void Show() { EnsureRaster(); lock (_gate) { if (!_enabled) return; BeginPresentation(); } } private double NextIntervalSeconds() => (IntervalFloorMs + _random.Next(IntervalJitterMs)) / 1000.0; private void BeginPresentation() { if (_raster == null) return; _buffer ??= new byte[MasterWidth * MasterHeight * 4]; // A new presentation can land anywhere, so the previous credit's pixels must go // before this one is written or the old spot ghosts through the fade. One 8MB // clear per presentation (not per tick) keeps that cheap. Array.Clear(_buffer); var spot = ComputePlacement(_raster.Width, _raster.Height, MasterWidth, MasterHeight, _random); _atX = (int)spot.X; _atY = (int)spot.Y; _elapsed = 0; } /// /// The credit for the output compositor and the preview: a master-space frame holding /// the glowing text at this presentation's random spot, alpha-scaled to the current /// point on the fade envelope. Null when idle, or unlicensed. /// The frame's pixel buffer is recycled across ticks (only the text rect /// is rewritten) and its is bumped every read, so the /// compositor's buffer-identity paste cache can never false-hit a stale credit. /// Consumers blit synchronously — copy the pixels if you need to keep them. public VideoFrame? Frame { get { lock (_gate) { if (!_enabled || _elapsed >= PresentationSeconds || _raster == null || _buffer == null) return null; var opacity = OpacityAt(_elapsed); if (opacity <= 0) return null; Paint(_buffer, _raster, _atX, _atY, opacity); return new VideoFrame(MasterWidth, MasterHeight, _buffer) { Epoch = ++_epoch }; } } } /// Full → hold → empty. Public and static so the preview and the tests /// agree with the renderer instead of re-deriving the curve. public static double OpacityAt(double elapsedSeconds) { if (elapsedSeconds < 0 || elapsedSeconds >= PresentationSeconds) return 0; if (elapsedSeconds < FadeInSeconds) return elapsedSeconds / FadeInSeconds; if (elapsedSeconds < FadeInSeconds + HoldSeconds) return 1.0; return 1.0 - ((elapsedSeconds - (FadeInSeconds + HoldSeconds)) / FadeOutSeconds); } /// /// A random top-left offset that keeps the whole credit ON the canvas. The text is a /// single pre-measured line, so it can never wrap — this is the "how far can the /// left margin travel" limit the creator described: 0 … (canvas - creditWidth) /// horizontally, likewise vertically. Pure + static, so the rule is directly testable. /// public static Point ComputePlacement(int creditWidth, int creditHeight, int canvasWidth, int canvasHeight, Random random) { var maxX = Math.Max(0, canvasWidth - creditWidth); var maxY = Math.Max(0, canvasHeight - creditHeight); return new Point(random.Next(0, maxX + 1), random.Next(0, maxY + 1)); } /// Rewrite the credit rect into the master buffer with alpha scaled to /// . The rect is fully overwritten each tick and the spot /// is fixed for the whole presentation, so nothing stale accumulates and no clear /// pass is needed here. private static void Paint(byte[] buffer, VideoFrame raster, int atX, int atY, double opacity) { for (var y = 0; y < raster.Height; y++) { var dst = ((atY + y) * MasterWidth + atX) * 4; var src = y * raster.Stride; for (var x = 0; x < raster.Width; x++) { var a = raster.BgraPixels[src + x * 4 + 3]; if (a == 0) continue; buffer[dst + x * 4] = raster.BgraPixels[src + x * 4]; buffer[dst + x * 4 + 1] = raster.BgraPixels[src + x * 4 + 1]; buffer[dst + x * 4 + 2] = raster.BgraPixels[src + x * 4 + 2]; buffer[dst + x * 4 + 3] = (byte)Math.Round(a * opacity); } } } /// Build the credit raster once, on the UI thread, and publish it under /// the lock. Called from every clock entry point, so it is a no-op after the first. private void EnsureRaster() { lock (_gate) { if (_raster != null) return; } var raster = RenderNeonText(Text); lock (_gate) { if (raster != null) { _raster = raster; } } } // ── rasterisation ────────────────────────────────────────────────────────── /// /// Rasterize the credit once, tight to the glyphs plus glow, on the UI thread (WPF /// RenderTargetBitmap, Pbgra32→straight alpha exactly like /// ) and cached — this is per-text work, not /// per-frame, so the pump can afford to run every tick at 30fps. /// The neon is a bright core plus a feathered halo: the glyphs redrawn at /// shrinking radii with rising alpha, the two colours offset in opposite directions, /// which is what makes it read as a lit tube rather than a drop shadow. private static VideoFrame? RenderNeonText(string text) { if (Application.Current is { } app && !app.Dispatcher.CheckAccess()) { VideoFrame? result = null; app.Dispatcher.Invoke(() => result = RenderNeonText(text)); return result; } var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal); var probe = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, font, FontSize, Brushes.White, 1.0); var w = (int)Math.Ceiling(probe.Width) + (int)(GlowPadding * 2); var h = (int)Math.Ceiling(probe.Height) + (int)(GlowPadding * 2); if (w <= 0 || h <= 0) return null; var visual = new DrawingVisual(); using (var dc = visual.RenderOpen()) { // Three halo rings per colour. Wide+dim first, then progressively tighter and // brighter, so the falloff is feathered instead of a hard-edged blob. foreach (var (radius, alpha) in new[] { (30.0, 0.16), (20.0, 0.26), (11.0, 0.40) }) { DrawHalo(dc, text, font, radius, alpha, NeonRed, +1); DrawHalo(dc, text, font, radius, alpha, NeonBlue, -1); } // A tight white bloom hugging the glyphs sells the brightness of the tube. DrawHalo(dc, text, font, 4.0, 0.55, CoreColor, +1); // The core itself, sharp and opaque, drawn last. dc.DrawText(new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, font, FontSize, new SolidColorBrush(CoreColor), 1.0), new Point(GlowPadding, GlowPadding)); } var bitmap = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32); bitmap.Render(visual); var pixels = new byte[w * h * 4]; bitmap.CopyPixels(pixels, w * 4, 0); // Pbgra32 is premultiplied — unpremultiply, or straight-alpha source-over in the // compositor haloes the glyph edges. for (var i = 0; i < pixels.Length; i += 4) { var a = pixels[i + 3]; if (a == 0 || a == 255) continue; pixels[i] = (byte)(pixels[i] * 255 / a); pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a); pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a); } return new VideoFrame(w, h, pixels); } private static void DrawHalo(DrawingContext dc, string text, Typeface font, double radius, double alpha, Color color, int direction) { var brush = new SolidColorBrush(color); const int spokes = 12; for (var i = 0; i < spokes; i++) { var angle = 2 * Math.PI * i / spokes; brush.Opacity = alpha; var ft = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, font, FontSize, brush, 1.0); dc.DrawText(ft, new Point( GlowPadding + Math.Cos(angle) * radius * direction, GlowPadding + Math.Sin(angle) * radius * direction)); } } public void Dispose() => _timer?.Stop(); }