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
+55 -18
View File
@@ -580,6 +580,34 @@ stays XAML (editing view); the compositor is the output view.
pre-composite once into a cached base). GPU effort belongs to **NVENC** (the encoder), not composition;
if composition grows (wipes, filters, many layers), a D3D11 compositor can replace this one **behind
the same seam** — the CPU master buffer stays the contract.
- **Every global overlay must be composited on EVERY render path (bug found 2026-09-26).** The
frame pump has three exits: `RenderFull` (no graph / no static base), `CompositeLayers` (static base
+ dynamic tail), and the **fully-static shortcut**, which stretches the cached bake and returned it
directly. That third path silently **discarded every per-frame overlay** — the social bar, the alert
ticker and now the brand flash. A creator whose scene was one static background saw none of them,
and the flash looked "preview-only forever" for a reason unrelated to the tier. `SceneCompositor.Overlay`
is therefore `internal static` (it copies before blitting, so the cached bake is never mutated) and
the shortcut now composites overlays onto a copy before stretching. **When adding an overlay, grep
for all three paths** — a fourth exit that forgets it fails the same silent way.
- **`VideoFrame` buffers are recycled by some producers, and `Epoch` is not optional.** A producer that
hands out the same array across frames MUST bump `Epoch`, or `FramePump`'s buffer-identity paste cache
false-hits and freezes stale content (the take-11 class of bug). The `C4` cache signature mixes
`Epoch`, so a per-frame overlay naturally invalidates it. `BrandFlashPresenter` reuses one 8MB
master buffer and stamps `Epoch` per read for exactly this reason — allocating a fresh 8MB frame per
tick is not an option at 1080p30.
- **Dev-only: two instances side by side (`Helpers/InstanceProfile.cs`).** Pre-1.0 the creator runs one
instance to stream and another to screen-capture it. Nothing prevented a second instance (no mutex, no
port); what broke it was shared state — the whole-scene read/write layout DB (clobber), Chromium's
**exclusive** lock on the WebView2 user data folder (second instance won't start), the auth token store
(a test instance overwrites the real YouTube sign-in), and `startup.log`. Set
`YTLIVE_INSTANCE=<id>` and that process gets a private root at `%APPDATA%\ytLlive\instances\<id>\`
for the DB, the auth file, the log and the WebView2 folder. Recording folder and the ffmpeg `tools\`
cache stay shared on purpose; global hotkeys stay un-namespaced (Windows refusing the second
`RegisterHotKey` is the correct answer). The **entire implementation is inside `#if DEBUG`** — a
Release build compiles to `DataRoot => DefaultRoot` and `WebViewDataFolder => null`, and the call
sites are unconditional so Release cannot drift. Proof + harness: `ytLive.Tests/InstanceIsolationTests.cs`.
⚠️ Grepping the binary for `YTLIVE_INSTANCE` proves nothing — a `const` is inlined and appears in
neither build; check the `InstanceVariable` **field** in metadata instead.
- **Branding flash is composited by the output path too** (it's on the live output, per Monetization),
passed in as a pre-rendered `VideoFrame?` — the compositor core stays pure byte-math, no WPF. Likely a
bundled asset rather than runtime text rendering (deterministic, no font/layout risk).
@@ -1416,24 +1444,33 @@ The **only difference is watermarking**. This is deliberate: no creator will tol
and as a good-guy developer, we give them complete access to every feature so no one can call us
crooked.
- **Free:** a periodic full-frame branding flash — "made with LlamaCasty!" rendered big and centered
at ~25% opacity for about one second (soft 250ms fade in/out), repeated every 300s, on the live
output (and on local recordings). Implemented as `BrandFlashLayer` in the preview compositor
(`MainWindow.xaml` CanvasGrid) + `BrandFlashTimer` in `MainViewModel` — cadence 300s, first flash
~5s after go-live, only while live or recording. An always-on watermark can be cropped or covered;
an intermittent full-frame flash can't be cropped and is impractical to edit around on a live feed.
**The flash is also the free tier's billboard** — every free stream advertises LlamaCasty to its
own viewers; the free tier is distribution, not compromise.
**Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting —
intervals shorten with use, starting at the 300s cadence and creeping toward a floor (the exact
curve is a build-time design knob). License activation still flips exactly one bit: `IsPremium` →
flash off. Nothing else changes between free and paid, ever.
**Pre-GA posture:** while the app is unreleased the flash renders **in the preview only** and is
never composited onto the live output or local recording — test VODs stay clean (same channel-
protection stance as the visibility lock), and creators can be shown what free looks like without
it ever touching a real broadcast. Flipping the flash live-on is a **TASK 36** unlock item.
- **Paid (one-time perpetual license):** branding flash removed (flips `BrandFlashEnabled` off). That's it.
No feature gating. Alerts, social bar slots, voice filters, TRAX, recording — everything is free.
- **Free:** a periodic branding credit — "made with LlamaCasty!" in **full-frame neon** (white core,
feathered red/blue halo with the channels split in opposite directions), at a **random position
inside the frame on every presentation** (single pre-measured line, so it never wraps; travel range
is `0 … 1920−textWidth`), held **2s** (500ms fade in, 1000ms full, 500ms fade out), first credit
~5s after go-live then **every `rand(30s)+30s`** (30–60s), on the live output **and** local
recordings. An always-on watermark can be cropped or covered; an intermittent full-frame flash can't
be cropped and is impractical to edit around on a live feed.
**The flash is also the free tier's billboard** — every free stream advertises LlamaCasty to its own
viewers; the free tier is distribution, not compromise.
Implemented as `Services/Compositor/BrandFlashPresenter.cs` (raster + envelope + placement + licence
gate), published to **both** the frame pump (`FramePump(brandFlash:)` → the compositor's per-frame
`flashFrame` slot) and the preview (`BrandFlashElement`, bound to `BrandFlashImageSource`) from the
**same** `VideoFrame`, so the preview cannot drift from the broadcast. Deliberately NOT the
`flashFrame` parameter of `SceneGraph.GetBakedBase` — `SceneRegion.Matches` compares element ids
only, so a credit folded into the cached bake would freeze there and never expire.
**Pre-GA posture — REVERSED 2026-09-26 (TASK 36 shipped).** This used to render *in the preview
only*, never on the broadcast, so test VODs stayed clean. The creator ruled that the free tier must
be honest advertising: it now composites into the recording and the stream. Test VODs from an
unlicensed instance carry the credit — use a license key or the paid build for clean captures.
**Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting.
License activation still flips exactly one bit: `IsPremium` → flash off. Nothing else changes
between free and paid, ever.
- **Paid (one-time perpetual license):** branding flash removed. `BrandFlashEnabled` is now a
**derived, non-assignable** `!IsPremium` and the presenter's own `Enabled` gate is re-checked on
every frame, so a key entered (or revoked) mid-credit cuts the advertisement on the next tick
rather than letting it finish. That's it. No feature gating. Alerts, social bar slots, voice
filters, TRAX, recording — everything is free.
- **Pricing (2026-09-21 — switched from subscription to one-time "own it"):** **$29 lifetime** founder's
price (launch → 90 days) → **$49 lifetime** list at GA. The key is **perpetual** (`IsPremium` never
lapses; the old renewal/lapse path is dead). Rationale: a local app with no per-user server cost and a