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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user