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
{
bool restart;
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();
restart = false;
}
else
{
// …but a DOWNGRADE (key entered mid-session) must bring the cadence
// back. The presenter now runs for the life of the app — Start() is
// called once at startup, not per go-live — so stopping the timer on
// the premium edge without restarting it here would leave the credit
// dead until the process is restarted, even after a valid key.
restart = _running;
}
}
if (restart) _timer?.Start();
}
}
/// True while a credit is on screen (drives the preview's visibility).
public bool IsPresenting
{
get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; }
}
/// Test-only: is the that drives
/// actually running? The premium/downgrade edge is the only
/// thing that ever stops and restarts it now that the presenter is started once for
/// the life of the app, and a synchronous test body cannot observe a real timer
/// tick — so assert the decision directly instead of sleeping through a 30–60s
/// interval. Never call this from product code.
internal bool IsCadenceTimerEnabled => _timer?.IsEnabled ?? false;
/// 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();
}