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
+13 -31
View File
@@ -325,37 +325,19 @@
Width="{Binding OutputRectWidth}" Height="{Binding OutputRectHeight}"
Visibility="{Binding IsOutputCropped, Converter={StaticResource BoolToVis}}"/>
<Grid x:Name="BrandFlashLayer" Width="1920" Height="1080" IsHitTestVisible="False" Opacity="0">
<Grid.Style>
<Style TargetType="Grid">
<Setter Property="Opacity" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding BrandFlashActive}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="0" To="0.25" Duration="0:0:0.25"/>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="0.25" To="0" Duration="0:0:0.25"/>
</Storyboard>
</BeginStoryboard>
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
<Viewbox Stretch="Uniform" MaxWidth="1600" MaxHeight="800"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="made with LlamaCasty!" FontWeight="Bold" Foreground="White"
FontSize="180" TextAlignment="Center"/>
</Viewbox>
<!-- Free-tier branding credit. The source is the SAME rendered
frame FramePump composites into the recording/stream
(BrandFlashPresenter → MainViewModel.BrandFlashFrame), so
the preview cannot drift from the broadcast. It replaces a
preview-only TextBlock that no viewer ever saw, and the
fade/spread/placement is the renderer's — there is no
storyboard here to keep in sync. -->
<Grid x:Name="BrandFlashElement" Width="1920" Height="1080"
Canvas.Left="0" Canvas.Top="0"
Background="Transparent" IsHitTestVisible="False"
Visibility="{Binding BrandFlashVisible, Converter={StaticResource BoolToVis}}">
<Image Source="{Binding BrandFlashImageSource}"
Stretch="Fill" RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
<!-- Alert announcement strip (TASK 47): the same global
+68 -99
View File
@@ -1,109 +1,78 @@
# HANDOFF — 2026-09-26, end of session (take four: ticker display methods)
# HANDOFF — current state
## Where we are
`main`, **5 local commits ahead of `origin/main` (= `ecb329e`), NOTHING PUSHED.**
Last pushed commit is still `ecb329e`. Do not push without the creator saying so.
**Branch:** `main` (pre-1.0, no feature branches). **Last pushed:** `7f14ffb`.
**This session's work is COMMITTED LOCALLY, NOT PUSHED** — two commits ahead of `origin/main`.
Push only when the creator says so.
| commit | what |
|---|---|
| `a11b15e` | TASK 47 alert video (first landing) |
| `7b940b6` | poller-thread marshal crash fix |
| `80038ff` | silent-decoder fallback + diagnostics |
| `aea0670` | video pacing via `FfmpegFrameRateProbe` |
| `e2ecc24` | **the real alert fix: `PreviewPane.xaml` `IsAlertBox` trigger** + 2 Good Dog tests + resolver/preview diagnostics |
| (last, unpushed) | **alert announcement strip: visible in the preview + 3 display methods** (Scroll / Flash / Solid), paced in reads-per-alert |
## Two work units landed this session
## THE ANSWER, take two (do not re-derive this)
The **ticker was output-only**. `AlertTickerFrame` existed solely as a frame-pump
callback (blitted at 0,0 into the OUTPUT). `PreviewPane.xaml` had no element for it
because the strip is master-width and GLOBAL, not a `Source`, so it cannot ride a
per-element `Image`. Nothing was broken in the renderer — there was simply no consumer
in the preview. Fixed with a `tickerPreviewSink` on `AlertOverlayLayer` (published from
`RefreshAlertPreviews()`, UI thread only) + `MainViewModel.AlertTickerImageSource` +
a global `AlertTickerElement` in `PreviewPane.xaml` mirroring `SocialBarElement`.
### 1. Branding flash is now composited into the OUTPUT (TASK 36 shipped)
The marquee was also **unreadably paced**: a fixed `140px/s` needed ~17s per pass, so a
10s alert showed the message barely once, entering from the right and never crossing. It
is now paced in **reads per alert** (`TickerReadsPerAlert = 3` inside the alert's own
length), which is the incumbent's own unit (Streamlabs: "Alert Duration" + "Text Delay",
never a scroll-speed slider).
It used to be a WPF `BrandFlashLayer` TextBlock in the preview at 25% opacity on a 300s timer — a
credit the creator could see and **no viewer ever could**. Now one rendered `VideoFrame` goes to
**both** the frame pump and the preview, so they cannot drift:
## THE ANSWER (do not re-derive this)
The alert video was **never drawn**, never mis-decoded. Build 90's diagnostics showed
`Alert preview: frame=680x200 a255` and `Output resolver: frame 680x200 playing=True`
every second for the whole clip — a real, opaque, correctly-sized frame reaching BOTH
consumers. `Controls/PreviewPane.xaml` keeps the per-element `Image` `Collapsed` unless a
DataTrigger fires, and there was **no `IsAlertBox` trigger** (only IsImageSource /
IsChatBox / IsWebSource / IsWebcam). Chat boxes rendered; alert boxes never could.
Removing/re-adding the layer could not have fixed it.
- `Services/Compositor/BrandFlashPresenter.cs` (new) — neon raster (white core + feathered red/blue
halo, channels split opposite), 2s envelope, random on-canvas placement per presentation,
licence gate re-checked on every read.
- `ViewModels/MainViewModel.BrandFlash.cs` (new) — `BrandFlashFrame()` for the pump +
`PublishBrandFlashPreview()` for the pane.
- `FramePump` gained `brandFlash:` → the existing per-frame `flashFrame` slot, and mixes it into the
C4 cache signature. `SceneCompositor.Overlay` is now `internal` for the shortcut path.
- `PreviewPane.xaml`: `BrandFlashLayer` → `BrandFlashElement` (bound to the published frame).
- `BrandFlashEnabled` is derived `!IsPremium` and no longer assignable.
Clip itself is fine: DB asset `941785b0-d022-45fa-a8a2-2cd59ae2ba48` is byte-identical
(md5 `0ee1f4960dd3b23dee5930a2af79d410`) to the creator's
`C:\Users\gramp\Downloads\llamacasty-dancingLlama-thankyou.mp4` — h264 1280x720 10s,
every sampled frame full bright content.
Spec met: 500ms in / 1000ms hold / 500ms out, first credit ~5s after go-live then every
`rand(30s)+30s`, single unwrapped line, random spot every time, fully on canvas.
## Working tree
Clean apart from the uncommitted work unit above (it is committed; check `git status`).
If the app is running, `ytLive.dll`/`.exe` are LOCKED and any build fails with MSB3021/
MSB3027 — the creator must close ytLive first. That is normal, not a broken build.
### 2. Dev-only: two instances side by side
## Build / test commands (Windows host, always)
```bash
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" --no-restore
"/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
```
Last full suite: **339/339 pass** (15 new facts for the ticker feature). Run it with ytLive CLOSED or the RealMouseDrag env
flake fires.
`Helpers/InstanceProfile.cs` (new). Set `YTLIVE_INSTANCE=<id>` and that process gets a private
`%APPDATA%\ytLlive\instances/<id>/` root for the **layout DB, auth file, startup.log and the
WebView2 user data folder**. Chromium locks that folder exclusively — without this the second
instance does not start at all. Recording folder and the ffmpeg `tools` cache stay shared on
purpose; global hotkeys stay un-namespaced.
## Queued next (creator's words, 2026-09-26) — the Good Dog queue
1. **Test-console chat must not clear the chat window.** "when I add a chat message from
the test console, or from any console, that chat text should not clear the current
chat window."
2. **Test pull-out chat input: 20px right padding** — the text box is clipped at the right
edge of the div; needs 20px padding against the div's right side.
3. **Test pull-out chat input: Enter inserts a newline**, it must not send.
4. ~~Verify the alert video live~~ — **DONE, the creator confirmed it plays.**
5. **Verify the three ticker display methods live** (Scroll / Flash / Solid) and pick a
default. Flash is 0.5s on / 0.5s off; Solid is centred; Scroll does 3 passes per 10s
alert. Nothing here has been SEEN on screen yet — the tests prove pixels and bindings,
not the look.
6. **Decide whether the once-per-second alert diagnostics stay.** `ViewModels/MainViewModel.cs`
(`ResolveOutputFrame`) and `Services/AlertOverlayLayer.cs` (`UpdatePreview`) still log
`Alert preview:` / `Output resolver:` once a second while an alert plays. They earned
their keep; they are noise in normal use. Creator's call.
**Usage:** `$env:YTLIVE_INSTANCE=2; dotnet run`
## Landmines / facts worth keeping
- The pinned BtbN ffmpeg has **no libx264** — generate test clips with `-c:v mpeg4`.
- **A bound `ItemsSource` ComboBox in `LeftPanel.xaml` breaks
`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`** (it
injects PHYSICAL mouse input, so a load-time re-measure moves the rows out from under
the cursor). Use inline `<ComboBoxItem>`s like the chat Font selector does. If a UI-only
change breaks a real-input test, suspect the control's binding STYLE first, and bisect
with `git checkout <the one xaml file>`.
- The `Source` row is read back by **POSITIONAL** index in `LayoutStore.Load.cs`
(`GetInt32(32..34)`). Append new Source columns to the END of the SELECT, or a
mid-list insert silently shifts a neighbour instead of failing to load. New columns use
the idempotent `PRAGMA table_info` guard in `LayoutStore.Migrations.cs` (no
`user_version` bump needed).
- **A seamless marquee never goes blank** (the wrapped copy enters from the right as the
pill clears the left), so an "empty frame" cannot count a pass — count the pill's
leading edge resetting to the right edge.
- `SceneCompositor.Render` fills its base with **opaque black**, so "any non-zero byte"
is not proof a layer painted. Compare against an idle render, or count COLOURFUL px.
- `sqlite3` on WSL: `/home/gramps/android-sdk/platform-tools/sqlite3`. Extract a BLOB with
`SELECT writefile('/tmp/opencode/x.bin', Data) FROM Asset WHERE Id='…'` (Linux paths
only — a `C:/…` path creates a junk `C:` dir IN THE REPO; one was made and removed).
- Layout DB: `/mnt/c/Users/gramp/AppData/Roaming/ytLlive/ytLlive.db` (tables: `Asset`
`Id/Hash/Data`, `Settings`, `Source`, `Scene`, …). Alert default video setting key:
`AlertDefaultVideoAssetId`. ffmpeg/ffprobe live in
`/mnt/c/Users/gramp/AppData/Roaming/ytLlive/tools/` (run the `.exe` directly from WSL).
- Two diagnostics were ADDED and are still in the tree (once-per-second, alert only):
`Output resolver: alert box …` (MainViewModel.cs) and `Alert preview: box …`
(AlertOverlayLayer.cs). Decide with the creator whether to keep them or strip them now
that the root cause is known.
- `PreviewPane.xaml` has the same latent gap for `IsMediaSource` (no trigger either) —
NOT fixed (out of scope), noted as a follow-up.
- Known perf item, untouched: FramePump stalls ~140-180ms renders
(`render=full-render split=0 … totalMs=140`). Unrelated to the alert bug.
- YouTube rejects the `complete` transition (403) at end-of-stream; `enableAutoStop`
finishes the broadcast. Harmless, already handled.
The entire implementation is inside `#if DEBUG`. Release compiles to `DataRoot => DefaultRoot` +
`WebViewDataFolder => null`; the call sites are unconditional so Release cannot drift.
## Bugs found and fixed along the way
- **Pre-existing, not mine:** `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, not just the flash. Now composites overlays onto a copy before stretching.
- **Cadence bug caught by my own test:** the interval was an absolute deadline, not a period, so
gaps collapsed (6.7s / 3.5s / 2.8s instead of 30–60s). Fixed.
- `BrandFlashPresenter` recycles one 8MB master buffer and **must** stamp `VideoFrame.Epoch` per
read, or the paste cache freezes the credit. It does.
## Test state — VERIFIED
- **357 total, 356 pass.** 18 new facts (10 brand flash, 8 instance isolation).
- The one failure is `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
and it is **flaky by environment, not a regression** — 2 fail / 1 pass in isolation. Its own doc
comment: "Requires an interactive desktop session: if the window is covered or the session is
locked, the pointer no-ops and the drag never lands." The symptom matches exactly (order
unchanged). **Run the suite with ytLive CLOSED** (see `MyMistakes.md` 2026-08 era note).
- `RealAppHost.RunAsync` was added for this: a frame-pump test MUST `await` inside it. A blocking
wait in `RealAppHost.Run` occupies the one shared STA thread and hangs the whole suite with no
output. **Always background a `vstest` run and poll the log** — foreground piped `vstest` returns
nothing in this shell even on success.
## Landmines
- A stale `ytLive.exe` (PID 2544) locked `bin/.../ytLive.exe` and broke `dotnet run` with MSB3027.
Killed. **If the build fails to copy `ytLive.exe`, check for a running instance first.**
- `GlobalHotkeys`: two instances registering the SAME global hotkey — Windows refuses the second.
Left alone deliberately.
- `OverlayHost.xaml` says Polar unlocks alerts too; alerts are not gated. Unresolved, queued.
## Next
1. Push the two commits when the creator asks.
2. Test-console chat UX (queued): don't clear the chat window, 20px right padding on the pull-out
input, Enter inserts a newline.
3. Decide the alert-gating copy question above.
4. Ship-checklist items live in `TASKS.md` → "1.0 gates".
+58
View File
@@ -989,3 +989,61 @@ in the same panel) fixed it with no other change. LESSON: **when a UI-only chang
real-input test, the control's data-binding style is the suspect, not the feature** — and
bisect by reverting ONE file (`git checkout Controls/LeftPanel.xaml`) before theorising.
Prefer the idiom already in the file you are editing.
---
## 2026-09-26 — A blocking wait on the shared `RealApp` STA thread hangs the ENTIRE suite silently
**Symptom:** `vstest` printed 4 lines and then nothing. No test ever completed, no timeout, no
stack trace. Ran it three times, plus after a full rebuild and a reboot. Looked exactly like a
broken test runner.
**It was not the runner.** Two things were stacked:
1. **The runner output was being swallowed by my own shell plumbing.** Running `vstest` in the
foreground with a pipe to `tail` produced *zero* bytes even when the run finished. Launching it
with `nohup … > log 2>&1 &` and polling the log worked every time. **Recipe: always background
a `vstest` run and poll the log file.** Never trust a foreground piped `vstest` in this shell.
2. **The real hang was my test.** `LayerReorderPersistenceTests` and friends share ONE STA thread
via the `RealApp` collection. I had written the frame-pump tests as
`_app.Run(() => { pump.StartAsync(ct).GetAwaiter().GetResult(); … })`. `Run` *occupies* that
single thread, so the pump had nowhere to run and the two deadlocked — permanently, with no
timeout to save it.
**The rule:** a test that drives the frame pump MUST be `async` and use the new
`RealAppHost.RunAsync(Func<Task>)`, with plain `await pump.StartAsync(...)`. `await` yields the
STA thread back to the dispatcher; `.GetAwaiter().GetResult()` holds it hostage. **Never put a
blocking wait inside `RealAppHost.Run`.** A collection-serialized UI thread turns a missing
`await` into a whole-suite hang, which is why this presented as "the runner is broken."
**Also:** isolate before fixing. Running just the suspect class proved the runner was fine and my
test was the hang. Three blind retries on the same command would have "confirmed" a false theory.
---
## 2026-09-26 — Two real bugs my own new tests caught, and the cadence trap
Both were found only because the tests asserted the creator's *stated numbers* rather than
"something was drawn":
1. **Interval cadence implemented as an absolute deadline.** I had `_sinceStart` accumulate forever
and compared it to `_untilNextFlash`, which I *replaced* with a new interval on each fire. That
makes the second value an absolute time, not an interval, so gaps **shrink every cycle**:
measured 6.7s, 3.5s, 2.8s instead of 30–60s. Fix: keep an absolute `_nextFlashAt` but set it to
`_sinceStart + interval` on each fire, so the interval runs *from the last flash*. General trap:
whenever a timer holds "when did I last do X" and "when next", decide explicitly whether the
period is relative to the last event or to the start.
2. **A fade-in that starts at exactly 0 opacity emits a blank first frame.** `OpacityAt(0) == 0`, so
`Frame` returned null on the tick right after `Show()`. Not wrong (it is the start of a fade) but
it wasted a tick and made "is a credit on screen right now?" answer false. Worth knowing before
writing the assertion — my test's arithmetic was wrong, not the renderer, and I nearly "fixed"
the renderer to match the bad test.
**The lesson:** pin the *numbers the creator asked for* (2s, 30–60s, fully-on-canvas). "It rendered
something" passes against almost any implementation and caught neither bug.
**Third trap, cost me a wasted verification:** to prove a `const` isn't shipped I grepped the
Release binary for it — and it was absent from the *Debug* binary too, because **consts are inlined
at compile time and never reach metadata**. The control (run it against Debug as well) is what
exposed that. Grep for a **field/type** (`InstanceVariable`) or use reflection; never grep for an
inlined literal, and always run the check against the build you expect it to FAIL in.
+401
View File
@@ -0,0 +1,401 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
namespace ytLive.Services.Compositor;
/// <summary>
/// The free-tier branding flash: a "made with LlamaCasty!" credit that is COMPOSITED
/// INTO THE OUTPUT (recording or stream), not merely shown in the preview.
/// <para>2026-09-26. This used to exist only as a WPF <c>BrandFlashLayer</c> in
/// <c>PreviewPane.xaml</c> — a 0.25-opacity centred TextBlock the creator could see and
/// no viewer ever could. The compositor has carried an unused <c>flashFrame</c> slot
/// since then; this type is the producer that finally feeds it. It is wired on the
/// PER-FRAME path only: <c>SceneGraph.GetBakedBase</c> also accepts a <c>flashFrame</c>,
/// and that one is a trap — <c>SceneRegion.Matches</c> compares element ids only, so a
/// flash folded into the baked static base would freeze there and never expire.</para>
/// <para>The credit is deliberately obnoxious (creator directive 2026-09-26): full-opacity
/// neon, a different random position every presentation, and a hard sell every 30–60s.
/// It is gated on LICENCE state, never on a UI toggle — see <see cref="Enabled"/>.</para>
/// </summary>
public sealed class BrandFlashPresenter : IDisposable
{
/// <summary>The credit line, kept identical to the old preview TextBlock so the
/// wording can never drift between the preview and the broadcast.</summary>
public const string Text = "made with LlamaCasty!";
// Presentation envelope (creator-specified 2026-09-26): 500ms fade in, the credit
// held at full opacity for 1000ms, then a 500ms fade out — 2000ms end to end.
public const double FadeInSeconds = 0.5;
public const double HoldSeconds = 1.0;
public const double FadeOutSeconds = 0.5;
public const double PresentationSeconds = FadeInSeconds + HoldSeconds + FadeOutSeconds;
/// <summary>Cadence: <c>rand(0, 30s) + 30s</c> ⇒ a flash every 30–60s, so it never
/// lands on a beat a viewer could tune out.</summary>
public const int IntervalFloorMs = 30_000;
public const int IntervalJitterMs = 30_000;
/// <summary>The first flash lands shortly after go-live (the stream-start
/// impression); the random cadence takes over from there.</summary>
public const double FirstFlashDelaySeconds = 5.0;
/// <summary>Master-space canvas. Global overlays in this codebase are authored at
/// master size and scaled by the compositor.</summary>
public const int MasterWidth = 1920;
public const int MasterHeight = 1080;
/// <summary>Neon recipe taken from the established overlay look (see commit
/// message): a bright near-white CORE plus a feathered multi-radius HALO, with the
/// red and blue channels pushed in OPPOSITE directions — Maxon's "Chromatic Glow"
/// notes the green channel is all but invisible behind white text once R and B are
/// split, which is exactly the look.</summary>
private static readonly Color NeonRed = Color.FromRgb(0xe9, 0x45, 0x60); // brand red, exact
private static readonly Color NeonBlue = Color.FromRgb(0x4d, 0x8b, 0xff); // brand blue #0f3460 lifted for luminance
private static readonly Color CoreColor = Colors.White;
/// <summary>Font size chosen so the credit spans roughly half the master width:
/// big enough to read as advertising, small enough that the random placement has
/// real travel room (a full-width line could only ever sit at x≈0).</summary>
private const double FontSize = 120;
private const double GlowPadding = 34;
private const double TickSeconds = 0.033;
private readonly Random _random;
private readonly DispatcherTimer? _timer;
private readonly object _gate = new();
private bool _enabled = true;
private bool _running;
private double _sinceStart;
private double _nextFlashAt; // absolute time of the next flash, _sinceStart's reference
private double _elapsed; // seconds into the presentation; >= PresentationSeconds means idle
private VideoFrame? _raster; // tight, full-opacity glowing credit (straight alpha)
private byte[]? _buffer; // reused master-space BGRA — hence the Epoch bump below
private int _atX, _atY;
private long _epoch;
public BrandFlashPresenter(Random? random = null)
{
_random = random ?? new Random();
if (Application.Current is { } app)
{
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(TickSeconds) };
_timer.Tick += (_, _) => Advance(TickSeconds);
}
}
/// <summary>The licence gate, driven by <c>MainViewModel.IsPremium</c> (Polar
/// validation) and NOT by a user-facing toggle: once this credit is composited into
/// the broadcast it is the thing standing between a paying customer's stream and an
/// advertisement, so it is re-checked on every read. A premium user mid-presentation
/// loses the flash on the very next tick rather than at the end of the credit.</summary>
public bool Enabled
{
get { lock (_gate) return _enabled; }
set
{
lock (_gate)
{
if (_enabled == value) return;
_enabled = value;
if (!value)
{
// Licence state only ever goes premium → no flash. Cut the current
// presentation short instead of running it out ungated.
_elapsed = PresentationSeconds;
_timer?.Stop();
}
}
}
}
/// <summary>True while a credit is on screen (drives the preview's visibility).</summary>
public bool IsPresenting
{
get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; }
}
/// <summary>Begin the cadence: first flash <see cref="FirstFlashDelaySeconds"/>
/// after the call, then every <c>rand(30s) + 30s</c>.</summary>
public void Start()
{
EnsureRaster();
lock (_gate)
{
_running = true;
_sinceStart = 0;
_nextFlashAt = FirstFlashDelaySeconds;
}
if (_enabled) _timer?.Start();
}
public void Stop()
{
lock (_gate)
{
_running = false;
_elapsed = PresentationSeconds;
}
_timer?.Stop();
}
/// <summary>Advance the presentation clock. Also the deterministic test seam: tests
/// drive this directly and never call <see cref="Start"/>, so no timer is running
/// and the clock only moves when the test moves it (mirrors <c>AlertOverlayLayer.Advance</c>).</summary>
public void Advance(double deltaSeconds)
{
// Rasterise BEFORE taking the lock. WPF text formatting is UI-thread affine, so
// this can hop to the dispatcher — and a blocking hop while holding _gate would
// deadlock against a timer tick that wants the same lock.
EnsureRaster();
lock (_gate)
{
if (!_enabled) return;
if (_running)
{
_sinceStart += deltaSeconds;
if (_sinceStart >= _nextFlashAt)
{
// The interval runs FROM THIS FLASH, not from Start(). An absolute
// deadline here made the gaps shrink every cycle (5s, then whatever
// was left of the previous interval) and collapse toward zero.
_nextFlashAt = _sinceStart + NextIntervalSeconds();
BeginPresentation();
}
}
if (_elapsed < PresentationSeconds)
{
_elapsed += deltaSeconds;
if (_elapsed >= PresentationSeconds) _elapsed = PresentationSeconds;
}
}
Notify();
}
/// <summary>Invoked on the UI thread at the end of every tick with the frame that
/// tick produced (null when nothing is due) — the preview's repaint hook, so the
/// preview and the broadcast are driven by ONE clock.</summary>
public Action<VideoFrame?>? OnFrame { get; set; }
private void Notify()
{
var cb = OnFrame;
if (cb == null) return;
// Read OUTSIDE the state lock: the callback is arbitrary code and must never
// run while _gate is held.
var frame = Frame;
if (frame == null) { cb(null); return; }
if (Application.Current is { } app && !app.Dispatcher.CheckAccess())
{
// Off the UI thread (a test driving the clock). The frame's buffer is
// recycled, so hand the deferred copy its own pixels.
var copy = new byte[frame.BgraPixels.Length];
Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length);
app.Dispatcher.BeginInvoke(() => cb(new VideoFrame(frame.Width, frame.Height, copy)));
}
else
{
cb(frame);
}
}
/// <summary>Force a presentation now — the "show me the flash" path and the test seam.</summary>
public void Show()
{
EnsureRaster();
lock (_gate)
{
if (!_enabled) return;
BeginPresentation();
}
}
private double NextIntervalSeconds()
=> (IntervalFloorMs + _random.Next(IntervalJitterMs)) / 1000.0;
private void BeginPresentation()
{
if (_raster == null) return;
_buffer ??= new byte[MasterWidth * MasterHeight * 4];
// A new presentation can land anywhere, so the previous credit's pixels must go
// before this one is written or the old spot ghosts through the fade. One 8MB
// clear per presentation (not per tick) keeps that cheap.
Array.Clear(_buffer);
var spot = ComputePlacement(_raster.Width, _raster.Height, MasterWidth, MasterHeight, _random);
_atX = (int)spot.X;
_atY = (int)spot.Y;
_elapsed = 0;
}
/// <summary>
/// The credit for the output compositor and the preview: a master-space frame holding
/// the glowing text at this presentation's random spot, alpha-scaled to the current
/// point on the fade envelope. Null when idle, or unlicensed.
/// <para><b>The frame's pixel buffer is recycled across ticks</b> (only the text rect
/// is rewritten) and its <see cref="VideoFrame.Epoch"/> is bumped every read, so the
/// compositor's buffer-identity paste cache can never false-hit a stale credit.
/// Consumers blit synchronously — copy the pixels if you need to keep them.</para></summary>
public VideoFrame? Frame
{
get
{
lock (_gate)
{
if (!_enabled || _elapsed >= PresentationSeconds || _raster == null || _buffer == null)
return null;
var opacity = OpacityAt(_elapsed);
if (opacity <= 0) return null;
Paint(_buffer, _raster, _atX, _atY, opacity);
return new VideoFrame(MasterWidth, MasterHeight, _buffer) { Epoch = ++_epoch };
}
}
}
/// <summary>Full → hold → empty. Public and static so the preview and the tests
/// agree with the renderer instead of re-deriving the curve.</summary>
public static double OpacityAt(double elapsedSeconds)
{
if (elapsedSeconds < 0 || elapsedSeconds >= PresentationSeconds) return 0;
if (elapsedSeconds < FadeInSeconds) return elapsedSeconds / FadeInSeconds;
if (elapsedSeconds < FadeInSeconds + HoldSeconds) return 1.0;
return 1.0 - ((elapsedSeconds - (FadeInSeconds + HoldSeconds)) / FadeOutSeconds);
}
/// <summary>
/// A random top-left offset that keeps the whole credit ON the canvas. The text is a
/// single pre-measured line, so it can never wrap — this is the "how far can the
/// left margin travel" limit the creator described: <c>0 … (canvas - creditWidth)</c>
/// horizontally, likewise vertically. Pure + static, so the rule is directly testable.
/// </summary>
public static Point ComputePlacement(int creditWidth, int creditHeight,
int canvasWidth, int canvasHeight, Random random)
{
var maxX = Math.Max(0, canvasWidth - creditWidth);
var maxY = Math.Max(0, canvasHeight - creditHeight);
return new Point(random.Next(0, maxX + 1), random.Next(0, maxY + 1));
}
/// <summary>Rewrite the credit rect into the master buffer with alpha scaled to
/// <paramref name="opacity"/>. The rect is fully overwritten each tick and the spot
/// is fixed for the whole presentation, so nothing stale accumulates and no clear
/// pass is needed here.</summary>
private static void Paint(byte[] buffer, VideoFrame raster, int atX, int atY, double opacity)
{
for (var y = 0; y < raster.Height; y++)
{
var dst = ((atY + y) * MasterWidth + atX) * 4;
var src = y * raster.Stride;
for (var x = 0; x < raster.Width; x++)
{
var a = raster.BgraPixels[src + x * 4 + 3];
if (a == 0) continue;
buffer[dst + x * 4] = raster.BgraPixels[src + x * 4];
buffer[dst + x * 4 + 1] = raster.BgraPixels[src + x * 4 + 1];
buffer[dst + x * 4 + 2] = raster.BgraPixels[src + x * 4 + 2];
buffer[dst + x * 4 + 3] = (byte)Math.Round(a * opacity);
}
}
}
/// <summary>Build the credit raster once, on the UI thread, and publish it under
/// the lock. Called from every clock entry point, so it is a no-op after the first.</summary>
private void EnsureRaster()
{
lock (_gate) { if (_raster != null) return; }
var raster = RenderNeonText(Text);
lock (_gate) { if (raster != null) { _raster = raster; } }
}
// ── rasterisation ──────────────────────────────────────────────────────────
/// <summary>
/// Rasterize the credit once, tight to the glyphs plus glow, on the UI thread (WPF
/// <c>RenderTargetBitmap</c>, Pbgra32→straight alpha exactly like
/// <see cref="AlertTickerRenderer"/>) and cached — this is per-text work, not
/// per-frame, so the pump can afford to run every tick at 30fps.
/// <para>The neon is a bright core plus a feathered halo: the glyphs redrawn at
/// shrinking radii with rising alpha, the two colours offset in opposite directions,
/// which is what makes it read as a lit tube rather than a drop shadow.</para></summary>
private static VideoFrame? RenderNeonText(string text)
{
if (Application.Current is { } app && !app.Dispatcher.CheckAccess())
{
VideoFrame? result = null;
app.Dispatcher.Invoke(() => result = RenderNeonText(text));
return result;
}
var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal,
FontWeights.Bold, FontStretches.Normal);
var probe = new FormattedText(text, CultureInfo.InvariantCulture,
FlowDirection.LeftToRight, font, FontSize, Brushes.White, 1.0);
var w = (int)Math.Ceiling(probe.Width) + (int)(GlowPadding * 2);
var h = (int)Math.Ceiling(probe.Height) + (int)(GlowPadding * 2);
if (w <= 0 || h <= 0) return null;
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
// Three halo rings per colour. Wide+dim first, then progressively tighter and
// brighter, so the falloff is feathered instead of a hard-edged blob.
foreach (var (radius, alpha) in new[] { (30.0, 0.16), (20.0, 0.26), (11.0, 0.40) })
{
DrawHalo(dc, text, font, radius, alpha, NeonRed, +1);
DrawHalo(dc, text, font, radius, alpha, NeonBlue, -1);
}
// A tight white bloom hugging the glyphs sells the brightness of the tube.
DrawHalo(dc, text, font, 4.0, 0.55, CoreColor, +1);
// The core itself, sharp and opaque, drawn last.
dc.DrawText(new FormattedText(text, CultureInfo.InvariantCulture,
FlowDirection.LeftToRight, font, FontSize, new SolidColorBrush(CoreColor), 1.0),
new Point(GlowPadding, GlowPadding));
}
var bitmap = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var pixels = new byte[w * h * 4];
bitmap.CopyPixels(pixels, w * 4, 0);
// Pbgra32 is premultiplied — unpremultiply, or straight-alpha source-over in the
// compositor haloes the glyph edges.
for (var i = 0; i < pixels.Length; i += 4)
{
var a = pixels[i + 3];
if (a == 0 || a == 255) continue;
pixels[i] = (byte)(pixels[i] * 255 / a);
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
}
return new VideoFrame(w, h, pixels);
}
private static void DrawHalo(DrawingContext dc, string text, Typeface font,
double radius, double alpha, Color color, int direction)
{
var brush = new SolidColorBrush(color);
const int spokes = 12;
for (var i = 0; i < spokes; i++)
{
var angle = 2 * Math.PI * i / spokes;
brush.Opacity = alpha;
var ft = new FormattedText(text, CultureInfo.InvariantCulture,
FlowDirection.LeftToRight, font, FontSize, brush, 1.0);
dc.DrawText(ft, new Point(
GlowPadding + Math.Cos(angle) * radius * direction,
GlowPadding + Math.Sin(angle) * radius * direction));
}
}
public void Dispose() => _timer?.Stop();
}
+7 -1
View File
@@ -359,7 +359,13 @@ public sealed class SceneCompositor
}
}
private static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0)
/// <summary>Copy <paramref name="frame"/> and blit a global overlay onto the copy
/// at (sx0, sy0) in crop space. internal (not private) because the frame pump's
/// fully-static path needs it: that path stretches the cached bake straight to the
/// output, so any per-frame overlay — social bar, alert ticker, brand flash — has to
/// be composited here first. Copying is the whole point: the bake is cached, and a
/// flash written straight into it would freeze there and never expire.</summary>
internal static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0)
{
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
+34 -7
View File
@@ -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)
+18
View File
@@ -167,6 +167,24 @@
---
## 1.0 gates (do these before the first shipped build)
- **Kill the dev multi-instance affordance.** `Helpers/InstanceProfile.cs` is already entirely
`#if DEBUG`, so a Release build ships as `DataRoot => DefaultRoot` / `WebViewDataFolder => null`.
Nothing to do but *keep it that way* — and re-prove it:
`InstanceIsolationTests.TheAlternateProfile_IsConfinedToTheDebugBuild` (source-level, always runs)
plus a Release build in which the `InstanceVariable` **field** is absent from metadata.
⚠️ Do NOT grep the binary for `YTLIVE_INSTANCE` — consts are inlined and appear in neither build.
- **TASK 36 is now shipped** — the branding flash composites into recordings and the stream. Test
VODs from an unlicensed instance carry the credit; use a license key for clean captures.
- Still queued from earlier: `scripts/publish.sh` (self-contained win-x64, no test artifacts,
Debug-only `InternalsVisibleTo`, no WPF trimming), and the `LlamaCasty.exe` assembly rename
(three pack URIs incl. `MainWindow.xaml:14` — do not rename `%APPDATA%\ytLlive`).
- Decide the open business question: `OverlayHost.xaml` copy says Polar unlocks *alerts* too, but
alerts are not gated by `IsPremium`. Either fix the copy or gate the alerts — do not leave it.
---
## YouTube Live API research facts
See [`TASKS/research-youtube-api.md`](TASKS/research-youtube-api.md) — authoritative facts for v3 build.
+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).
}
+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
+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);
}
}
+26
View File
@@ -68,6 +68,32 @@ public sealed class RealAppHost : IDisposable
throw failure;
}
/// <summary>Awaits <paramref name="action"/> on the App's STA thread and rethrows
/// any failure.
/// <para>A test that drives the frame pump MUST use this and <c>await</c> — never
/// <c>Run()</c> around a blocking wait. <see cref="Run"/> occupies the one STA
/// thread this collection shares, so a blocking call inside it deadlocks the pump
/// against the dispatcher and hangs the entire run with no output: the frame pump's
/// <c>StartAsync</c> needs to keep pumping while the test waits for a frame. Awaiting
/// yields the thread instead of holding it.</para></summary>
public async Task RunAsync(Func<Task> action)
{
Exception? failure = null;
await _dispatcher.InvokeAsync(async () =>
{
try
{
await action();
}
catch (Exception ex)
{
failure = ex;
}
});
if (failure != null)
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw();
}
public void Dispose()
{
try