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:
@@ -30,6 +30,7 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly Func<VideoFrame?>? _alertTicker;
|
||||
private readonly Func<VideoFrame?>? _brandFlash;
|
||||
private readonly TransitionService? _transition;
|
||||
private readonly SceneGraph? _sceneGraph;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
@@ -131,6 +132,7 @@ public sealed class FramePump : IDisposable
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
|
||||
Func<VideoFrame?>? alertTicker = null,
|
||||
Func<VideoFrame?>? brandFlash = null,
|
||||
TransitionService? transition = null,
|
||||
SceneGraph? sceneGraph = null)
|
||||
{
|
||||
@@ -143,6 +145,7 @@ public sealed class FramePump : IDisposable
|
||||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||
_socialBar = socialBar;
|
||||
_alertTicker = alertTicker;
|
||||
_brandFlash = brandFlash;
|
||||
_transition = transition;
|
||||
_sceneGraph = sceneGraph;
|
||||
}
|
||||
@@ -413,6 +416,11 @@ public sealed class FramePump : IDisposable
|
||||
// each tick and let the compositor pin it to the very top.
|
||||
var tickerFrame = _alertTicker?.Invoke();
|
||||
|
||||
// The free-tier branding credit. Read per-frame for the same reason
|
||||
// as the ticker: it fades and sits on a fresh random spot, so it can
|
||||
// never live in the baked base or the blit-on-change cache.
|
||||
var flashFrame = _brandFlash?.Invoke();
|
||||
|
||||
VideoFrame frame;
|
||||
var scratchSize = compositorOptions.SourceRectWidth
|
||||
* compositorOptions.SourceRectHeight * 4;
|
||||
@@ -423,7 +431,7 @@ public sealed class FramePump : IDisposable
|
||||
// pooling change because its buffer's only consumer was its own release.
|
||||
renderSw.Restart();
|
||||
var scratch = AcquireScratch(scratchSize);
|
||||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, scratch, TimedResolver);
|
||||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver);
|
||||
if (_transition is { Active: true } transition)
|
||||
{
|
||||
frame = transition.BlendFrame(frame);
|
||||
@@ -564,6 +572,7 @@ public sealed class FramePump : IDisposable
|
||||
VideoFrame? socialBarFrame,
|
||||
int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
VideoFrame? flashFrame,
|
||||
byte[]? scratch = null,
|
||||
Func<SceneElement, VideoFrame?>? resolver = null)
|
||||
{
|
||||
@@ -571,7 +580,7 @@ public sealed class FramePump : IDisposable
|
||||
if (_sceneGraph == null)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, scratch, resolver);
|
||||
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
|
||||
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
|
||||
return frame;
|
||||
}
|
||||
@@ -584,6 +593,18 @@ public sealed class FramePump : IDisposable
|
||||
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
|
||||
if (baked != null)
|
||||
{
|
||||
// BUG (pre-existing, found while wiring the branding credit 2026-09-26):
|
||||
// this path returned the stretched bake and silently DROPPED the social
|
||||
// bar and the alert ticker. Overlay() copies before blitting, so the
|
||||
// cached bake is not mutated and the flash still cannot get sticky in it.
|
||||
if (socialBarFrame != null || tickerFrame != null || flashFrame != null)
|
||||
{
|
||||
var withOverlays = baked;
|
||||
if (flashFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, flashFrame, 0, 0);
|
||||
if (tickerFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, tickerFrame, 0, 0);
|
||||
if (socialBarFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, socialBarFrame, socialBarTop, 0);
|
||||
baked = withOverlays;
|
||||
}
|
||||
var stretched = StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
|
||||
ProbeRender("fully-static", scene, split, sw.ElapsedMilliseconds, (int)sw.ElapsedMilliseconds);
|
||||
return stretched;
|
||||
@@ -595,14 +616,14 @@ public sealed class FramePump : IDisposable
|
||||
if (baseFrame != null)
|
||||
{
|
||||
var frame = SceneCompositor.CompositeLayers(
|
||||
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, tickerFrame: tickerFrame, scratch: scratch);
|
||||
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch);
|
||||
ProbeRender("base+layers", scene, split, swBase.ElapsedMilliseconds, 0);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// No static base (first layer is dynamic or empty scene) — full render.
|
||||
var swFull = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, scratch, resolver);
|
||||
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
|
||||
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
|
||||
return frame2;
|
||||
}
|
||||
@@ -614,7 +635,7 @@ public sealed class FramePump : IDisposable
|
||||
/// hit costs ~3ms of pure copy and a miss costs exactly the old full render.</summary>
|
||||
private VideoFrame RenderFull(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
VideoFrame? tickerFrame, VideoFrame? flashFrame,
|
||||
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
// 1:1 guard: the cache stores the OUTPUT bytes and scratch is source-sized —
|
||||
@@ -625,7 +646,7 @@ public sealed class FramePump : IDisposable
|
||||
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
|
||||
&& options.SourceRectHeight == options.OutputHeight;
|
||||
var signature = useCache
|
||||
? BuildFullRenderSignature(scene, options, socialBarFrame, socialBarTop, tickerFrame, resolver)
|
||||
? BuildFullRenderSignature(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, resolver)
|
||||
: 0UL;
|
||||
|
||||
if (useCache && _fullCacheValid && signature == _lastFullSignature
|
||||
@@ -636,7 +657,7 @@ public sealed class FramePump : IDisposable
|
||||
return new VideoFrame(options.SourceRectWidth, options.SourceRectHeight, scratch);
|
||||
}
|
||||
|
||||
var rendered = _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame);
|
||||
var rendered = _compositor.Render(scene, resolver, flashFrame, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame);
|
||||
CacheRenders++;
|
||||
if (useCache)
|
||||
{
|
||||
@@ -670,6 +691,7 @@ public sealed class FramePump : IDisposable
|
||||
private static ulong BuildFullRenderSignature(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
VideoFrame? flashFrame,
|
||||
Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
var h = 14695981039346656037UL; // FNV-1a offset basis
|
||||
@@ -684,6 +706,11 @@ public sealed class FramePump : IDisposable
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
if (tickerFrame != null) h = MixFrame(h, tickerFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
// BrandFlashPresenter recycles one buffer and bumps Epoch every read, so this
|
||||
// invalidates the cache on every tick of a credit — a cached frame would freeze
|
||||
// the fade and pin a stale spot.
|
||||
if (flashFrame != null) h = MixFrame(h, flashFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
|
||||
var backdropResolved = false;
|
||||
foreach (var element in scene.Elements)
|
||||
|
||||
Reference in New Issue
Block a user