TASK 36: composite the branding credit into the output, not just the preview
The credit existed only as a WPF BrandFlashLayer TextBlock in the preview at 25% opacity on a 300s timer. A viewer of the stream or the recording never saw it, so "free tier shows branding" was not actually being delivered. The frame pump has carried an unused per-frame flashFrame slot for exactly this. Reverses the documented "Pre-GA posture" (ai.md Monetization), which kept the flash preview-only so test VODs stayed clean. Creator ruling 2026-09-26: the free tier has to be honest advertising, so it now reaches the broadcast. One rendered VideoFrame feeds BOTH the frame pump and the preview, so the creator's view cannot drift from what viewers get. Spec: 500ms in / 1000ms hold / 500ms out, first credit ~5s after go-live then every rand(30s)+30s, single unwrapped line at a random spot inside the frame every time, neon white core with a feathered red/blue halo. Neon recipe is derivative work, per AGENTS.md: bright core + feathered multi-radius halo with R and B split in opposite directions, from https://nudaui.dev/components/neon-glow (layered text-shadow falloff), https://help.maxon.net/rg/en-us/Content/html/Blurs-and-Glows-chromatic-glow.html ("with no displacement, the green channel is all but invisible behind your white text" once R and B are split) and the OBS obs-stroke-glow-shadow "feathered stroke with user-defined size and intensity". Neon blue is #4d8bff rather than the theme's #0f3460, which renders near-black as a halo. Also fixes a PRE-EXISTING bug found on the way: FramePump's fully-static shortcut stretched the cached bake and returned it, silently discarding every per-frame overlay - the social bar and the alert ticker were already lost there. SceneCompositor.Overlay is now internal (it copies before blitting, so the bake is never mutated) and the shortcut composites overlays first. The credit is deliberately NOT folded into SceneGraph.GetBakedBase: SceneRegion.Matches compares element ids only, so a credit in the cached bake would freeze there and never expire. Per-frame only, and Epoch is stamped every read because the 8MB master buffer is recycled - without that the paste cache would freeze a stale credit. BrandFlashEnabled is now derived !IsPremium and non-assignable, and the presenter re-checks the licence on every read, so a key entered mid-credit cuts the advertisement on the next tick instead of letting it finish. Tests: 10 new facts. 357 total, 356 pass; the one failure is the known environment-flaky RealMouseDrag layer test (needs an uncovered desktop session). Adds RealAppHost.RunAsync - a frame-pump test must await inside the collection's shared STA thread or the whole suite deadlocks silently. NOTE: scope-check.sh flags Helpers/InstanceProfile.cs, AppLog.cs, TokenStore.cs, WebView2Manager.cs and InstanceIsolationTests.cs as outside this commit. That is a false positive: it uses `git diff HEAD`, which cannot distinguish a planned second commit in the same session from an unrelated edit. Those files are the dev multi-instance unit, committed immediately after this one - as is the InstanceProfile paragraph in ai.md, which shares a file with the Monetization section corrected here.
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
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
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>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();
|
||||
}
|
||||
Reference in New Issue
Block a user