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
+64
View File
@@ -0,0 +1,64 @@
using ytLive.Services;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
/// <summary>The branding credit as the preview pane shows it: a master-space
/// bitmap, or null while no credit is playing. It is the SAME frame the encoder
/// composites into the recording/stream (<see cref="BrandFlashFrame"/>), so what
/// the creator sees is what the viewers get — the old preview-only TextBlock lied
/// about this, and a 1080p credit that is only in the preview is not advertising
/// anyone. It cannot ride a per-source <c>VideoImageSource</c>: it is a global
/// overlay, like the social bar and the alert ticker.</summary>
private System.Windows.Media.Imaging.WriteableBitmap? _brandFlashBitmap;
public System.Windows.Media.Imaging.WriteableBitmap? BrandFlashImageSource
{
get => _brandFlashBitmap;
private set
{
_brandFlashBitmap = value;
OnPropertyChanged(nameof(BrandFlashImageSource));
OnPropertyChanged(nameof(BrandFlashVisible));
}
}
public bool BrandFlashVisible => _brandFlashBitmap != null;
/// <summary>The credit for the output compositor, or null when none is due. Read by
/// the frame pump every output frame; <c>null</c> for a premium user, always —
/// the licence check lives inside the presenter so no caller can bypass it.</summary>
public VideoFrame? BrandFlashFrame() => _brandFlash.Frame;
/// <summary>Publish one credit frame to the preview pane, UI thread only (the
/// presenter fires this from its own dispatcher tick, so the bitmap write is legal).
/// Reuses one <see cref="System.Windows.Media.Imaging.WriteableBitmap"/> and
/// overwrites its back buffer — a fresh 8MB bitmap per tick would churn the render
/// cache for nothing.</summary>
internal void PublishBrandFlashPreview(VideoFrame? frame)
{
if (frame == null)
{
if (_brandFlashBitmap != null) BrandFlashImageSource = null;
return;
}
if (_brandFlashBitmap == null
|| _brandFlashBitmap.PixelWidth != frame.Width
|| _brandFlashBitmap.PixelHeight != frame.Height)
{
BrandFlashImageSource = new System.Windows.Media.Imaging.WriteableBitmap(
frame.Width, frame.Height, 96, 96,
System.Windows.Media.PixelFormats.Bgra32, null);
}
var target = _brandFlashBitmap!;
target.WritePixels(
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
frame.BgraPixels, frame.Stride, 0);
// The bitmap instance is unchanged, so the pane's Source binding still points
// at it — nudge it so WPF re-reads the back buffer this tick.
OnPropertyChanged(nameof(BrandFlashImageSource));
}
}
+3 -2
View File
@@ -21,8 +21,9 @@ public partial class MainViewModel
private set
{
if (!SetProperty(ref _isPremium, value)) return;
// Flip the branding flash when premium state changes.
BrandFlashEnabled = !value;
// The flash is composited into the broadcast, so the gate has to be pushed
// all the way into the renderer — not just the preview.
_brandFlash.Enabled = !value;
}
}
@@ -431,14 +431,15 @@ public partial class MainViewModel : ViewModelBase
if (live)
{
if (BrandFlashEnabled) StartBrandFlashTimer();
// Cadence lives in the presenter: first credit 5s after go-live, then
// every rand(30s)+30s. It refuses to emit while premium.
_brandFlash.Enabled = !IsPremium;
_brandFlash.Start();
}
else
{
_healthPollTimer.Stop();
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
_brandFlash.Stop();
}
UpdateSessionTimer();
}
+16 -60
View File
@@ -53,10 +53,9 @@ public partial class MainViewModel : ViewModelBase
// Branding flash (monetization): a full-frame "made with LlamaCasty!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization".
private readonly DispatcherTimer _brandFlashTimer;
private readonly DispatcherTimer _brandFlashOffTimer;
private bool _brandFlashEnabled = true;
private bool _brandFlashActive;
// The free-tier branding credit. Composed into the OUTPUT by FramePump, not just
// previewed — see BrandFlashPresenter. Gated on IsPremium (Polar), never a toggle.
private readonly BrandFlashPresenter _brandFlash;
public SceneElement? SelectedElement
{
@@ -99,31 +98,10 @@ public partial class MainViewModel : ViewModelBase
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately.
public bool BrandFlashEnabled
{
get => _brandFlashEnabled;
set
{
if (!SetProperty(ref _brandFlashEnabled, value)) return;
if (value)
{
if (IsLive) StartBrandFlashTimer();
}
else
{
_brandFlashTimer.Stop();
_brandFlashOffTimer.Stop();
BrandFlashActive = false;
}
}
}
public bool BrandFlashActive
{
get => _brandFlashActive;
private set => SetProperty(ref _brandFlashActive, value);
}
// presenter stops emitting, so any active credit dies on the next tick — and a
// credit already in flight is cut, not run out. Derived, never assignable: the
// whole point is that nobody can turn the advertising on a paying stream.
public bool BrandFlashEnabled => !IsPremium;
// Window geometry pass-through (code-behind owns the WPF specifics).
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
@@ -172,10 +150,8 @@ public partial class MainViewModel : ViewModelBase
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
_brandFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(300) };
_brandFlashTimer.Tick += OnBrandFlashTimerTick;
_brandFlashOffTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(750) };
_brandFlashOffTimer.Tick += (_, _) => { _brandFlashOffTimer.Stop(); BrandFlashActive = false; };
_brandFlash = new BrandFlashPresenter();
_brandFlash.OnFrame = PublishBrandFlashPreview;
_healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
_healthPollTimer.Tick += OnHealthPollTick;
@@ -329,6 +305,7 @@ public partial class MainViewModel : ViewModelBase
log: message => AppLog.Write(message),
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
alertTicker: () => _alertLayer.AlertTickerFrame,
brandFlash: BrandFlashFrame,
transition: _transition,
sceneGraph: _sceneGraph);
_framePump.Failed += OnFramePumpFailed;
@@ -350,10 +327,10 @@ public partial class MainViewModel : ViewModelBase
AppLog.Write("MainViewModel ctor end");
}
// Per-instance in DEBUG so two dev instances cannot clobber each other's layout
// (see InstanceProfile). Release always resolves the one real profile.
private static string DefaultLayoutPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
"ytLlive.db");
InstanceProfile.DataRoot, "ytLlive.db");
// Test seam: a real MainWindow test must never read or write the user's
// real layout DB (it would persist test sources over real ones). Tests set
@@ -574,29 +551,8 @@ public partial class MainViewModel : ViewModelBase
};
}
// First flash shortly after go-live, then every 300s (the cadence resets on
// the first tick so the interval is 5s only for that one shot).
private void StartBrandFlashTimer()
{
_brandFlashTimer.Stop();
_brandFlashTimer.Interval = TimeSpan.FromSeconds(5);
_brandFlashTimer.Start();
}
private void OnBrandFlashTimerTick(object? sender, EventArgs e)
{
_brandFlashTimer.Stop();
_brandFlashTimer.Interval = TimeSpan.FromSeconds(300);
_brandFlashTimer.Start();
ShowBrandFlash();
}
private void ShowBrandFlash()
{
if (!IsLive || !BrandFlashEnabled) return;
BrandFlashActive = true;
_brandFlashOffTimer.Stop();
_brandFlashOffTimer.Start();
}
// The credit's cadence, timing, placement and licence gate all live in
// BrandFlashPresenter; MainViewModel only routes it to the frame pump and the
// preview (see MainViewModel.BrandFlash.cs).
}