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:
2026-09-27 08:55:55 -07:00
parent 5aedca7305
commit f5a9d46881
14 changed files with 1247 additions and 222 deletions
+479
View File
@@ -0,0 +1,479 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Good Dog (2026-09-26): the free-tier branding credit must be COMPOSITED INTO THE
/// BROADCAST, and must be exactly what the creator sees in the preview.
/// <para>The bug: the credit existed only as a WPF <c>BrandFlashLayer</c> in
/// PreviewPane.xaml — a 0.25-opacity TextBlock on a 300s timer. A viewer of the
/// stream or the recording never saw it, so the "free tier shows branding" promise was
/// simply not being kept. The frame pump has carried an unused <c>flashFrame</c> slot
/// for exactly this and it was never wired.</para>
/// <para>The fix routes ONE rendered frame to both consumers: the output compositor and
/// the preview pane. These facts pin the behaviour the creator specified — in the
/// recording, 2 seconds, random spot every time, inside the frame, and never for a
/// paying customer.</para>
/// </summary>
[Collection("RealApp")]
public sealed class BrandFlashOutputTests
{
private readonly RealAppHost _app;
public BrandFlashOutputTests(RealAppHost app) => _app = app;
// ---------- the headline: the credit is in the encoded output ----------
[Fact]
public async Task FreeTier_Credit_ReachesTheEncodedOutputFrame()
{
await _app.RunAsync(async () =>
{
// A real 1920x1080 pump: the credit is authored at master size, so a
// toy-sized canvas would clip it away and prove nothing.
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new Source
{
Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0",
});
using var presenter = new BrandFlashPresenter(new Random(1));
presenter.Show();
presenter.Advance(0.5); // land at the top of the full-opacity hold
var credit = presenter.Frame;
Assert.NotNull(credit);
var encoder = new CapturingEncoder();
using var pump = new FramePump(
sceneProvider: () => scene,
frameResolver: _ => null,
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0,
SourceRectWidth = BrandFlashPresenter.MasterWidth,
SourceRectHeight = BrandFlashPresenter.MasterHeight,
OutputWidth = BrandFlashPresenter.MasterWidth,
OutputHeight = BrandFlashPresenter.MasterHeight,
},
encoderOptions: () => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = BrandFlashPresenter.MasterWidth,
Height = BrandFlashPresenter.MasterHeight,
Fps = 30,
},
encoderFactory: () => encoder,
pacingDelay: (_, _) => Task.CompletedTask,
brandFlash: () => presenter.Frame);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
await pump.StartAsync(cts.Token);
await encoder.FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(15));
await pump.StopAsync();
var sent = encoder.Frames[0];
Assert.True(HasBrightPixels(sent),
"the branding credit never reached a frame submitted to the encoder — "
+ "it is still preview-only, so nobody watching the stream sees it");
// The preview must show the very same thing, not a separate XAML text block.
Assert.Equal(BrandFlashPresenter.MasterWidth, sent.Width);
Assert.Equal(BrandFlashPresenter.MasterHeight, sent.Height);
});
}
// ---------- a fully static scene must not swallow it ----------
[Fact]
public async Task FullyStaticScene_StillShowsTheCredit()
{
await _app.RunAsync(async () =>
{
// Regression on a PRE-EXISTING defect found while wiring this up: the
// fully-static shortcut stretched the cached bake and returned it, dropping
// every per-frame overlay on the floor — the credit, and (before this
// change) the social bar and the alert ticker too. A creator with a single
// static background would have seen the flash preview-only, forever.
var black = SolidBlackFrame();
var scene = new Scene { Name = "Live" };
// One Background element and nothing dynamic => GetSplitPoint ==
// Elements.Count, which is the branch that used to drop the overlays.
scene.Elements.Add(new Source { Type = SourceType.Background });
using var presenter = new BrandFlashPresenter(new Random(2));
presenter.Show();
presenter.Advance(0.5);
Assert.NotNull(presenter.Frame);
var encoder = new CapturingEncoder();
using var pump = new FramePump(
sceneProvider: () => scene,
frameResolver: e => e is Source { Type: SourceType.Background } ? black : null,
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0,
SourceRectWidth = BrandFlashPresenter.MasterWidth,
SourceRectHeight = BrandFlashPresenter.MasterHeight,
OutputWidth = BrandFlashPresenter.MasterWidth,
OutputHeight = BrandFlashPresenter.MasterHeight,
},
encoderOptions: () => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = BrandFlashPresenter.MasterWidth,
Height = BrandFlashPresenter.MasterHeight,
Fps = 30,
},
encoderFactory: () => encoder,
pacingDelay: (_, _) => Task.CompletedTask,
brandFlash: () => presenter.Frame,
sceneGraph: new SceneGraph());
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
await pump.StartAsync(cts.Token);
await encoder.FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(15));
await pump.StopAsync();
Assert.True(HasBrightPixels(encoder.Frames[0]),
"a fully static scene dropped the branding credit — the fully-static path "
+ "stretches the cached bake and used to discard every per-frame overlay");
});
}
// ---------- the preview shows the same rendered credit ----------
[Fact]
public void PreviewPane_ShowsTheSameCreditThatGoesToTheStream()
{
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
window.UpdateLayout();
using var presenter = new BrandFlashPresenter(new Random(3));
presenter.Show();
presenter.Advance(0.5);
var credit = presenter.Frame;
Assert.NotNull(credit);
vm.PublishBrandFlashPreview(credit);
window.UpdateLayout();
var pane = (UserControl)window.FindName("PreviewPane")!;
var holder = Find(pane, e => e is FrameworkElement f
&& f.Name == "BrandFlashElement") as FrameworkElement;
Assert.True(holder != null,
"PreviewPane.xaml has no BrandFlashElement — the credit is a global "
+ "overlay, so it needs its own root, not a Source's Image");
Assert.Equal(Visibility.Visible, holder!.Visibility);
var image = Find(holder, e => e is Image) as Image;
Assert.True(image != null && image.Source != null,
"the branding element never bound a bitmap, so the credit is never drawn");
Assert.IsType<System.Windows.Media.Imaging.WriteableBitmap>(image!.Source);
// It must go away again rather than sit on the next scene forever.
vm.PublishBrandFlashPreview(null);
window.UpdateLayout();
Assert.Equal(Visibility.Collapsed, holder.Visibility);
}
finally
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
});
}
// ---------- the specified numbers ----------
[Fact]
public void Credit_LastsTwoSeconds_ThenIsGone()
{
// 500ms fade in, 1000ms held, 500ms fade out.
Assert.Equal(0.0, BrandFlashPresenter.OpacityAt(0.0), 3);
Assert.Equal(0.5, BrandFlashPresenter.OpacityAt(0.25), 3);
Assert.Equal(1.0, BrandFlashPresenter.OpacityAt(0.5), 3);
Assert.Equal(1.0, BrandFlashPresenter.OpacityAt(1.49), 3);
Assert.Equal(0.5, BrandFlashPresenter.OpacityAt(1.75), 3);
Assert.Equal(0.0, BrandFlashPresenter.OpacityAt(2.0), 3);
Assert.Equal(2.0, BrandFlashPresenter.PresentationSeconds, 3);
}
[Fact]
public void Credit_IsGoneExactlyWhenTheTwoSecondsAreUp()
{
_app.Run(() =>
{
using var presenter = new BrandFlashPresenter(new Random(4));
presenter.Show();
// At exactly t=0 the fade-in has not started, so opacity is 0 and there is
// deliberately nothing to blit — the credit appears on the next tick.
Assert.Null(presenter.Frame);
presenter.Advance(1.8);
Assert.NotNull(presenter.Frame); // 1.8s: inside the fade-out
Assert.True(presenter.IsPresenting);
presenter.Advance(0.3);
Assert.Null(presenter.Frame); // 2.1s in — over
Assert.False(presenter.IsPresenting);
});
}
[Fact]
public void EveryPresentation_PicksADifferentSpot_AndAlwaysLandsOnTheCanvas()
{
_app.Run(() =>
{
using var presenter = new BrandFlashPresenter(new Random(7));
var spots = new List<Point>();
for (var run = 0; run < 40; run++)
{
presenter.Show();
presenter.Advance(0.5);
var frame = presenter.Frame;
Assert.NotNull(frame);
// The credit must be FULLY on the canvas — this is the "how far can the
// left margin travel" limit: 0 .. (canvas - creditWidth), never wrapped.
var (minX, minY, maxX, maxY) = AlphaBounds(frame!);
Assert.True(minX >= 0 && minY >= 0,
$"the credit hangs off the top-left at ({minX},{minY})");
Assert.True(maxX < frame.Width && maxY < frame.Height,
$"the credit runs off the bottom-right at ({maxX},{maxY})");
spots.Add(new Point(minX, minY));
}
Assert.True(spots.Distinct().Count() > spots.Count / 2,
"the spot barely changed across 40 presentations — it is not random per flash");
});
}
[Fact]
public void Placement_StaysInsideTheFrameForAnyTextSize()
{
var rand = new Random(11);
for (var i = 0; i < 500; i++)
{
var w = rand.Next(1, 4000);
var h = rand.Next(1, 3000);
var p = BrandFlashPresenter.ComputePlacement(w, h,
BrandFlashPresenter.MasterWidth, BrandFlashPresenter.MasterHeight, rand);
Assert.InRange(p.X, 0, Math.Max(0, BrandFlashPresenter.MasterWidth - w));
Assert.InRange(p.Y, 0, Math.Max(0, BrandFlashPresenter.MasterHeight - h));
}
// A credit wider than the canvas still gets a legal offset rather than throwing.
var over = BrandFlashPresenter.ComputePlacement(5000, 5000,
BrandFlashPresenter.MasterWidth, BrandFlashPresenter.MasterHeight, rand);
Assert.Equal(0, over.X);
Assert.Equal(0, over.Y);
}
[Fact]
public void Cadence_RepeatsEveryThirtyToSixtySeconds()
{
_app.Run(() =>
{
using var presenter = new BrandFlashPresenter(new Random(5));
presenter.Start();
try
{
// First credit 5s after go-live, then not again inside 30s.
Step(presenter, 4.0);
Assert.Null(presenter.Frame);
Step(presenter, 1.5);
Assert.NotNull(presenter.Frame);
// Sample the start-to-start intervals the presenter actually schedules
// rather than trusting the constants. A rising edge is a new credit.
// The first gap is the deliberate 5s opener, so it is excluded.
var starts = new List<double>();
var t = 0.0;
var wasPresenting = presenter.IsPresenting;
while (t < 1200.0)
{
presenter.Advance(0.05);
t += 0.05;
var presenting = presenter.IsPresenting;
if (!wasPresenting && presenting) starts.Add(t);
wasPresenting = presenting;
}
var intervals = starts.Zip(starts.Skip(1), (a, b) => b - a)
.Where(g => g > 6.0) // drops the single 5s opener
.ToList();
Assert.True(intervals.Count >= 15,
$"expected ~20 credits in 1200s of a 30-60s cadence, saw {starts.Count}");
// The whole point: no beat a viewer could tune out, and none of the
// collapsing gaps an absolute-deadline bug produces.
Assert.All(intervals, g => Assert.InRange(g, 30.0 - 0.2, 60.0 + 0.2));
Assert.True(intervals.Distinct().Count() > intervals.Count / 2,
"the cadence barely varied — it is not random per flash");
}
finally
{
presenter.Stop();
}
});
}
[Fact]
public void Credit_IsNeverEmittedForAPayingCustomer()
{
_app.Run(() =>
{
using var presenter = new BrandFlashPresenter(new Random(6));
presenter.Show();
presenter.Advance(0.5);
Assert.NotNull(presenter.Frame);
// A key entered mid-credit must kill it on the next tick, not let the
// advertisement finish playing.
presenter.Enabled = false;
Assert.Null(presenter.Frame);
Assert.False(presenter.IsPresenting);
presenter.Advance(0.1);
presenter.Show();
Assert.Null(presenter.Frame);
// Going back to free tier restores it.
presenter.Enabled = true;
presenter.Show();
presenter.Advance(0.5);
Assert.NotNull(presenter.Frame);
});
}
[Fact]
public void BrandFlashEnabled_IsDerivedFromTheLicence_AndCannotBeSwitchedOn()
{
var prop = typeof(MainViewModel).GetProperty(nameof(MainViewModel.BrandFlashEnabled));
Assert.NotNull(prop);
Assert.Null(prop!.SetMethod);
Assert.True(prop.CanRead, "the preview needs to read the gate state");
}
// ---------- helpers ----------
private static DependencyObject? Find(DependencyObject parent, Func<DependencyObject, bool> predicate)
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (predicate(child)) return child;
var found = Find(child, predicate);
if (found != null) return found;
}
return null;
}
/// <summary>Drive the clock in 1/30s steps, like the real timer does.</summary>
private static void Step(BrandFlashPresenter presenter, double seconds)
{
for (var t = 0.0; t < seconds; t += 1.0 / 30.0) presenter.Advance(1.0 / 30.0);
}
/// <summary>Alpha bounding box of everything the frame actually drew.</summary>
private static (int MinX, int MinY, int MaxX, int MaxY) AlphaBounds(VideoFrame frame)
{
var minX = int.MaxValue;
var minY = int.MaxValue;
var maxX = -1;
var maxY = -1;
for (var y = 0; y < frame.Height; y++)
for (var x = 0; x < frame.Width; x++)
{
if (frame.BgraPixels[(y * frame.Stride) + (x * 4) + 3] == 0) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
Assert.True(maxX >= 0, "the credit frame drew no visible pixels at all");
return (minX, minY, maxX, maxY);
}
/// <summary>True when the frame carries near-white opaque pixels — the credit's core.
/// A black background cannot produce them, so this cannot false-positive.</summary>
private static bool HasBrightPixels(VideoFrame frame)
{
for (var i = 0; i < frame.BgraPixels.Length; i += 4)
{
if (frame.BgraPixels[i + 3] < 200) continue;
if (frame.BgraPixels[i] > 200 && frame.BgraPixels[i + 1] > 200 && frame.BgraPixels[i + 2] > 200)
return true;
}
return false;
}
/// <summary>An opaque black master frame. The credit's core is near-white, so it
/// cannot false-positive against this and the assertion stays honest.</summary>
private static VideoFrame SolidBlackFrame()
{
var w = BrandFlashPresenter.MasterWidth;
var h = BrandFlashPresenter.MasterHeight;
var pixels = new byte[w * h * 4];
for (var i = 0; i < pixels.Length; i += 4) pixels[i + 3] = 255;
return new VideoFrame(w, h, pixels) { IsOpaque = true };
}
/// <summary>Captures the frames the pump actually submits — the same trick the
/// production encoder uses, so the assertion is about the bytes on the wire.</summary>
private sealed class CapturingEncoder : IFfmpegEncoder
{
public readonly List<VideoFrame> Frames = new();
public readonly TaskCompletionSource FirstFrame =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public int DroppedFrames { get; set; }
public event EventHandler<StreamHealth>? HealthUpdated;
public event EventHandler<string>? ProcessFailed;
public Task StartAsync(EncoderOptions options, CancellationToken ct = default)
=> Task.CompletedTask;
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken ct = default)
{
Frames.Add(new VideoFrame(frame.Width, frame.Height, (byte[])frame.BgraPixels.Clone()));
FirstFrame.TrySetResult();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct = default) => Task.CompletedTask;
public void Dispose() { }
// Never invoked here; present so the fake satisfies the interface the same way
// FramePumpTests' fake does.
public void RaiseFailed(string message) => ProcessFailed?.Invoke(this, message);
public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
}
}