Files
LlamaCasty/Services/Compositor/BrandFlashPresenter.cs
T
gramps 9761b1d4be branding credit: run in every scene and in recordings, not only while live
Creator 2026-09-26: "the made with llamacasty flash should appear in all scenes,
not just live" and "should also appear in recordings". The presenter was
Start()/Stop()-ed from UpdateLiveVisuals()'s IsLive branch, so a recording made
WITHOUT ever going live carried no credit at all -- the exact case the creator hit
while judging compositing from local recordings.

It is now Start()ed once in the MainViewModel ctor and never stopped on live-state
churn. One start covers every scene, the preview, the stream and the recording,
because go-live and local recording are the SAME FramePump: both
Streaming.Operations.cs:68 and :199 call StartAsync with the same brandFlash:
delegate. Verified by reading both call sites, not assumed -- there is no second
encoder path that needed a "redirect". The licence gate needs no live branch at
all: IsPremium's setter already pushes BrandFlashPresenter.Enabled from anywhere.

Fixed a trap that app-lifetime exposed: Enabled = false stops the presenter's
DispatcherTimer to cut the advertisement mid-credit. When Start() was per-go-live
the next go-live restarted it; with a single app-lifetime Start() nothing would,
so a key entered mid-session would leave the credit dead until the process was
restarted. The setter now restarts the timer when re-enabling while _running.

Tests (12 facts in BrandFlashOutputTests, +2):
  Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt -- drives a real
    never-live VM past the 5s first-flash delay and asks the frame the pump would
    composite. Uses a new internal AdvanceBrandFlash seam; because Advance only
    advances the cadence while the presenter is running, a credit coming out
    proves Start() happened at construction.
  ADowngradeMidSession_RestartsTheCadenceTimer -- asserts the premium/downgrade
    edge decision directly (IsCadenceTimerEnabled) instead of sleeping through a
    30-60s interval, which a synchronous test body cannot observe.

Full suite 364/364.
2026-09-27 09:33:30 -07:00

422 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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.
/// <para>2026-09-26. This used to exist only as a WPF <c>BrandFlashLayer</c> in
/// <c>PreviewPane.xaml</c> — a 0.25-opacity centred TextBlock the creator could see and
/// no viewer ever could. The compositor has carried an unused <c>flashFrame</c> slot
/// since then; this type is the producer that finally feeds it. It is wired on the
/// PER-FRAME path only: <c>SceneGraph.GetBakedBase</c> also accepts a <c>flashFrame</c>,
/// and that one is a trap — <c>SceneRegion.Matches</c> compares element ids only, so a
/// flash folded into the baked static base would freeze there and never expire.</para>
/// <para>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 <see cref="Enabled"/>.</para>
/// </summary>
public sealed class BrandFlashPresenter : IDisposable
{
/// <summary>The credit line, kept identical to the old preview TextBlock so the
/// wording can never drift between the preview and the broadcast.</summary>
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;
/// <summary>Cadence: <c>rand(0, 30s) + 30s</c> ⇒ a flash every 30–60s, so it never
/// lands on a beat a viewer could tune out.</summary>
public const int IntervalFloorMs = 30_000;
public const int IntervalJitterMs = 30_000;
/// <summary>The first flash lands shortly after go-live (the stream-start
/// impression); the random cadence takes over from there.</summary>
public const double FirstFlashDelaySeconds = 5.0;
/// <summary>Master-space canvas. Global overlays in this codebase are authored at
/// master size and scaled by the compositor.</summary>
public const int MasterWidth = 1920;
public const int MasterHeight = 1080;
/// <summary>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.</summary>
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;
/// <summary>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).</summary>
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);
}
}
/// <summary>The licence gate, driven by <c>MainViewModel.IsPremium</c> (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.</summary>
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();
}
}
/// <summary>True while a credit is on screen (drives the preview's visibility).</summary>
public bool IsPresenting
{
get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; }
}
/// <summary>Test-only: is the <see cref="DispatcherTimer"/> that drives
/// <see cref="Advance"/> 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.</summary>
internal bool IsCadenceTimerEnabled => _timer?.IsEnabled ?? false;
/// <summary>Begin the cadence: first flash <see cref="FirstFlashDelaySeconds"/>
/// after the call, then every <c>rand(30s) + 30s</c>.</summary>
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();
}
/// <summary>Advance the presentation clock. Also the deterministic test seam: tests
/// drive this directly and never call <see cref="Start"/>, so no timer is running
/// and the clock only moves when the test moves it (mirrors <c>AlertOverlayLayer.Advance</c>).</summary>
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();
}
/// <summary>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.</summary>
public Action<VideoFrame?>? 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);
}
}
/// <summary>Force a presentation now — the "show me the flash" path and the test seam.</summary>
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;
}
/// <summary>
/// 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.
/// <para><b>The frame's pixel buffer is recycled across ticks</b> (only the text rect
/// is rewritten) and its <see cref="VideoFrame.Epoch"/> 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.</para></summary>
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 };
}
}
}
/// <summary>Full → hold → empty. Public and static so the preview and the tests
/// agree with the renderer instead of re-deriving the curve.</summary>
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);
}
/// <summary>
/// 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: <c>0 … (canvas - creditWidth)</c>
/// horizontally, likewise vertically. Pure + static, so the rule is directly testable.
/// </summary>
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));
}
/// <summary>Rewrite the credit rect into the master buffer with alpha scaled to
/// <paramref name="opacity"/>. 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.</summary>
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);
}
}
}
/// <summary>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.</summary>
private void EnsureRaster()
{
lock (_gate) { if (_raster != null) return; }
var raster = RenderNeonText(Text);
lock (_gate) { if (raster != null) { _raster = raster; } }
}
// ── rasterisation ──────────────────────────────────────────────────────────
/// <summary>
/// Rasterize the credit once, tight to the glyphs plus glow, on the UI thread (WPF
/// <c>RenderTargetBitmap</c>, Pbgra32→straight alpha exactly like
/// <see cref="AlertTickerRenderer"/>) and cached — this is per-text work, not
/// per-frame, so the pump can afford to run every tick at 30fps.
/// <para>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.</para></summary>
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();
}