9761b1d4be
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.
422 lines
19 KiB
C#
422 lines
19 KiB
C#
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();
|
||
}
|