Files
LlamaCasty/ai.md
T
gramps 769c616706 Preview: promote selected layer for hit testing
Temporarily raise the selected preview item and prioritize it in the custom hit test without changing scene order. Refresh integration-test expectations for Brand logo layers and explicit thumbnail assets.

WPF references: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.panel.zindex?view=windowsdesktop-10.0 https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer
2026-10-28 13:09:15 -07:00

1309 lines
126 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ytLlive — AI Guide
> Memory map entry point. Conventions live in [`schema.md`](schema.md); task
> status and YouTube API research in [`TASKS.md`](TASKS.md); brand/marketing
> palette and launch strategy in [`MARCOM.md`](MARCOM.md) (gitignored — read
> it before any branding or marketing work); directory maps in each folder's
> `index.md`. Reading order: this file → `TASKS.md` → `MARCOM.md` → `<dir>/index.md` → source.
>
> **Subsystem detail is split out:** large blocks of this file now live under
> [`docs/ai/`](docs/ai/index.md) and this file keeps a short invariant stub + link where each was
> removed. Open [`docs/ai/index.md`](docs/ai/index.md) to find the right one, or ignore it entirely
> until your task touches a moved subsystem.
## Response style
No default "Plans & Pitfalls" / planning boilerplate. Respond directly and
concisely: **do the queued work, then report what changed and what's next.**
Skip feature pitch, step-by-step implementation plans, pros/cons tables, and
"potential pitfalls" sections unless the user explicitly asks for a plan first.
A short diff-style summary beats a proposal document every time.
## Derivative work (spin guard mandated in AGENTS.md)
Nothing we build is novel — streaming/overlay/WebView2 problems were solved by
OBS, CEV, and WPF ecosystems long before us. Two consequences:
1. **Proactive:** new features start with a quick external scan, not head-first
design (citation goes in the commit message).
2. **Objectively triggered spin guard:** a second failed fix for the same symptom
means STOP theorizing and research the established answer externally; there is
no unique bug in this repo that the wider ecosystem hasn't hit.
This project's hard lesson: the web-source bounding saga (2026-08) burned 9
commits rediscovering that a browser source is a *fixed canvas* — OBS keeps it at
a stable page size, crops/hugs content via the box, and clips at the edge. The
apparent "gap" that ended the saga was a widget's own CSS glow effect, not a bug.
**Derived-solution rule (2026-08-29, driven by the image-shrink incident):** when
you work out any one-off, reusable solution (recipe / workaround / how-to), write
it into `MyMistakes.md` → **Recipes registry that same session**, and grep it
before ever re-deriving. A solution recorded once ends the loop; an un-recorded
one guarantees the user hears it derived again (see `AGENTS.md` → Working rules → 🔬).
## LAN infrastructure (192.168.50.x)
All LAN credentials live in `CREDENTIALS.md` (gitignored). Server IPs and roles:
| Host | IP | Role |
|------|----|------|
| corsair | .132 | Gaming/streaming primary — RTX 5070 Ti (this machine) |
| llamavault | .87 | ODROID-HC4, 12TB RAID5, media storage (NFS). SSH: `ssh mshallop@192.168.50.87`. **DHCP-bound — it moved off `.86` after the 2026-10 power outage, so confirm with a ping before trusting any path.** SMB is reached by the **`llamavault` name**, pinned in the Windows hosts file, never by literal IP: `\\llamavault\llamaVault\...` |
| pivault | .158 | RPi5 8GB, 10.83TB RAID5 NAS |
| jarvis | .210 | AMD Ryzen 5 5600X, RX 6600 XT — AI inference, SearXNG, Docker registry, Immich |
| ultron | .108 | AMD Ryzen 7 7840HS — AI orchestrator, llama-server, Qdrant |
| gordito | .144 | Home Assistant, Alarmo, Zigbee |
| officerfriendly | .26 | Pi-hole DNS |
| octopi | .197 | OctoPrint (Prusa MK4) |
| BigBlinkyRouter | .1 | ASUS ROG GT-BE98 Pro, WiFi 7 |
Web infra (DO droplet): `llamacasty.com` + `llamachile.tube` at `143.244.176.131`.
**Pushing to the Gitea instance (`llgit.llamachile.tube`).** The repo is **`gramps/LlamaCasty`**
(renamed from `gramps/ytLlive`; the old path 301-redirects). **Push over HTTPS**, not SSH:
`origin = https://llgit.llamachile.tube/gramps/LlamaCasty.git`, with the credential helper
`/home/gramps/bin/gitea-credential` (registered as
`credential.https://llgit.llamachile.tube.helper`) reading the account password from the
gitignored `CREDENTIALS.md` on demand — nothing stored in plaintext, nothing in a command line.
**The Gitea account password IS the portal password** in `CREDENTIALS.md`, and the same password
authenticates the REST API (`curl --config …/api/v1/user`) — **that API is the fastest way to ask
the server questions** (the repo's real name and clone URL, the account's SSH keys, a token).
⛔ **The SSH endpoint on `llgit:2214` is not a usable push path**: the account's SSH keys ARE
registered (key id 6 "Corsair VSC" = this machine's `~/.ssh/id_ed25519`) yet the server refuses
the publickey, and the port later refused connections outright. Do not spend a session on key
archaeology — ask the API, then push over HTTPS.
### No-Fluff Mode (on demand)
Invoke with "no-fluff mode" (or similar) when you want ruthless review instead
of reassurance. In that mode:
- Strip all polite pleasantries, emojis, transitions, and conversational padding.
- Treat the user's input as a draft to be methodically deconstructed or
strengthened — argue, correct, and sharpen rather than agree.
- Give unvarnished truth, not reassurance.
This is an occasional, explicitly-invoked mode — **never the default**. The
default response style above stays in effect unless invoked.
## Brand
Brand palette and official assets live in `MARCOM.md` (gitignored — read it for hex values,
asset file names, and marketing copy). The brand red `#e94560` is the primary accent used
throughout the UI.
### Product name vs repo/assembly branding (2026-09-14)
The product is **llamacasty** (llamacasty.com, llamachile.tube, `llgit.llamachile.tube`). The
repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths (`%APPDATA%\ytLlive\...`),
and most internal namings are the legacy **ytLive/ytLlive** — a re-brand that never renamed
the internals. Treat the two as separate: external/user-facing language says "llamacasty",
code/assembly/repo names stay `ytLive`. If a full internal re-brand is ever done, this note
and the UI locator (Premium/wordmark) are the checklists; it is NOT a goal pre-1.0.
**Say "LlamaCasty" when reporting to the creator** — never "ytLive" in prose. `ytLive` is
the internal repo/assembly/namespace name only. Exact current split:
| surface | name | note |
|---|---|---|
| product / site / marketing | **LlamaCasty** | llamacasty.com, llamachile.tube |
| repo dir, csproj, `RootNamespace` | `ytLive` | 230 files: `namespace ytLive.*` |
| `AssemblyName` → shipped exe | `ytLive` | `ytLive.exe` |
| app data dir | `%APPDATA%\ytLlive` | **capital L**, drifts from `ytLive` |
| `Distribution.md` tells customers | `LlamaCasty.exe` | ⚠ **mismatch vs the real `ytLive.exe`** |
**Consequences, so nobody re-hunts them:**
- Renaming `<AssemblyName>` to `LlamaCasty` is the cheap win (the customer-visible exe
name matches what `Distribution.md` already promises; namespaces can stay `ytLive.*`).
It is **4 lines**, but it hard-breaks three pack URIs that spell the assembly name:
`MainWindow.xaml:14` (the **window icon** — taskbar/Alt-Tab), `OverlayHost.xaml:96`,
`RenameRecordingDialog.xaml:5`. Miss those and you ship a broken icon. Queued in TASKS.md
under the shipping task, not done.
- Renaming the `%APPDATA%\ytLlive` folder is **breaking** — it would orphan every existing
user's layout DB, `Assets` and settings. Never do it casually; needs a migration.
- No code compares its own assembly name (no `GetExecutingAssembly().GetName().Name` checks),
so the assembly name is otherwise free to change.
### AI transparency
This project is built through AI-assisted pair-programming. All code is generated
under the supervision of an experienced developer with decades of coding experience.
Architecture decisions, product direction, and quality gates are human-owned;
AI accelerates implementation. This is a testimonial to successful human-AI
collaboration, not autonomous code generation.
## Run
**First time on a fresh clone (or if `ffmpeg/` was deleted) — this is REQUIRED, not optional:**
```bash
./scripts/fetch-ffmpeg.sh # ~65 MB once; verifies SHA-256, extracts 9 files into ./ffmpeg
```
FFmpeg is **bundled, never downloaded at runtime** (Windows Store policy 10.2.2 — the old runtime
download 404'd on 2026-09-01 and would fail certification). The binaries are gitignored because
135 MB of DLLs in git would tax every commit on this 9p tree; the pinned URL + SHA-256 in the script
are the audit trail. `ytLive.csproj`'s `GuardBundledFfmpeg` target **fails the build** with that
exact instruction if the folder is missing. `FFMPEG_ZIP=<path>` reuses a pre-downloaded archive.
```bash
# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project:
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
"/mnt/c/Program Files/dotnet/dotnet.exe" run -c Release # the launch command (creator, 2026-10-16): Release, not Debug — perf takes must read Release numbers
```
**Run-mode convention (creator directive 2026-10-16):** the creator launches with `-c Release`
by DEFAULT. Debug runs happen ONLY when the AI explicitly names the Debug command for a
specific reason — never implicitly, and never for reading performance numbers (Debug pixel
paths are several × slower; every take in the 141ms saga read Debug numbers where Release is
the real reading).
**Pre-commit gate (write it down — I forgot once):** ALWAYS run
`./scripts/verify.sh "<file1>" "<file2>" ...` (declared scope) before committing. It forces a
**clean** build (0 warnings), the full test suite (passes only the 2 known pre-existing `[FAIL]`s), and
the scope check in one shot. An **incremental** build can report `0 Warning(s)` while skipping
recompiles — that's how `CS8601` slipped past on 2026-08-29. Never claim 0 warnings from anything but
`verify.sh`'s clean build.
`EnableWindowsTargeting=true` in `ytLive.csproj` lets a cold restore work from WSL, but a Linux
`dotnet run`/`build` re-downloads 100M+ of `windowsdesktop.app.*` packs into the Linux NuGet cache
(which lacks them) over the slow 9p `/mnt/c` bridge — twice, because the WPF `_wpftmp` generated
project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the
packs resolve from `C:\Program Files\dotnet\packs`, so the Windows host never re-downloads.
Never use `--no-restore` right after an interrupted restore — the stale `project.assets.json`
produces misleading `NETSDK1064` "package not found" errors. Running requires Windows anyway.
## Tests
xUnit in `ytLive.Tests` (net8.0-windows10.0.19041.0, matches the app TFM). Run on Windows —
WSL can't run net8.0-windows tests:
```bash
dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
```
Good Dog Rule: ONE integration test per change (no feature branches pre-1.0 — work lands on
`main`, see AGENTS.md). Current: TokenStore DPAPI
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip,
LayoutStore background roundtrip, LayoutStore HasBackground roundtrip + v5→v6 non-Live backfill,
ScreenCaptureManager refcount + shared-bitmap + coalescing
(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackgroundTests, SceneCatalogTests,
the TASK 25 dirty-layout heal (BackgroundHealIntegrationTests), WebcamSafeguardTests,
SceneCompositorTests (full-scene composite + vertical tier), StretchMathTests, FfmpegLocatorTests,
FfmpegEncoderTests, FramePumpTests, the TASK 8/11/12 audio chain (AudioPipelineTests + MasterLimiter),
the Socials fediverse-heal roundtrip, AboutHubTests, NotificationAreaIntegrationTests (TASK 24),
GlobalHotkeyTests + HotkeyConfigTests (TASK 20), WebcamMenuGateTests (TASK 26), ChatLayerGateTests
(TASK 27), BroadcastPullOutTests (TASK 29), DefaultRecordFolder fallback (TASK 30), WebView2ManagerTests
(TASK 17), RecordingFileTests + OnAirSignTests (TASK 18), SessionTeardownTests +
WebcamOutputKeyTests + GameMeterHonestyTests (2026-09-01 recording-verification fixes) — **ZERO
known failures as of 2026-09-01. `AudioPipelineTests` 25/25 green in 26ms —
the "known failing" `Mix_HonorsProviderGains…` and the class's notorious STANDALONE HANG shared
one root cause: TASK 22's `_delayedMix` (nullable, never initialized) was dereferenced
(`delayed.Length` on null) every live-mix tick — a swallowed NRE starved the pipe the tests
read and flooded startup.log. Test was right, code drifted from the map's own contract
(loopbackGain = GameAudioVolume — now honored; the "unity because loopback scales with the
endpoint" assumption was disproven by the creator's 20%-volume meter observation). The former
RoundClip "known failure" (stale-test layers: namescoped `FindName` + `VisualTreeHelper.HitTest`
where `UIElement.InputHitTest` models input — see MyMistakes) was fixed the same day.
Per-class runs through the Windows dotnet.exe host execute the RealApp/MainWindow suites fine;
only the FULL-suite run still hangs (WASAPI teardown, pre-existing) — and the whole-suite
"247 total" era count is stale; trust per-class results.**
Reward-event capture (monetization awareness, see the Monetization section) will add its integration
tests here when it ships: one real chat-poll payload containing all seven reward event types →
assert the persisted canonical `RewardEvent` rows (type + amount/currency/tier/memberLevel/participant
ids) round-trip into SQLite.
### Launch POSITION is derived, never restored (2026-10-04)
Creator ruling: the window must open in the **upper-left of its screen with ~75px padding**, because
the restore path was putting it below the fold. `MainWindow.RestoreWindowGeometry` now ignores the
saved position entirely and calls the pure `TopLeftPlacement(workArea, w, h, padding)`, which takes
the work-area origin plus `LaunchPadding = 75` and clamps so the window is never pushed off-screen:
a window that fits stays wholly visible, one too big to fit falls back to the work-area origin.
`LaunchPadding` is in **device-independent units**, so it scales with DPI instead of being a fixed
physical pixel count.
**Size and maximized state are still persisted and restored** — only position was dropped. The
clamp still applies to size: saved values are `Math.Clamp`ed to `[MinWidth, work.Width]`, which is why
a saved 800×500 comes back as 1366×768 (the XAML `MinWidth`/`MinHeight`). Reverting this is
reverting the one block in `MainWindow.xaml.cs`; the saved values are still written, so nothing is lost.
**Why the old clamp was wrong (do not "fix" it back):** it tolerated `Left` as low as
`work.Left - width + 120` and `Top` up to `work.Bottom - 80` — ranges wide enough to leave the window
almost entirely off-screen while still looking like a successful clamp. A clamp that permits the
window to be ~unreachable is worse than no clamp, because it reads as a guarantee it does not keep.
`BroadcastPullOutTests.Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote` (renamed
from `..._WindowRestores_Clamped_...`) is the integration guard — it asserts the constructor applies
the derived placement for both an on-screen and a wildly off-screen (`-99999`) saved position.
Placement math itself is covered by `WindowPlacementTests` (pure function, no HWND needed).
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
The integration test boots a real `MainWindow` → `MainViewModel` → real `LayoutStore`
(`%APPDATA%\ytLlive\ytLlive.db`). `Shutdown()` on close **saves the layout** (full rewrite:
DELETE all scenes/sources, re-insert), so any source a test adds would be persisted over the
user's real ones — this happened and wiped the real webcam source (DeviceId replaced by the
test's fake `test-camera`). Rule: a test that constructs `MainWindow` MUST first set
`MainViewModel.LayoutPathOverride` to a temp DB path and reset it (plus
`SqliteConnection.ClearAllPools()` + delete) in `finally`. The seam is
`internal static string? LayoutPathOverride` (line ~529 in `MainViewModel.cs`),
`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`.
The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so
save/load round trips are exact: an element removed in the UI (`RemoveElement` →
`scene.Elements.Remove` → `OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
## Architecture
C# / WPF (.NET 8) following MVVM:
| Path | Role |
|------|------|
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition), and now also over `Services/AlertOverlayLayer.cs` (TASK 43 — the native alert box, same true-component pattern)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see [`docs/ai/frame-pipeline.md`](docs/ai/frame-pipeline.md) → "Scene compositor")**, **alerts: `ChatOverlayLayer` + `AlertOverlayLayer`/`AlertRenderer` (the native celebration-zone overlay, TASK 43 — see "Native events & alerts")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `ITraxPlayer` (TRAX toggle seam — FakePlayer in tests; the dot is green/red/gray and the error scrolls terse in the desktop-audio bar; MusicVolume fixed 0.5) + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see [`docs/ai/output.md`](docs/ai/output.md) → "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see [`docs/ai/output.md`](docs/ai/output.md) → "Live encoder" + [`docs/ai/frame-pipeline.md`](docs/ai/frame-pipeline.md) → "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, **BuildStamp (wordmark release counter `#N`, incremented by `GenerateBuildStamp` from the local `BuildCounter.txt`; per-build GUID/time are logged to startup.log)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; top bar + health banner; work area has a fixed 360px left column and preview column, top-left readouts/resolution and scene strip above preview, lower left panel + preview; bottom bar is an empty spacer |
### Key patterns
- `ViewModelBase.SetProperty<T>()` for property change notifications
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row; Socials is on the preview rail. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC / ON-AIR pills + signs (TASK 18, 2026-08-29):** the top-center area is now two **sliding pill toggles** (intent) next to two **status signs** (reality). REC pill (`RecordPillOn`) is the local-recording intent — works signed-out; ON-AIR pill (`OnAirPillOn`) is the streaming intent — greyed/disabled until `IsConnected`. The **REC sign** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`) is dark-gray + dim offline and **green-tinged, pulsing only while actually recording** (`IsRecording`), not merely pill-on. The **ON-AIR sign** (`OnAirBrush` = `#555` offline, `#22c55e` live) is green while actually streaming (`IsLive`); the `PRIVATE` badge (`IsLivePrivate`) still shows when the broadcast is private. Both sign labels share the `StatusSignText` style (`Themes/Controls.xaml`) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows while `IsLive || IsRecording`. The connected account's avatar/name shows in the top bar next to the primary button (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
- **True decomposition beats partial-shuffling (2026-08-31, Commit G):** `MainViewModel` is a ~4100-line god-object split into 17 partials — but partials are a *myth of decomposition*: every partial shares the same class, same `SetProperty` state, same collaborators via `Scenes`/`StagedScene`/`IsLive`/`Source.VideoImageSource`, so an AI fetching one partial still reconstructs the whole class. The only thing that actually improves retrieval is a boundary where a feature **owns its own state + collaborators**. `Services/ChatOverlayLayer.cs` is the model (extracted from `MainViewModel.Chat.cs` 194→44): the layer owns the message buffer, `ChatBoxRenderer`, fade/mock timers, preview renders, and the live `RenderFrame`; the VM keeps only the binding surface (`ChatMessages` delegates to `_chatLayer.Messages` so XAML + LeftPanel `CollectionChanged` hold; the two computed gate props stay on the VM because their `OnPropertyChanged` is raised from VM setters). **`Services/AlertOverlayLayer.cs` (TASK 43, 2026-09-24) is a second true component built fresh on the same contract** — it owns the event queue, the 33ms playback ticker, the per-source preview renders and the live `RenderFrame`, with `Advance(double)` exposed as the deterministic test clock; the VM again keeps only the facade (`RenderAlertBox`, `UpdateAlertBoxPreview`, `CanAddAlerts`). Two COMPONENT layers, one facade partial. **Diagnostic — glue vs. component:** before extracting, count `OnPropertyChanged`/`SetProperty` touches (glue) and bound-property reads per partial. Zero/low-glue + a cohesive state blob (renderer/timers/buffer) = extract (chat). High glue over an already-extracted service (Audio, Webcam, Background, Scenes — `AudioMixer`/`CameraManager`/`ScreenCaptureManager`/`ChatBoxRenderer` already exist) = leave as VM glue; forcing it adds coupling, doesn't remove it. Don't manufacture seams to hit a line count — the line count is a guideline for context, not a design goal.
- **SceneGraph component + baked-crust compositor (TASK 31):** `Services/SceneGraph.cs` owns the scene collection (`Scenes` — the ViewModel's `Scenes` property delegates to it) and the element mutation surface (`AddElement`/`InsertElement`/`RemoveElement`/`MoveElement`, each invalidating the bake cache) plus queries that were scattered LINQ (`GetBackground`/`GetWebcam`/`GetChatBoxes`/`GetSplitPoint`/`IsStatic`). Elements expose `ElementKind Kind` (`Static` = images/background art, `Dynamic` = webcam/live capture/chat/web). The **split point** is the index of the first dynamic element; `SceneGraph.GetBakedBase` bakes/caches all static layers below it (keyed by scene id + static element identities), and `SceneCompositor.BakeStaticBase`/`CompositeLayers`/the `Render(.., staticBase, split)` overload composite the dynamic/above-split layers per frame. `FramePump.RenderScene` uses the optimized path when a `SceneGraph` is wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (via `InvalidateBake` from the VM's element-property and background-heal paths). **Defensive deviation from the TASK 31 spec:** `ChatOverlayLayer` keeps taking `IEnumerable<Scene>` instead of depending on `GetChatBoxes()` — it is deliberately decoupled from the graph (its doc comment says "without owning the scene graph"); and the static background helpers (`EnsureBackground`/`NormalizeBackgrounds`) stay on the ViewModel because `BackgroundTests.cs` unit-tests `MainViewModel.EnsureBackground` directly. Queries + invalidation moved; helpers stayed.
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load
- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug).
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox`, the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back — **but that guard sees only those three control types**, so a picker built as something else (a `Button` + a code-built `ContextMenu`) slipped through it and the creator had to ask twice (TASK 63)
- **The properties panel is a HIERARCHY (TASK 59, 2026-10-21):** a **title owns its content, and the content is indented one level (10px) under it** — the creator's ruling: *"the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur."* So the webcam's block reads `Shape` (the mirror switch + the shape choice) → `Geometry` → `Border Color` → `Border Thickness` → `Background` (Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px `#a0a0b0` like the existing ones. **The shape is a CHOICE, not a toggle button:** `IsRectShape`/`IsRoundShape` on `SceneElement` are two settable bools and the panel shows them as two `SegmentToggle` options (Rect | Circle) with **Rect selected by default** (an element that never picked a shape is a rect) — the old single button showed the *next* action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips through `ToggleClipShape()` and both props are re-raised), **not** in a WPF `RadioButton` group — a RadioButton group unchecks its siblings by setting `IsChecked` in code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`
- **One list for the offered colours (TASK 60):** the border-colour drop-down builds its menu from
`Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` + `NameOf(hex)` — **empty** for a colour
outside the palette, "no name" being the honest answer), NOT from a list inside
the click handler — the creator asked for a colour NAME next to each hex (`#e94560 Crimson`), and a
hard-coded `string[]` cannot carry one. A palette the creator may want to extend belongs in a
testable catalogue, with the UI as a consumer. **TASK 62:** the field that shows the *current* colour
shows the name beside it too (`SceneElement.BorderColorName`), and the hex field keeps a fixed width
so the name stays next to the hex instead of drifting to the row's far edge
- **A picker IS the app's drop-down (TASK 63, 2026-10-21):** the border colour is now a
**`YtComboBox`** — the same widget the Display and Font pickers wear — not a `...` button that
built a `ContextMenu` in code. The creator's complaint (*"one of the first things we did was define
branding. We established look-and-feel standards. And yet you keep ignoring these"*) had a precise
cause: the TASK 58 sweep looked for `ComboBox`/`CheckBox`/`TextBox` **types**, and the border picker
was neither — it was a hand-rolled `Button` + code-built menu, so it was never in scope, and the
guard could not see it. **Lesson: a style sweep must ask "is this the app's widget?", not "is this
control type themed?"** — anything hand-rolled that *looks* like a picker (a button that opens a
menu) is invisible to a type-based guard. The hex field stays (a custom colour can still be typed);
the drop-down carries the swatch and the colour's NAME, the rows carry swatch + hex + name.
Guard: `ytLive.Tests/BorderColorPickerTests.cs`
- **A ComboBox's CLOSED box and its ROWS can differ — only through a template selector (TASK 63):**
WPF copies `ItemTemplate` into `SelectionBoxItemTemplate`, and that property is **read-only in
C#** ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned"), so a plain
`ItemTemplate` renders the SAME content in the closed box and in every row. The one mechanism that
tells the two apart is an `ItemTemplateSelector`: WPF calls it with the `ContentPresenter`'s
**templated parent** — the **`ComboBox`** when the closed box renders, the **`ComboBoxItem`** when a
row renders. `Helpers/ClosedBoxTemplateSelector.cs` does exactly that (`ClosedTemplate` for the
ComboBox, `RowTemplate` for the ComboBoxItem). A `ComboBoxItem.ContentTemplate` set through an
`ItemContainerStyle` does **not** work: WPF overwrites the container's `ContentTemplate` with the
ComboBox's `ItemTemplate` (null) — so use the selector.
- **The theme's drop-down popup sizes to its CONTENT, not to the ComboBox (measured 2026-10-21):**
the `YtComboBox` popup carries no width binding, so the pop-up is as wide as its widest row
(measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped,
and the popup may be wider than the closed box. Measure (`RenderTargetBitmap`/`ActualWidth` in a
throwaway test), don't guess. **And a `Popup`'s visual root (`PopupRoot`) has a `null` visual
parent (`VisualTreeHelper.GetParent(popupRoot) == null`)** even though `PopupRoot` IS a `Visual`:
its only link back to the `Popup` and the owning `ComboBox` is `LogicalTreeHelper.GetParent`. Any
ancestor walk used by a window-level click-outside guard (`LeftPanel.IsDescendantOf`) MUST fall
back to `LogicalTreeHelper.GetParent(child)` when `VisualTreeHelper.GetParent(child)` returns
`null`, or clicking a `ComboBoxItem` inside `PART_Popup` looks like an outside click and
deselects `SelectedElement` before the item click registers (fixed 2026-10-22).
- **A field in a dense panel carries a FIXED width (TASK 61):** a WPF element sizes itself to its
content, and in a `DockPanel` the **last child fills the row** — so a row of same-kind fields looks
broken unless every field has an explicit `Width` AND the row sets `LastChildFill="False"`. The
geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
`ytLive.Tests/GeometryFieldWidthTests.cs`
- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p30@8 and 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug). **The log is SIZE-CAPPED (TASK 55, 2026-10-22):** at 5 MB `startup.log` rolls to `startup.1.log` (one rolled copy kept) — the policy lives in `Helpers/LogRotation.cs` as a pure function over a path, so `AppLog`'s 156-call-site surface never changes and the rule is testable on its own. Guard: `ytLive.Tests/AppLogRotationTests.cs`
### ⚠️ Known issue: screens/layers settings audit — RETIRED INTO THE GOLD PASS (2026-08-22, dispositioned 2026-09-01)
The AI hallucinated through multiple commits that night on background/scene property placement, repeatedly misreading the spec and introducing bugs. Specifically: moved properties to the wrong superclass level, used `new` instead of `override` breaking WPF bindings, removed working code, and added UI elements that weren't spec'd. The audit was never run since. The creator ruled (2026-09-01): it is a **going-gold requirement**, not session debt — the fine-tooth-comb pass (every screen's Background layer properties, pill toggle persistence, context menu visibility, "Add Layer"/"(+)" menu items) is a named checklist item under **TASK 36 (gold pass)** and this landmine is closed here.
### Current limitations / TODOs
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). Sign-out is **explicit only**, and that means **Logout — NOT Change Account**: `StopStream()` does NOT clear the session — TASK 18's 2026-08-29 reversal ("stopping a recording leaves the creator signed in") — the older "graceful End signs out" line here was stale and is corrected against the code (2026-09-01). `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` + `openBrowser` callback (test seams + save hook; services are still constructed in `MainViewModel`). **Change Account must never sign out first** (fixed 2026-09-27): it used to `await SignOutYouTubeAsync(); await SignInAsync();`, and since `SignOutYouTubeAsync` → `TokenStore.Clear()` **deletes** `ytLlive.auth`, cancelling in the Google chooser left the creator logged out of a working account. It is now just `await SignInAsync()` — success overwrites the token via the `sessionChanged` hook, cancel/failure leaves the session untouched. The OAuth flow also **releases its loopback callback port on retry**: abandoning the browser leaves the attempt holding `localhost:8765` for its 5-minute timeout, so a new `AuthenticateAsync` cancels and awaits the abandoned attempt before binding, serialised by a `SemaphoreSlim` (`_authGate`) — without that gate, two overlapping calls both see a free port and race to bind it (the exact failure the takeover prevents)
- **Google Cloud project ownership (recorded 2026-09-27, do not re-hunt):** OAuth ClientId is `198374416003-s8tm3rb5csh91quvlg6mnec6srmrr6md.apps.googleusercontent.com`, so the **Cloud project number is `198374416003`**. That project is owned by the **`llamachile.shop@gmail.com`** ("gramps") Google account. ⛔ **This is NOT the same account as the YouTube channel a creator signs into the app with** — console access and channel identity are separate logins, and assuming they are the same was the trap that stalled the search. Direct link that resolves it in one hop: `https://console.cloud.google.com/apis/credentials?project=198374416003`. If the picker shows no match, it defaults to the **"Recent"** tab — switch to **"All"** before concluding the project is missing.
- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state persists (LayoutStore Settings table — `LicenseKey`/`LicenseValidatedAt`/`IsPremium`, 14-day offline grace)
- `YouTubeStreamService` manages the **variable reusable stream** (shipped 2026-08-16): `GetOrCreateReusableStreamAsync` lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, creating it only on first use (`resolution=variable`, `frameRate=variable`); the stream is cached via `LayoutStore` (`SaveReusableStream`/`LoadReusableStream`, Settings table) and bound at broadcast insert (`boundStreamId`). Health (shipped 2026-08-16): `GetStreamHealthAsync(streamId)` polls `liveStreams?part=status` for `healthStatus` + `configurationIssues[]` → `StreamHealth`; the real API nests them under `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — the parse accepts that object shape and the legacy flat-string shape (fix 2026-09-22: the object shape crashed every poll with `requires an element of type 'String'`); banner decision in pure `StreamHealthReporter`
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED**, **frame-pipeline wiring (TASK 4 ship step 5) is SHIPPED**, **health stats (TASK 4 ship step 6) is SHIPPED**, **one-click go-live + private-only enforcement (TASK 4 ship step 7) is SHIPPED** — TASK 4 (RTMP Ingest) is fully done; full plan in `TASKS.md`
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) shipped — steps 1-7 (Velopack bootstrap, PolarLicenseService, license entry UI, LayoutStore entitlement, BrandFlash toggle, startup re-validation, PremiumUrl wired); Velopack update URL pending:
- TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates
- TASK 19/23 merged: Control Surface UX — director's control room with an explicit EDIT toggle, scene strip above the preview, readouts in the top-left cell, and preview-rail drawers; current behavior is detailed in “Control Surface UX” below
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
- Broadcast metadata pull-out + launch geometry — shipped 2026-08-24 ("Text" tab on the Live screen, see its section below)
- TASK 21: Media source (video file playback) — starting soon videos, BRB loops
- TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards
- These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in `TASKS.md`.
### Control Surface UX (TASK 19/23 foundation; surface redesign started 2026-10-26)
The app is a **director's control surface**, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor.
**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer.
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output.
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand/gear and account surfaces remain at the sides. The gear's left-click menu remains; direct right-click-to-settings is in the current uncommitted UX work (see HANDOFF).
- **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition.
- **Preview rail:** stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls.
- **Central monitor:** shows the staged scene while editing and the program scene when editing is off. Preview transitions snapshot and animate the outgoing picture in every mode; output transitions run while streaming or recording. Go Live itself remains a Cut.
- **Edit safety:** edit availability follows `EditingActive`; it is no longer a hard lock derived from "offline and not recording." Keep the explicit toggle and output/edit behavior distinct.
- **Selected-layer preview access:** a draggable layer selected from the list temporarily gets the highest preview `Panel.ZIndex` and first priority in the preview's custom hit test, so it can be grabbed even underneath a higher scene layer. Clearing/replacing selection restores the preview stack; `Scene.Elements` order is untouched, so output and saved z-order do not change. Guard: `ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs`.
**Scene thumbnails:** `Scene.Snapshot` is a 320×180 compositor render. The strip uses per-scene `ThumbnailAssetId`, then background art, then a placeholder; existing saved `OnAirImageAssetId` can still replace the staged tile. The old hardcoded CRT-thumbnail and on-air seed paths are empty, so a fresh layout no longer imports that artwork. Minis never render live captures. `DESIGN.md` records exploratory Stitch color/type tokens only; it is not a layout spec and has not been reconciled into `Themes/Controls.xaml`.
### Global hotkeys (TASK 20 — step 1 shipped 2026-08-24)
- `Services/GlobalHotkeys.cs`: `HotkeyId` enum (9 actions), `IHotkeyRegistrar` seam, `Win32HotkeyRegistrar` (`RegisterHotKey`, no modifiers), `GlobalHotkeyManager`. Fixed defaults: F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop-audio mute, F9 TRAX play/pause.
- `MainWindow.OnSourceInitialized` creates the manager and forwards `Activated` → `MainViewModel.HandleHotkey`; window `Closed` detaches (unregister all).
- `MainViewModel.HandleHotkey` maps ids to `TransitionToScene`/start-end commands (honoring `CanExecute`)/mute toggles/`TraxLeftClick`.
- Tests: `RegistrarOverride` static seam mirrors `LayoutPathOverride`. **Landmine:** forcing a WPF window's handle with `WindowInteropHelper.Handle` does NOT raise `SourceInitialized` and returns Zero pre-show — use `EnsureHandle()` in tests; it both creates the HWND and fires the event.
### Broadcast metadata pull-out + launch geometry (2026-08-24)
**The "Text" tab** (always visible, 2026-08-24 revision): a white 30px tab with rotated YouTube-red "Text" sits on the right edge of the preview, vertically centered — **shown regardless of login/stream state** (creator decision; original IsLive gating removed same day). Click **toggles** the drawer (`ToggleDrawerCommand`); any left-click outside the form also collapses it (`Window_PreviewMouseLeftButtonDown` checks `TextPullOutHost` ancestry), as does the ✕. The drawer is a 320px dark panel over the preview's right edge (200ms CubicEase on `Width`, `BroadcastForm.IsDrawerOpen` drives it). The Update button still greys until `CurrentBroadcastId` exists.
**Fields — the always-editable snippet/status set** (⚠️ creator premise inverted: contentDetails fields like latency/DVR/embed are editable ONLY in created/ready, NOT while live; they are deliberately absent):
- Title / Description / Tags (comma-separated string → `snippet.tags[]`) / Visibility ComboBox (private/unlisted/public) / Made-for-Kids CheckBox — all live-editable pre- and post-launch
- Scheduled Start is read-only ("set at Go Live")
**Plumbing:** `Models/BroadcastMetadata` POCO; `Services/LiveBroadcastFormViewModel` (owns field state, persists every edit to Settings keys `Broadcast.*` immediately via `Func<LayoutStore>` seam — survives layout re-pointing; owns drawer state; `UpdateBroadcastCommand` gated by `CurrentBroadcastId != null && !IsUpdating`). `YouTubeStreamService.UpdateBroadcast` PUTs `liveBroadcasts?part=snippet,status` and **must echo scheduledStartTime — update replaces the whole snippet part**, omitting it clears the schedule. Go Live prefills the dialog from the form (`BroadcastForm.Title/Description`) and calls `CaptureGoLive(title, description, now)` on confirm. `MainViewModel.CurrentBroadcastId` exposes the private `_currentBroadcastId`; notified on create/end.
**The old Default Stream Title/Description** (un-persisted, App Settings overlay) are DELETED — the form owns those fields end-to-end; Go Live prefill routes through it.
**Launch geometry:** default **1920×1040**, MinWidth **1366**, MinHeight **768**, `WindowStartupLocation=Manual`. Window size/position persist on close via `RestoreBounds` (+ WindowState Normal/Maximized; Minimized treated as Normal) into Settings keys `Window.*`; restored in the MainWindow ctor BEFORE show, clamped to the minimums and the primary `SystemParameters.WorkArea` (a position saved on a since-disconnected secondary monitor falls back on-screen). Multi-monitor restore is not supported yet.
Test seam note: `YouTubeAuthService.SetSession()` is public — tests inject a channel with future `TokenExpiry` so `EnsureToken()` passes offline; pair with an `HttpClient(new StubHandler())`.
### Capture — backdrop, webcam, effects & the vertical tier — moved
Screen/game/window backdrop capture, the webcam seam and sessions, the MediaPipe background effects
and the vertical tier's per-scene camera placement live in
**[`docs/ai/capture.md`](docs/ai/capture.md)** — read it before touching capture, cameras, the
Background row, or anything the 9:16 tier re-places.
Invariants that hold even if you never open that file:
- **The Background is a locked per-screen layer:** exactly one per canonical scene, named
"Background", pinned at index 0, never addable/removable/reorderable — Live is `DisplayCapture`,
every other screen is static art. `NormalizeBackgrounds` heals any layout to that model on load.
- **Never feed a capture row to the `ActiveBackgroundImage` overlay.** That layer is fed by
static-art rows only; a flavor-blind `IsBackground` lookup there permanently covers the live
desktop/game feed (the 2026-08-23 regression).
- **`window:`/`picker:` capture keys are session-scoped and stripped on load** (a recycled HWND
would capture the wrong window), and an explicit window pin beats auto game/desktop detection
while that window is alive.
- **The WinRT interop paths are settled:** read frame bytes with `WindowsRuntimeMarshal.TryGetDataUnsafe`
(the `IMemoryBufferByteAccess` ComImport throws `Invalid cast` on every frame under CsWinRT) and
build the D3D device via `CreateDirect3D11DeviceFromDXGIDevice` (the
QI-for-`IDirect3DDxgiInterfaceAccess` trick returns E_NOINTERFACE on current Windows).
- **`VideoFrame` + the capture interfaces are the seam** — nothing above the WinRT layer leaks WinRT
types; capture is CPU-first (`MemoryPreference.Cpu`, `SharedReadOnly`), the reader-subtype ladder
tries each candidate under its OWN catch (one rejected format must never abort acquisition), and
there is deliberately **no fallback camera** — the user's pick is never overridden.
- **One webcam identity app-wide, placements per scene.** `IsWebcamAttainable` needs a live lock, not
just a saved id; the app keeps its own base ref, so removing every placement leaves the session up.
- **`CameraManager` is the ONE background-effects processing point** — segment once per raw frame,
render per-scene variants keyed by effect+strength, mask smoothed per camera session (0.75 new /
0.25 previous). The raw path stays untouched so a slow model never stalls the webcam, and the
bundled model's provenance is fixed (do not swap in PINTO #109).
- **The vertical tier is geometry only:** `BeginVerticalLayout`/`EndVerticalLayout` park and swap the
live placement, so the compositor and `PreviewPane.xaml` never learn about tiers, and the stored
X/Y/W/H/ClipShape columns stay the **16:9** layout. The Chat camera is rect-only through the MODEL
(`VerticalShapeLocked`), not merely through a hidden button.
### Rendering budget, measurement & the compositor — moved
Frame cadence, per-element composite timing, the scene dump, the render budget and the
`SceneCompositor` design live in **[`docs/ai/frame-pipeline.md`](docs/ai/frame-pipeline.md)** —
read it before changing the render loop, the compositor, or anything that moves per-frame cost.
Invariants that hold even if you never open that file:
- **Every global overlay must be composited on EVERY render path** (pump, preview/thumbnails, scene
dump — `SceneCompositor.Overlay` is shared for all three). A feature wired into one path is silently
absent from the output; that bug (flash + social bar "preview-only forever", 2026-09-26) is why the
rule exists.
- **`VideoFrame.Epoch` is not optional.** A producer that recycles buffers MUST bump `Epoch` per
frame, or `FramePump`'s buffer-identity paste cache and the full-render signature serve stale
pixels. `BrandFlashPresenter` reuses one 8MB master buffer and stamps `Epoch` per read for exactly
this reason.
- **`StripPool` owns four dedicated `BelowNormal` background threads** for strip-parallel
compositing; strips below 64 rows run inline. Never demote threads inside `Parallel.For` — it runs
on thread-pool threads and starves the scheduler (that mistake once broke audio-mix timing).
- **Read render numbers in Release only.** Debug pixel paths are several× slower; a Release 1080p
composite measures ~1.0–1.1ms against the 16.67ms budget, and only Release takes are evidence.
### Output — ffmpeg, encoder, recording & audio — moved
The bundled `ffmpeg.exe` decision, the encoder subprocess (`FfmpegEncoder`/`IEncoderProcess`), local
recording, and the whole WASAPI capture → filter → mix → named-pipe audio chain live in
**[`docs/ai/output.md`](docs/ai/output.md)** — read it before touching ffmpeg args, the encoder
seams, REC/ON-AIR, or the audio mix.
Invariants that hold even if you never open that file:
- **ffmpeg is BUNDLED, never downloaded or fetched at runtime.** `IFfmpegLocator` checks the bundle
first, PATH second, and throws loudly if neither exists; `GuardBundledFfmpeg` fails the build when
the folder is missing. `TheLocatorCarriesNoNetworkCapability` prevents the old download design
(Store policy 10.2.2, and it 404'd in production on 2026-09-01) from coming back.
- **Args stay pure** (`FfmpegArgs.Build`) and **never use `-re`** — the pump is the pacer; a second
clock on the rawvideo demux fights it. Keyframes `-g fps×4` with a closed GOP, `-bf 0`, yuv420p.
- **Encoder choice is probed, never libx264** (GPL): NVENC → QSV → AMF → OpenH264. Never add a static
or GPL codec path — see the licensing guardrails in `ai.md`.
- **REC and ON-AIR are radio-exclusive by creator ruling.** The dual-output machinery stays generic,
but the UI can never arm both: one ffmpeg, one output, "we're not them".
- **A modal's falsy result must never fall through into its side effect.** Cancel on the recording
save dialog DISCARDS the temp file explicitly (the bug that kept recordings under the default name).
- **Torn-down sessions must leave no lit pill:** every pump death / go-live prep failure runs the full
`StopStream` teardown, and `AudioMixer.StopLive` is idempotent for rollbacks that never started.
- **Audio stop order:** `StopStream` closes the audio pipe (`StopLive` → audio EOF) **BEFORE** stopping
the frame pump (video EOF); the reverse order stalls on pipe backpressure.
- **Audio is honest by measurement, not by ear:** meters show true dBFS (`ToDisplay`), the mix runs
`SpeechLeveler` + `MasterLimiter` (−1 dBFS ceiling), and gains are `Func` seams read live each tick
(`AudioGainProvider`) — a decorative volume control is a bug. Judge loudness with the bundled
ffmpeg (`ebur128`), never the in-app meter alone.
### The live frame pipeline — moved
`FramePump` and the whole path from frame producer to encoder (pacing, pool/paste caches, slice
history, teardown, health stats) live in
**[`docs/ai/frame-pipeline.md`](docs/ai/frame-pipeline.md)**.
Invariants that hold even if you never open that file:
- **rawvideo is stamped by ARRIVAL, never by a timestamp.** ffmpeg assigns pts from frame order at
the declared fps, so supply rate = output speed. The pump duplicates the previous frame on a missed
deadline (correct duration) and **never resets `nextTick` to wall-now** — rebasing turns a hitch
into a silent time-lapse (take-2 lesson, 2026-09-01).
- **Submit never blocks.** The encoder queue is bounded and a drain task owns the stdin write +
flush; on overflow the **newest frame is dropped and counted** (`DroppedFrames`) — overload costs
content, never a frozen or smeared session.
- **Stop order:** `StopAsync` stops the encoder first — flushing the pending queue to EOF so the file
finalizes with no accepted frame lost — **before** awaiting the pump loop; the reverse order
deadlocked on pipe backpressure.
- **Judge output in Release, by the burned `_outputIndex`** — the 6-digit counter burned into each
frame is what lets a recorded file be audited for drops after the fact; stats alone are not proof.
### Social bar (TASK 6 — shipped 2026-08-12, plan in TASKS.md; bar bug-fix branch 2026-08-13)
A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the
output and carries the creator's social links — content-sized, centered, GREEN glow when ON, position is a
**click-toggle top ⇄ bottom** (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum
(YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/
Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/
`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` + `BarEnabled`; `BarJustify` dropped,
column back-compat). Configured in the "Social Media Site Promotion" dialog (`SocialsDialog.xaml` +
`ViewModels/SocialsDialogViewModel`, WPF-free, injected `ISocialValidator` + sign-in/sign-out fakes):
ON/OFF bar switch (schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle;
signed-out → sign-in gate → OAuth; delete → confirm sign-out), row 2 free, rows 3–6 lock icons on
freemium. Service detection (`SocialServiceIcons.DetectService`): URL domain / fediverse `@user@domain` →
**Fediverse** / bare→Website. Validation (`Services/SocialValidator.cs`, `ISocialValidator` seam +
`HttpSocialValidator` default): async GET of the canonical profile URL; 200/redirect = valid, 404/failure =
rejected. Fediverse additionally does a **best-effort nodeinfo lookup** (`/.well-known/nodeinfo` →
`software.name`, stored in `SocialEntry.FediverseSoftware` / the `SocialEntry.Software` column,
column-presence migration, no version bump) so the entry shows the **instance's real logo**
(`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse
honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodeinfo failure still validates
(glyph falls back). If the identity domain's nodeinfo is blocked (YunoHost SSO gates
`/.well-known/nodeinfo` behind the login page) but the bare root 302s to the real instance, the lookup
**follows the root redirect** and asks the resolved host. Cancel is a hard stop: `LookupAsync` takes a
`CancellationToken` (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations
never touch slot state).
**Bar bug-fix branch (2026-08-13) — three changes:**
1. **Positioning is a click-toggle (KISS)** — the original drag set a local `Canvas.SetTop` value that
permanently overrides the `{Binding SocialBarTop}` (a binding can never win over a local value), so the
bar stayed wherever it was dropped. The first drag-snap fix (`SocialBarSnap.Decide` + `ClearValue` on
release) still failed for shaky hands — jitter around the ±6px deadzone snapped the bar up but wouldn't
let it come back down. **Superseded by the user's click-toggle:** clicking the bar flips it top ⇄ bottom
(`MainViewModel.ToggleSocialBarPosition`), the bar rides the binding alone, and `SocialBarSnap` is gone.
2. **Fediverse software self-heal** — the DB row `@gramps@llamachile.tube` had `Software = NULL` because
nodeinfo was only ever asked of the identity domain (a landing page; the real instance is
`mastodon.llamachile.tube`). `HttpSocialValidator.ResolveFediverseSoftwareAsync` now **probes
well-known subdomains** (`mastodon.` → `social.` → … `FediverseSubdomainCandidates`) when the identity
domain and its redirect both come up empty, under a ~15s linked-CTS budget. `MainViewModel` runs the
static `HealFediverseSoftwareAsync` off the UI thread on layout load, applies matches via the dispatcher,
and saves; `SocialEntry.FediverseSoftware` is settable and raises `LogoData`, so the icon updates in place.
3. **The bar renders on the live output** — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a
transparent straight-alpha BGRA strip (1920-wide, 40px content + 24px glow pad, the green glow baked in,
Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame
pipeline").
### Toast notifications (TASK 24 — shipped 2026-08-23, plan in TASKS.md)
Non-blocking user-facing messages replace blocking MessageBoxes. **Seam:** `INotificationService.Show(title,
message, AppNotificationSeverity)` (`Services/INotificationService.cs`) + `Info/Success/Warning/Error`
extension methods; `MainViewModel` news up the real `NotificationService` at its field initializer
(no DI container — the VM is built from XAML). **Library:** `Notification.Wpf` 11.0.0 (Platonenkov fork,
MIT — notice #10 in `THIRD-PARTY-NOTICES.txt`). Facts that cost a hunt:
- The `NotificationArea` XAML control registers itself with the manager on `Loaded`; routing matches
the area's `Name` against the request's `AreaName` — our host is `x:Name="ToastArea"` in MainWindow's
root grid (last child = topmost z-order, `Grid.RowSpan=4`, bottom-right above the footer via
`Margin="0,0,12,96"`, outside the preview Viewbox). An unknown AreaName silently drops the toast.
- `NeverExpires()` sets `ExpirationTime = TimeSpan.MaxValue` (NOT null); `NotificationColor.ToHex()`
returns `#AARRGGBB` (alpha prefix).
- Lifetimes: Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky until dismissed (the pure mapping
lives in `NotificationService.BuildRequest` + the `Cards` tint table: slate info `#3a3b52`, green
success `#1d5c38`, amber warning `#b8860b`, dark-red error `#8f1f1f` — matching the health-banner language).
- Calls marshal to the captured Dispatcher (`_dispatcher.Invoke`) — encoder/pump events fire off-thread.
Migrated MessageBoxes: both webcam-acquire warnings, image-read warning, sign-in unsuccessful/failed.
Promoted from log-only (each keeps its AppLog line): go-live prep failures ×3 (reusable stream,
broadcast insert, prep exception), frame-pump death while live, mic-missing at startup, premium lapse,
saved-session refresh failure (sign-out notice). Deliberately left log-only: offline license re-validation
skip (would spam every launch). Still modal by design: SocialsDialog's slot-delete YesNo confirm.
### Licensing — do not violate (GPL-3.0 open source since 2026-10-09; see `LICENSE`, `ADDITIONAL-TERMS.md`, `THIRD-PARTY-NOTICES.txt`)
**LlamaCasty is free software under GPL-3.0-or-later** (creator rulings 2026-10-09 — free forever, public
repo, "GPL-3 with attribution"). Copyright (C) 2026 Gramps. `ADDITIONAL-TERMS.md` carries the three
§7 terms: **7(b)** every copy keeps "Based on LlamaCasty by Gramps — https://llamacasty.com" in its
About screen; **7(c)** modified versions are marked and renamed; **7(e)** no trademark rights. The
**icon + logo files** (`Assets/llamacasty-icon.png/.ico`, `Assets/llamacasty-logo.jpg`) are **all
rights reserved, outside the GPL** — verbatim copies may carry them, forks must replace them (this is
what keeps the merch brand protected). The scene backgrounds + default alert video are AI-generated
**placeholders** (creator: made "with the intent of individual creators to immediately replace with
their own images") — they go out under the GPL with the code, no reservation. `LICENSE` + `ADDITIONAL-TERMS.md` ship beside the exe
(csproj `None` items); the About panel shows `THIRD-PARTY-NOTICES.txt`, whose header carries the
LlamaCasty license notice (GPL §5(d) "Appropriate Legal Notices"). **Source offer (§6):**
`https://llgit.llamachile.tube/gramps/LlamaCasty` (the Gitea repo; creator toggles it public — the
old `gramps/ytLlive` path 301-redirects there), recorded in the notices header + README.
Every bundled component must stay GPL-3.0-compatible (MIT/BSD/Apache-2.0/LGPL/public domain all are;
**GPL-2.0-only and proprietary are not**; Apache-2.0 is fine with GPL-3 but NOT with GPL-2). The
rules below predate the GPL switch; where their *reason* was "proprietary product" it is noted, but
the rule itself still stands for its other reasons. **NEVER:**
- **Swap the FFmpeg pin casually** — the BtbN `lgpl-shared` pin is the verified build. A GPL FFmpeg
build would now be license-compatible (the old "GPL contaminates the paid product" reason is gone),
but changing builds means re-verifying encoders, and libx264 carries no H.264 patent grant (Cisco's
covers OpenH264). Keep `lgpl-shared` unless a task says otherwise.
- **Distribute the static lgpl build** — LGPLv2.1 §6 wants relinkable object files for static linking.
The **shared** (dynamic-DLL) build sidesteps that: compliance is "license text + source offer +
unmodified binaries". The pin is `lgpl-shared`; when the pin is refreshed, keep the shared variant.
- **Use BtbN's `nonfree` variant** — it adds fdk-aac (Fraunhofer code licensing). The native FFmpeg AAC
encoder is fine (no Fraunhofer code) but grants no AAC patent license — accepted low-risk posture for
RTMP→YouTube, since encoder vendors cover their implementations (Cisco OpenH264, NVIDIA NVENC, Intel
QSV, AMD AMF).
- **Link FFmpeg into the app** — it stays a separate subprocess fed frames over a pipe; that separation
keeps the app's own code out of LGPL reach.
- **Drop `THIRD-PARTY-NOTICES.txt`** from the shipped app or the About screen, or alter the FFmpeg
copyright/LGPL notices inside the downloaded binaries. Automating the download counts as distribution
— the obligations are not optional.
- **Pin to a moving target** — the `latest` BtbN release tag floats. Only immutable autobuild tags give
a reproducible source offer. Record the tag + variant beside the URL (TASKS.md) every time the pin moves.
- **Use non-CC0 icon art** — the social bar's bundled SVG logo path data comes from **Simple Icons**
(CC0 1.0, public domain — see `THIRD-PARTY-NOTICES.txt`). Replacing or adding logos must stay CC0 or
another public-domain source; a logo asset under an incompatible or attribution-heavy license
would complicate every redistribution. The app's icon + logo (`Assets/llamacasty-icon.*`,
`Assets/llamacasty-logo.jpg`) are the creator's own art — all rights reserved, outside the GPL
(`ADDITIONAL-TERMS.md`).
- **Ship an ML model without clean provenance** — the background-effects model (TASK 51) is Google's
official MediaPipe Selfie Segmenter download (Apache-2.0, model card), converted by our own script.
Model-zoo re-hosts (e.g. PINTO #109 = ML Kit's model, extracted) are off-limits. Apache-2.0 §4
obligations (license notice + "we converted it") live in `THIRD-PARTY-NOTICES.txt`.
- **Forget the v1 license-texts gate** — `THIRD-PARTY-NOTICES.txt` links the canonical license texts; at
**v1 (GA)** the full texts of every license it names MUST ship alongside it (TASK 4 requirement 9 is the
release blocker). Queued early is wrong; the release pass owns it.
- **Break the in-app About requirement** — since TASK 11 the About overlay is the app's in-app creator
hub: the real logo, the creator links (YouTube channel, Mastodon, Buy me a coffee, and the greyed
"Unlock Premium" seam whose billing URL is tabled), and a **Licenses & legal** sub-panel that loads
`THIRD-PARTY-NOTICES.txt` from the executable directory and renders it in a scrolling panel —
**never the OS viewer, no Notepad, no browser tab**. The premium/coffee link constants live in
`MainViewModel` (`PremiumUrl`, `CoffeeUrl`, `ChannelUrl`, `MastodonUrl`); filling in the billing URL
lights up the premium button. Do not reintroduce an "open the notices in Notepad" button.
## Design Principle
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
Apply this to every UI decision:
- One-click go-live with working defaults
- Prefilled YouTube defaults (RTMP URL, bitrate, resolution, latency)
- Visual/drag-and-drop scene building over property panels
- Every action produces a visible outcome — no dead ends
- **"We're not them" (2026-09-01):** assume the creator's hardware is mediocre, because it is.
Every feature spends the machine's budget once — never twice for the same result (record OR
stream, not both; one reusable stream; one webcam; two mixer inputs). If a feature only sings on
a high-end chassis, it doesn't ship — the OBS escape hatch is open by design.
## Monetization (design decision — the branding flash is the sword)
> **⛔ Superseded 2026-10-08/09 — free forever, no branding on the stream.** The credit is now
> **OFF by default**: `MainViewModel.ShowBrandFlash` (internal static, default `false`) is the
> master switch, and `BrandFlashEnabled => ShowBrandFlash && !IsPremium` feeds the presenter at
> construction and on every licence change. The code (`BrandFlashPresenter`, the FramePump
> `brandFlash:` seam, the preview layer) is kept, not deleted — creator ruling: "don't delete it,
> just make it conditionally displayed". `BrandFlashOutputTests` opt in by setting the switch
> before constructing a `MainWindow`; `Credit_IsOffByDefault_EvenOnTheFreeTier` guards the default.
> Everything below is the historical paid-tier design.
Full pricing and the Year 1 revenue forecast in `MONETIZATION.md` (gitignored). ⚠️ **No Polar
product, discount code, or license key exists any more** — nothing bills today; Polar is dormant.
Brand palette and assets in `MARCOM.md` (gitignored). This section covers the in-app model.
Free forever: **all features unlocked for everyone** — no feature lock, no paid tier, and since
2026-10-08 **no watermark either** (the credit is OFF by default; see the banner above). No
creator will tolerate a watermark, and as a good-guy developer we give everyone complete access
so no one can call us crooked. Money comes from gratuity/merch, not gating (TASK 54).
- **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.
**Cadence is APP-LIFETIME, not go-live (creator ruling 2026-09-26):** the presenter is
`Start()`ed **once in the `MainViewModel` ctor**, not from `UpdateLiveVisuals()`. It used to be
gated on `IsLive`, which meant a recording made without ever going live carried no credit — the
creator's ruling: *"the made with llamacasty flash should appear in all scenes, not just live"*
and *"should also appear in recordings"*. One start now covers every scene, the preview, the
stream and the recording, because go-live and local recording are the **same `FramePump`**
(`Streaming.Operations.cs:68` and `:199` both call `StartAsync` with the same `brandFlash:`
delegate) — verified, not assumed; there is no second encoder path needing a "redirect". The
licence gate needs no live branch: `IsPremium`'s setter pushes `Enabled` from anywhere.
**Consequence of app-lifetime — the premium edge restarts the timer.** `Enabled = false` stops
the `DispatcherTimer` (cutting the advertisement mid-credit). Because `Start()` is no longer
called per go-live, nothing would ever restart it, so a key entered mid-session would leave the
credit dead until the process restarted. The setter therefore restarts it when re-enabling while
`_running` (`IsCadenceTimerEnabled` is the test seam).
**Test-arithmetic trap, hit again 2026-09-26:** `Advance(5.1)` in ONE call cannot observe a
credit — `Advance` both *opens* the presentation and *ages* it by the same delta, so a single
5.1s step blows clean through the 2s window. Always step in 1/30s increments like the real timer
(the `Step` helpers in `BrandFlashOutputTests`).
- **Paid:** 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 the
entitlement changing 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. **How `IsPremium` is obtained is a billing concern and has changed twice;
what it gates has not.**
- **Pricing: ⛔ SUPERSEDED 2026-10-08 by the free-forever ruling (TASK 54).** Retained as
reasoning only: `$10/mo` subscription or `$400` lifetime, no annual, ruled 2026-09-27 and
overtaken ten days later. If a paid tier ever returns, these are the numbers to argue from.
**How `IsPremium` would be obtained is a billing concern; what it gates is not.**
- **Billing: ⏸ dormant — no paid tier exists** (free-forever ruling 2026-10-08, TASK 54). The
Store IAP plan of 2026-09-27 was never implemented (`Windows.Services.Store` appears nowhere
in the repo) and was withdrawn with the Store route on 2026-10-22. If a paid tier ever
returns, the provider is **Polar** — merchant of record for VAT/sales tax, 5% + $0.50 Starter,
**$50 already paid**; `Distribution.md` §4.2/§4.5 holds the design. ⚠️ No Polar product, org
ID, key prefix, or discount code exists (`d105dfa1-…`, `c05fb364-…`, `LCYT-`, `LLAMAFOUNDER`/
`LLAMA50`, the customer-portal validate endpoint, and the 14-day offline-grace machine are all
gone).
- **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the
once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are
how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with
emergency patches; not a 24/7 service promise. There is **no licence chatter at all** — the app
is free, there are no keys, and no user sees licence UI. TASK 36 item 5 (expiry reminder) is
struck.
**Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1
ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by
design, for **every** creator, free and ungated (the only subscriber swap remains the branding-flash
removal above). It has three layers, all free — this is the product's differentiator, not a paid tier:
- **Reward-event capture (the data foundation).** The live-chat poll (`YouTubeChatService.Poll`)
receives every reward event over `liveChat/messages` and — since TASK 43 (2026-09-24) —
**decodes all six types**, not two: `superChatEvent`, `superStickerEvent`, `newSponsorEvent`,
`membershipGiftingEvent`, `giftMembershipReceivedEvent`, `memberMilestoneChatEvent` land as
`ChatMessage.Kind` (`ChatEventKind`) with their payload fields (display string, sticker
description, gift count, gifter name, milestone months); free subscriptions emit NO event
(API fact — a sub mention is a plain text row). The remaining half of the original capture
plan stays open (TASK 3 item 20): persist each event to the canonical SQLite `RewardEvents`
table (broadcastId, type, timestamp, amountMicros, currency, tier...) + `superChatEvents.list`
(30-day lookback) backfill — that persistence is what the session report and the journey
tracker read. Alerts did not wait on it: the shipped alert path (TASK 43) renders directly
from the in-memory `ChatMessage` feed.
- **Session / broadcast report.** A session is created at Go Live; the poll + End roll that session's
reward totals (new members, Super Chat $, stickers $, gifts, milestones). This is the in-app
replacement for the emailed external "stream activity report" — the incumbents (Streamlabs et al.)
email near-useless post-stream summaries; we track the same data natively and maintain it.
- **Journey tracker (TASK 39, slice 1 shipped 2026-09-22).** A **"YPP"** tab below the
Stream Settings pull-out (Live screen right edge) shows the connected creator's position
toward each YPP tier threshold. Slice 1 = current-scope data only: `channels.list?mine=true
&part=statistics,contentDetails` (subs/views/videos + uploads-playlist id) +
`playlistItems.list` for 90-day public uploads. Every refresh appends
a `YppSnapshot` to SQLite (schema v10) — the history a later slice needs for velocity/ETA.
**The `auditDetails` part is OUT (fixed 2026-09-23):** readding it 403s the WHOLE request —
that part alone requires the `youtubepartner-channel-audit` scope no app for normal creators
should request (the standing flags sat unreadable since slice 1 shipped; the app's `youtube` +
`youtube.force-ssl` scopes were never sufficient for them — the "no re-consent" slice-1 claim
was wrong for that part). Channel standing now deep-links to the Earn page; API stats come from
`statistics,contentDetails` only. YPP/API failures log the response body's `error.reason`.
**YPP thresholds are versioned, date-aware DATA, never constants** — the Tier-2 bar doubles
for new applicants on **2027-02-01** (long-form 4,000 → 8,000 qualified hrs / 365d; Shorts
10M → 20M / 90d), while the Tier-1 fan-funding bar (500 subs / 3,000 hrs / 3M Shorts) is
unchanged. Hardcoding the pre-2027 numbers would ship the wrong ETA. Watch-hours / Shorts /
velocity / ETA are **slice 2**: Analytics-API scopes (`yt-analytics.readonly`, additive
`yt-analytics-monetary.readonly`) behind the `IChannelStatsProvider` seam (additive
capability, requires an OAuth re-consent). Compliance items no API exposes (2FA, AdSense
linkage) are in-app self-reported checkboxes with deep links; channel standing (exposed only
via the partner-audit scope) deep-links to the Earn page. Slice 2's once-daily refresh gate +
snapshot history were baked into slice 1.
**What was rejected:** always-on watermark (obscurable — replaced by the flash), hard stream-time
cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams
routinely run 2-4 hours), soft-limit nagging, freemium feature tiers, and donation-only (relies on
the kindness of strangers). Resolution/quality ceilings stay rejected (fixed 2026-09-01, no longer
"deferred"): the free tier never loses quality — auto step-down (**TASK 33**) is a *protect the
stream* feature, never a monetization penalty.
## Auth gates Go Live, but not exploration
The app is fully usable without authentication: users can build scenes, add sources, compose
previews, and audition the software with zero commitment. But **going live requires authentication** —
it's the one capability gated behind YouTube sign-in. The sign-in should never pressure the user
("sign in (optional)", not a modal wall): the two-state top bar shows **Start Stream** (offline) /
**End Stream** (live), and the Start Stream dialog hosts the account — a saved session appears as
the default with "Change Account"; with none saved, a "Sign in to YouTube" button starts OAuth and
the Start button stays disabled until signed in.
## Account assumption (do not build an account setup flow)
Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no
YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to
YouTube" button; going live is unreachable until an account is connected.
## YouTube Live API — design constraints (do not violate)
> ## ✅ SOLVED 2026-10-04 — the broadcasts never went live because they were never BOUND.
>
> **The bug, in one line: `contentDetails.boundStreamId` on `liveBroadcasts.insert` does not bind
> the broadcast.** Binding requires an explicit `POST liveBroadcasts/bind?id=…&streamId=…&part=id`.
>
> **How it was finally proven, after a week of misdiagnosis** — the diagnostic line, once its own
> JSON-nesting bug was fixed (`FirstItem`), read:
> ```
> lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING
> | streamStatus=active activeChat=none cdn=rtmp/variable@variable
> ```
> Every field is real, and together they are conclusive:
> - `bound=NONE` **with** `autoStart=true`. Both live in the *same* `contentDetails` object on the
> insert, so the request was well-formed and accepted — the API took one field and silently ignored
> the other. That asymmetry is the proof; a malformed request would have failed outright.
> - `lifeCycle=created`, **not** `ready`. Google: `created` = *"incomplete settings, so it is not
> ready to transition to a live or testing status"*. An unbound broadcast is precisely incomplete.
> - `streamStatus=active` — the ingest half was never broken. YouTube had an active stream and a
> broadcast that was not attached to it.
>
> **The causal chain, complete:** no binding → `lifeCycleStatus` stuck at `created` →
> `enableAutoStart` has no bound stream to fire from → never live → close-out 403s
> `invalidTransition` because `created → complete` is not a legal edge (only `ready`/`live` are).
>
> **Why it survived a week, and the lesson worth keeping:** every observable pointed away from the
> binding. ffmpeg pushed ~99 MB cleanly, the reusable stream went `active`, close-out returned a
> *transition* error (which reads like a lifecycle-timing problem), and the insert returned a valid
> broadcast ID (which reads like success). **A reusable stream accepts bytes for a broadcast that is
> not watching it**, so a perfectly healthy RTMP session is exactly what an unbound broadcast looks
> like. Google says it outright in the migration guide: *"a liveBroadcast must be bound to exactly one
> stream before the live broadcast can actually start. The broadcast is not bound to any streams at
> the time that it's created."* That sentence was in the docs I read during the original design and
> was not applied. **Read the failure modes in the docs, not just the happy path.**
>
> **Shipped:** `YouTubeStreamService.BindBroadcastAsync`, called from `PrepareAndStartLiveAsync`
> immediately after `CreateBroadcast` and **before** the pump starts. Failure is fatal to the session
> (an unbound broadcast can never go live, so a live-looking UI over it is a lie). `boundStreamId` was
> **removed** from the insert body and the `streamId` parameter **removed from `CreateBroadcast`** — a
> parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible.
> The old test `CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert`, which asserted the
> disproven behaviour and therefore *laundered a guess into a guarantee*, is replaced by
> `CreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamId` plus
> `BindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body`.
>
> **Bind failures are not all "sign in again."** Google documents six reasons
> (`insufficientLivePermissions`, `liveStreamingNotEnabled`, `liveBroadcastBindingNotAllowed`,
> `liveStreamNotFound`, `liveBroadcastNotFound`, `internalError`, plus rate limiting) and only the
> first is an auth problem. `BindFailureMessage` maps each to its own text; `liveBroadcastBindingNotAllowed`
> matters most, because it means *our* ordering is wrong and a generic "check the sign-in" would hide
> a bug in our own code behind a plausible excuse.
These are the hard facts behind every decision. Full list in `TASKS.md`.
- **One-click go-live** — never call `transition(live)`. Insert the broadcast with
`enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`,
`selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live.
`enableMonitorStream=false` is what lets us skip the testing stage.
- **⛔ CORRECTION (2026-10-04): the push is provably healthy — stop investigating it.** The long
test run settles it: `liveBroadcasts.insert` at 14:17:04.577, ffmpeg `Output #0, flv` to
`rtmp://a.rtmp.youtube.com/live2/5f6z-…` at 14:17:05.686, and at 14:18:56.473 ffmpeg's own
muxing summary reads **`video:101229KiB audio:1419KiB`**, `muxing overhead: 0.202949%` — ~99 MB
of real video plus audio over 112s, which is exactly what 8000+128 kb/s should produce. **No
socket error, no 4xx from ingest, no reconnect.** The broadcast *still* never left `ready`, and
close-out earned 403 `invalidTransition` again. An earlier 8s run behaved identically, so
"YouTube needed longer" is disproven — 112s is longer. The break is between *ingest accepted*
and *broadcast told to start*, nowhere in the RTMP path.
- **The binding is the prime suspect, and the app was blind to it.** `contentDetails.boundStreamId`
("the live stream bound to the broadcast") and `enableAutoStart` ("start automatically when you
start streaming video **on the bound live stream**") are both valid on insert, with no documented
restriction against a reusable stream. But YouTube's migration guide carries the danger:
**"a liveBroadcast must be bound to exactly one stream before the live broadcast can actually
start. The broadcast is not bound to any streams at the time that it's created."** And this
failure mode is *invisible from the RTMP side*, because **a reusable stream accepts bytes for an
unbound broadcast** — ingest succeeding proves nothing. ⇒ `LogBroadcastDiagnosticsAsync` rides
the **existing** stream-health poll (no second timer) and writes one greppable line:
`lifeCycleStatus`, `privacyStatus`, `recordingStatus`, `boundStreamId` **compared against the
stream id we are actually pushing** (`BoundMatches` → `yes`/`MISMATCH`/`NO-BINDING`/`unknown`),
`boundStreamLastUpdateTimeMs`, `enableAutoStart/Stop`, `liveChatId` presence, the stream's own
`status.streamStatus` + `activeLiveChatId`, and the CDN settings we declared. It is
**self-limiting** — `_broadcastSeenLive` latches on `live`/`liveStarting`, so the line shouts only
while the broadcast is stuck and goes quiet once healthy. **The next run reads this line; it is
the whole experiment.** Guarded by `BroadcastBindingTests.BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush`,
which pins `unknown` for a missing expectation so an absent stream id can never be misreported
as a match.
- **⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).**
`liveBroadcasts.list` returns a **list wrapper**, so the broadcast lives at `items[0]`. The
original code descended into `items` for the *stream* but read the *broadcast's* response root,
so every broadcast field came back `?` — and `BoundMatches` then dutifully reported a confident
**`boundMatches=NO-BINDING`** from a parse that had never happened. Three lines of fabricated
alarm. Two fixes, both load-bearing: **`FirstItem()`** descends into `items[0]` for both halves
(and returns `default` when there is genuinely no item), and the line now prints
**`boundMatches=unread`** when the broadcast could not be read, so an absent parse can never be
reported as a verdict. Guarded by
`BroadcastBindingTests.FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly`.
**Generalisation: a diagnostic must degrade to "I don't know", never to a confident wrong
answer** — otherwise one level of JSON nesting manufactures a bug that isn't there.
- **What the one run that *did* work proved:** the stream half was correct, and it shows
`streamStatus=ready` → **`streamStatus=active`** about 30s into the push. So the ingest does
register as active server-side, which is a genuine step forward — combined with the 99MB muxed
in the earlier run, the stream half of the system is now verified healthy. The broadcast half is
still unobserved pending a re-run.
- **Visibility picker (TASK 9 item 6) — DELIBERATE TEST-PHASE LOCK, not drift (2026-09-01).**
The "always Private" enforcement in `YouTubeStreamService.CreateBroadcast` stays while the creator
runs multi-month real-world testing: non-private test streams would clutter the channel with VODs
highlighting where the product breaks. `recordFromStart`/DVR stay ON — Private VODs are invisible
to subscribers and serve as post-mortem review tapes; bulk-delete pre-GA. The unlock is a
deliberate final-pass item in **TASK 36 (gold pass)**, wired with the dialog selection — never
opportunistic. The PRIVATE badge keeps showing when the stream is actually private.
- **⛔⛔ STOP. The always-Private lock is a DECISION, not a bug, not drift, not an open question.
(Creator ruling, restated 2026-10-04 after being asked about it AGAIN.)**
`privacyStatus = "private"` hardcoded in `YouTubeStreamService.CreateBroadcast` is
**exactly right as written. Do not change it, do not "unlock" it, and — this is the part I keep
getting wrong — do not raise it as a question, an observation, a suggestion, or a
"heads-up".** The creator has now answered this more than once.
- **What it becomes:** the hardcoded lock is replaced by a real, user-selectable
visibility option in the Go-Live dialog — **at v1, not before.** Not at 1.0-candidate, not
at GA prep, not opportunistically during any other unit.
- **Why it exists:** pre-v1 test streams would otherwise publish VODs of a product still
breaking, in public, to subscribers. Private keeps those tapes usable for post-mortem review
while staying invisible to the channel's audience.
- **Do not let these look like symptoms of the one they resemble.** All are working as designed:
`privacy=private` in the broadcast diag line; the PRIVATE badge; `recordFromStart`/DVR ON;
and the absence of any delete call in the codebase (there is no delete feature, by design at
this stage — "can we delete the test VODs" is a *retired* question, answered 2026-10-04:
`videos.delete` accepts `youtube.force-ssl` which we already hold, private is a visibility
setting not a deletion lock, and anyway a broadcast that never reaches `live` never becomes a
VOD at all).
- **`UpdateBroadcast` is NOT the unlock — and it is also currently DEAD CODE. (Fact corrected
2026-10-04: an earlier version of this note said `CreateBroadcast:112` /
`UpdateBroadcastMetadataAsync`, both wrong.)** The method that can change a live broadcast's
privacy is `YouTubeStreamService.UpdateBroadcast(broadcastId, meta)`, which sends
`privacyStatus = meta.Visibility ?? "private"`. **It has no production caller** — the only
caller in the repo is `BroadcastPullOutTests.cs:120`. So `CreateBroadcast` hardcoding private
is not merely backed up by convention, it is the *only* thing enforcing it.
- **⇒ Is a Go Live video private? YES — verified 2026-10-04, and this answers a standing
creator doubt.** Three independent hardcodes (`CreateBroadcast` → `"private"`;
`GoLiveViewModel.Visibility => "Private"`; `BeginTestStream` → `StreamVisibility = "Private"`)
plus one unreachable method. There is no path by which a broadcast this app creates can become
non-private today. The creator's actual concern — "do not populate my channel with videos
showing the app at its worst" — is fully met.
- **⛔ THE LANDMINE, and the reason the above matters:** `LiveBroadcastFormViewModel` still
exposes `VisibilityOptions = {private, unlisted, public}` and `UpdateBroadcast` would happily
honour it. **The v1 visibility option is the first thing that will ever wire `UpdateBroadcast`
up — so v1 is exactly when this guarantee ends, by the creator's own choice.** When that
happens, private stops being guaranteed and becomes a default the user can change. That is
intended at v1; it is a disaster before v1. Do not wire `UpdateBroadcast` into any
production path before v1, and do not "fix" the dead-code smell by calling it.
- **⛔ The TEST button is HIDDEN at v1 — it exists only because of the lock above (creator
ruling 2026-10-04).** This was **absent from the map entirely** until that date, which is how the
dry-run framing below came to be the only recorded one. Both are true, and the second is the
load-bearing one:
- **Its real job is being the guaranteed-private path.** `BeginTestStream` hard-sets
`StreamVisibility = "Private"` (`MainViewModel.Streaming.Operations.cs:105`), so Test can never
publish. That is *why* it exists for the whole pre-v1 phase — a dry run you cannot leak.
- **At v1 it is redundant and gets hidden**, because once visibility is a real dialog option the
user picks Private themselves and a separate "safe" button no longer earns its place. Hiding it
is part of the v1 visibility work, not a separate cleanup.
- **The pre-existing "dry run" framing is now known to be incomplete** (`ShowTestButton`'s own
comment calls it "a dry-run of the livestream"). Do not read that comment as the reason the
button must persist, and do not re-derive the button's purpose from it. If a dry run is still
wanted at v1 it must be re-justified on its own merits — that question is **open, and is for
v1, not now.**
- **It is a computed predicate, not a setting** — `ShowTestButton => ShowStreamActions &&
IsConnected` (`MainViewModel.Streaming.cs:182`), feeding both `CanStartTest` and the XAML
visibility. So the v1 hide is a one-line predicate change, not a settings migration.
- **Not a v1 blocker:** the Test route is the *same* pipeline as Go Live —
`BeginTestStream` → `StartStreamingSession(alsoRecord: false)` → `PrepareAndStartLiveAsync` —
so Test and Go Live create identical broadcasts (`boundStreamId`, `enableAutoStart`). Test
differs only in never recording (`alsoRecord: false`) and forcing Private. **This is why Test
is the correct way to run any diagnostic stream**, and why a Test result is evidence about Go
Live.
- **Full broadcast form (TASK 9 item 7) — RESCOPED 2026-09-01** — the core editable fields ship (since 2026-08-24) as the always-visible **Text drawer**: title, description, tags, visibility, made-for-kids, live-editable; scheduling ships as **TASK 34** (drawer ☑ Scheduled + adoption at Start). The old **Advanced tab is permanently out of product** (the 10% margin — see TASKS.md → "Out of product"): latency locked `low`, DVR/record-from-start locked on, embed/projection/CC/region fixed at sane defaults, invisible — every exposed field is a support ticket. `categoryId` is removed from `CreateBroadcast` (not a `liveBroadcast` field, silently ignored). Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field. Go-live order (TASK 9, shipped 2026-08-16):
`BeginGoLive` → `PrepareAndStartLiveAsync` — ensure the reusable stream (`GetOrCreateReusableStreamAsync`,
cache it), create the broadcast bound to it (`CreateBroadcast(..., stream.Id)` → `boundStreamId`),
THEN start the pump (the URL must exist before `FramePump.StartAsync`, which reads it once). Failure →
`StreamStatus.Error`, never a crash; `StopStream` clears `_currentBroadcastId`.
- **Variable reusable stream** (shipped 2026-08-16) — `GetOrCreateReusableStreamAsync` lists
`liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once per channel
(`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`) only on first use; the
ingestion URL is cached via `LayoutStore` Settings (`SaveReusableStream`/`LoadReusableStream`) and
bound to each broadcast at insert (`boundStreamId`). Any quality tier works without recreating the
stream, and auto step-down rides the same property — we drop bitrate/resolution on the fly, zero
API calls — **but the deciding governor is not built**: the old present tense there was a map-lie,
corrected 2026-09-01; step-down is **TASK 33** (v1 scope).
- **Quality is greyed out while live** — resolution/frameRate/ingestionType are immutable after
stream creation; editing title/description/privacy is fine at any time.
- **Report-by-exception health (SHIPPED 2026-08-16, TASK 9 item 3)** — `YouTubeStreamService.GetStreamHealthAsync(streamId)`
polls `liveStreams.list?part=status`; render nothing on `good`/`ok`/`noData`, surface a banner only
on `configurationIssues[]` with `warning`/`error` severity. The decision is the pure
`Services/StreamHealthReporter.BannerFor` (null text = no banner; error beats warning). The VM polls
every **30s while live** (`DispatcherTimer` `_healthPollTimer`, first poll right after go-live) and
clears on End via `ResetHealth`; poll failures log only. **Parsing (fix 2026-09-22):** the real API
nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — reading it as
a string crashed every poll (`requires an element of type 'String'`, seen 2026-09-22 on both test
sessions); the parse now reads the nested `.status`/`configurationIssues` and tolerates the old
flat-string shape. UI = a full-width banner strip under the
top bar, `HealthIssueBanner` text + `HealthIssueBackground` (amber `#b8860b` warning / dark red
`#8f1f1f` error), hidden by `NotNullToVis`. The design's bottom-strip YouTube logo + green/red dot
(clickable → dialog) is still queued.
- **One dialog, three states** — not connected / connected-offline (all editable) / live
(title + description + visibility editable; quality + account greyed out). Both entry points
(Start Stream button + bottom strip) open it; prefilled from saved session profile.
- **End stream (SHIPPED 2026-09-01; pre-flight hardened 2026-09-22)** — stop encoder → `YouTubeStreamService.EndBroadcastAsync` POSTs `liveBroadcasts/transition?broadcastStatus=complete&id=…&part=status` (AFTER RTMP EOF, so no frames post-date the end — VOD finalizes immediately instead of ~1min of frozen "stream offline"). **2026-09-22: every end logged 403 `invalidTransition`** — a blind complete races enableAutoStop/YouTube's auto-complete (the old doc's "already fired" guess was wrong; it's a broadcast state race). The call now pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only transitions from `live`/`testing`, skipping silently otherwise (`enableAutoStop` finishes every skip — never throws, never toasts a finished session). Inconclusive pre-check still posts (old behavior, backstopped). Record-only stops make ZERO API calls (guard: `wasLive && _currentBroadcastId != null`).
- **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz,
mono/stereo only. YouTube flags violations via health status.
- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD
(`recordFromStart` + `enableDvr` default true).
- **Test Stream mode (TASK 41, SHIPPED 2026-09-22)** — every broadcast is private-only anyway
(see the visibility lock above), so "Test" is a **session variant over the real go-live
pipeline**, not a new mode: `BeginTestStream` → `StartStreamingSession(alsoRecord:false)`
(the dialog path was extracted into this shared boot — no Go Live dialog for a Test, no
`BroadcastForm.CaptureGoLive` "last live" stamp, no recording). `IsTestStream` flips the face:
gold top bar + glow (`#9c6f1c`), a **TEST** badge, and the End button reads **"End Test"**;
`StopStream` clears it in every path (including pump-failure rollback). Once the liveChatId
resolves, `TestSession.BeginTest(broadcastId, liveChatId)` opens the **TEST drawer** (third
right-rail pull-out, three-way one-open-at-a-time exclusivity wired in `MainViewModel.Ypp.cs`;
any left-click outside the rail closes whichever drawer is open — TEST included (fixed
2026-09-25, TASK 46; the code-behind outside-click close in `MainWindow` had skipped
TestSession, so only Stream Settings + YPP dismissed on an outside click).
**liveChatId resolution (fixed 2026-09-25, TASK 44; ⚠️ CORRECTED 2026-10-04):** the id is
`snippet.liveChatId` — `contentDetails` has NO liveChatId property — and
`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` reads part=snippet and
polls with a bounded retry, and `PrepareAndStartLiveAsync` starts the frame pump FIRST before
resolving it. **The old claim that "YouTube only populates it once the broadcast is LIVE" is
WRONG, and it was inferred from a sample rather than from the API.** The 2026-10-04 diagnostic
caught `liveChatId=present` while `lifeCycleStatus=created` — not live, not even `ready`. The
official `GetLiveChatId.java` sample tests `broadcastStatus == active` before returning the id, and
that read as a statement about *when YouTube populates the field*; it is only a guard in the
sample. **Generalisation: a sample's defensive check is not a spec.** Also note the old note said
the missing id meant "the ready state" — the broadcasts were never `ready` at all, they were
`created` and unbound (see the SOLVED box above), so the terminology was wrong too.
The bounded retry is harmless and stays, but the id is available far earlier than assumed — worth
revisiting only if chat startup latency is ever reported as slow.
Drawer tooling: **Mock Chat Input** = a REAL `liveChat/messages.insert` (`YouTubeStreamService.InsertChatMessageAsync`)
that round-trips through the real ~2s poll and renders through the live overlay path;
**simulated Subscriber / New Member / Super Chat** = `YouTubeChatService.InjectSimulatedMessage`,
which raises the poller's `MessageReceived` seam, marked `ChatMessage.IsSimulated`. **API fact
(researched + hard constraint): `liveChat/messages.insert` only creates `textMessageEvent`s —
SuperChats / memberships / sponsorships cannot be inserted via any API**; simulated non-text
events are local-only by design and never reach YouTube. **Insert body shape (fixed 2026-09-25,
TASK 45):** the body MUST include `snippet.type` = `"textMessageEvent"` alongside `liveChatId`
and `textMessageDetails.messageText` — omitting type returns `400 MISSING_REQUIRED_FIELD`
(found live on the next Test Stream; the TASK 41 test had asserted body presence but not
type, so it stayed false-green). Scopes `youtube` + `youtube.force-ssl`
already cover insert. **Real stinger/TTS alert widgets are DONE (TASK 43, shipped
2026-09-24) as the native alert box** — see the next section; Text source (TASK 3 item 16)
and the RewardEvent persistence (item 20) remain open.
### Native events & alerts (TASK 43, shipped 2026-09-24 — StreamElements replaced)
The creator asked to replace the external StreamElements feed with native YouTube events;
the answer is **native events + a native alert box**, and the product decisions are
published decisions (recorded in `TASKS/task-43-native-alerts.md`):
- **No third party.** The chat feed IS the event feed: `liveChat/messages` carries all six
monetization events (superChat / superSticker / newSponsor / membershipGifting /
giftMembershipReceived / memberMilestoneChat). `streamList` is the *connection semantics*
of that same endpoint (pollingIntervalMillis-driven), not a separate call — the service
re-arms its one-shot poll on the server's cadence (clamp 1000–6000ms), `maxResults=2000`.
Cite: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- **`SourceType.AlertBox` — one "Stream Alerts" celebration zone per layout**, OBS
alert-box style: a designated draggable canvas area, idle = transparent. Mirrors the chat
gate (`CanAddAlerts`; `AddSource` refuses a second). Defaults 620×430 → 680×200.
- **Six events → six unique animations** (`Services/AlertRenderer.cs`): SuperChat slide-up
+ gold count-up + shine sweep (green), SuperSticker scale-pop on a tilted chip (purple),
NewMember drop-in + flash band (violet), MemberGift slide-left + gift-chip `×N` fan
(amber), GiftReceived confetti scale-in (green→purple), MemberMilestone rise + tenure
growth bar (orange). Phases enter/hold/exit (~4–5s); every card draws the **brand line
"made with LlamaCasty!"** — each broadcasted event is free product placement.
- **`Services/AlertOverlayLayer.cs`** — the component: enqueues events one-at-a-time,
advances on a 33ms dispatch ticker, serves the live frame through `RenderFrame` (same
cache-first pattern as chat) and the preview through `UpdatePreview`; **`Advance(double)`
is the deterministic test clock** (tests drive playback with no timer). Queue cap 10
(floods drop the tail, never stall). `ChatEventKind.None` rows (plain chat, sub mentions)
**never enqueue** — per the creator ruling, a subscriber mention is sufficient; there is
no free-sub alert (YouTube emits none) and no viewer count (vetoed — demotivating,
weaponizable).
- **Alert box VIDEO (TASK 47, 2026-09-26):** since TASK 47 the box plays a clip on every
alert instead of (only) an animation — `Enqueue`→`BeginClip` spawns a **per-play**
`IAlertClipDecoder` (ffmpeg `rawvideo bgra -vf scale=W:H` frame pipe + `f32le` audio
pipe, both real-time paced via `RawVideoFrameReader`; **not** the refcounted
`MediaVideoSourceManager` — alerts are one-shot plays that must be disposed at drain).
Custom clip wins per-source over the built-in ONLY when `AlertUseDefaultVideo==false`
AND the file exists; the shipped `Assets/alert-default.mp4` is stamped into the `Asset`
table at startup (`AlertDefaultVideoKey` setting; the prune keeps it via UNION) and
read-at-play; any decode failure falls back to the six `AlertRenderer` animations
(deliberate, not an error). Fades in/out via **`FadeDurationSeconds = 0.30`**
(`AlertOverlayLayer` const): fade-in by `elapsed` on straight-alpha frame copies;
EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears
`_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio
forwards to the mixer sink scaled by `volume × fade`.
- **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes
"Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` —
a strip rasterized **on the UI thread once per text** (cached, unpremultiplied
straight-alpha pill) then positioned by pure byte-math per call, so the pump calls it
from its own thread with no locking. **It is a dynamic overlay, NEVER baked** (the
social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` +
`CompositeLayers` take a trailing `tickerFrame` threaded from
`FramePump._alertTicker` (`Func<VideoFrame?>` ctor seam) through
`RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll
changes the cache key. Toggled by `AlertShowTicker`.
- **It draws INSIDE the Stream Alerts box (creator ruling 2026-09-26).** Was a global
1920px bar pinned to the top edge: *"the ticker should appear over the stream alerts
video, not over the entire preview window"*. The strip is now rendered at the
**alert box's own size** (`AlertTickerRenderer.Render(..., width, height)`, defaults
still 1920×48) and the box's origin rides on **`VideoFrame.Placement`**
(`(int X, int Y)?`, with `OriginX`/`OriginY` = 0 when unset). Every blit site reads
`tickerFrame.OriginX/OriginY` instead of a literal `0, 0` — `SceneCompositor` ×2 and
`FramePump`'s static-bake `Overlay` path. The preview binds the same numbers
(`AlertTickerLeft/Top/Width/Height` → `AlertTickerElement`'s `Canvas.Left/Top` +
`Width`/`Height`), so preview and output cannot drift — the same guarantee as the
branding credit. Product default box is **680×200 at (620, 430)**
(`MainViewModel.Sources.cs`); height is clamped to the natural 48px strip.
**No alert box in the scene ⇒ no ticker at all** — there is no global position left.
- **Why the position travels on the frame, not in a full-canvas frame:** a 1920×1080
overlay is 8.3MB of large-object-heap garbage per tick — ~2.5GB churned over one 10s
alert at 30fps. A box-sized strip is ~370KB. The branding flash *does* render
full-canvas, but it **recycles** one 8MB buffer and bumps `Epoch`, so it never
allocates per frame; the ticker allocates per call, so it must stay small.
- `CopyStrip` clips **rows** to the target height: the pill rasterises at its natural
48px, so a box shorter than 48px would otherwise write past the end of the buffer.
- **Three display methods** (`Source.AlertDisplayMethod`, DB column, panel "Display"
selector): `TickerScroll` (marquee), `Flash` (0.5s on / 0.5s off), `Solid` (centred,
still). The marquee is paced in **reads per alert** — `TickerReadsPerAlert = 3`
passes inside the alert's own length, speed derived from that — **never px/s**; the
old fixed `140px/s` took ~17s per pass, so a 10s alert showed the message once.
Same convention as the incumbent (Streamlabs: "Alert Duration" + "Text Delay", not a
scroll-speed slider). Flash's off half returns null.
- **A ticker still needs its own preview element, not a `Source`.** It is a frame-pump
producer, so it never rides a per-element `Image`; `AlertOverlayLayer` takes a
`tickerPreviewSink` and publishes from `RefreshAlertPreviews()` (UI thread only —
the pump thread must never touch a `WriteableBitmap`); the VM writes it into one
reused `WriteableBitmap` (`AlertTickerImageSource`/`AlertTickerVisible`) bound to
`AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`. The rect
is re-raised on **every** published frame, not just on a bitmap resize, so a box
moved or resized mid-alert tracks live.
- **Two measurement traps here:** a seamless marquee never goes blank (a wrapped copy
enters as the pill clears), so count a pass by the pill's leading edge resetting, not
by an empty frame; and the run is phase-started half a frame in, or the first
published frame is blank (the pill sits exactly off the right edge).
- **Alert audio under the master limiter:** `AudioMixer.EnqueueAlertAudio` writes a
dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` before the
limiter and added at **unity — never ducked** (creator ruling: "don't lower my game
audio for a tip"); `StartLive` clears it. Per-alert loudness = the Volume slider.
---
## Windows packaging & distribution (⛔ REVERSED 2026-10-22 — direct + Steam; Store dropped)
**The Microsoft Store route is dead.** A brand name on the Store's *publisher* line requires a
**Company** account, which requires a registered legal entity (an LLC — ~$85 New Mexico with a
~2-week SOS backlog, ~$143 Wyoming). Partner Center does **not** convert an Individual account to
a Company account later, and an Individual account publishes under the verified legal name. The
creator will not fund an entity for a nameplate, so the 2026-09-27 Store ruling was withdrawn on
2026-10-22. Store IAP was never implemented either — `Windows.Services.Store` appears nowhere in
the repo.
**The route now:**
1. **Direct download from `llamacasty.com`** — the app is **free forever** (TASK 54), so there is
nothing to bill in v1.
2. **Steam when $100 exists** — Steam Direct is $100 per product (recoupable after $1,000 AGR),
accepted categories include *Audio/Video Production* / *Player Tools* (OBS Studio is the
precedent), the **developer/publisher name is self-chosen** so the brand needs no entity,
5+ screenshots at 1920×1080 + capsule art, a build Valve runs, and a **2-week live page**
before release. Valve takes 30%.
3. **Polar is dormant, not deleted-in-principle** — chosen checkout/licence provider if a paid
tier ever returns; $50 already paid. `Distribution.md` §4.2/§4.5 holds the design.
4. **The Microsoft Store: not pursued.** The MSIX package stays as a working artifact and is
submitted nowhere. History: `TASKS/research-store-certification.md`,
`TASKS/task-48-distribution-msix.md`.
**Consequences that are architecture, not packaging detail:**
- **⛔ The updater is now MISSING, not deleted-by-design.** Velopack and its bootstrap were
removed on 2026-10-22 (TASK 48 slice 3) because the Store was going to deliver updates — that
premise is gone. An update path must come back, and
`ytLive.Tests/StorePackagingTests.cs` (which asserts no updater returns) must be rewritten
with the chosen updater rather than left to veto one.
- **The MSIX package still builds** via `scripts/package-msix.sh` (TASK 48 slice 2) — publish
`Release -r win-x64 --self-contained` into `artifacts/msix-stage`, copy
`Packaging/Package.appxmanifest` in as `AppxManifest.xml` + `Packaging/Assets/`, then
`makeappx pack` → `artifacts/LlamaCasty.msix` (159 MB, gitignored; `MSIX_PFX` signs it for a
local install). The manifest is a **full-trust `packagedClassicApp` at `mediumIL`** with
`runFullTrust` + webcam + microphone. `Square310x310Logo`/`Wide150x150Logo` traps are in
`MyMistakes.md`. Logos come from the app's own artwork via `scripts/make-store-logos.py`.
- **⛔ Bundle ffmpeg — no runtime downloads** (was Store policy 10.2.2; still right for a direct
download, and it fixes the 2026-09-01 404). See [`docs/ai/output.md`](docs/ai/output.md) → "FFmpeg locator".
- **⛔ 64-bit Windows only (creator ruling 2026-10-09).** Both csproj files set
`<PlatformTarget>x64</PlatformTarget>` (the test project too — an AnyCPU test project
referencing an x64 app raises MSB3270 and breaks the 0-warning gate). Why: the bundled ffmpeg
is the win64 build, ONNX Runtime 1.31 ships no win-x86 native (only win-x64/win-arm64), and a
32-bit address space can't hold the 1080p frame buffers. Windows 11 is 64-bit-only anyway.
Public copy says "64-bit Windows 10 or 11".
**What does NOT change: the monetization posture.** The app is **free forever with no watermark**
(TASK 54, 2026-10-08), so `IsPremium` gates nothing in v1 and the branding flash is OFF by
default. `Monetization` above governs.
### ⛔ Durable invariant: full trust, or recording silently breaks
MSIX packages run at one of two trust levels. A packaged WPF app declaring
`rescap:Capability Name="runFullTrust"` runs at **mediumIL — the same permissions as a
standard desktop app, NOT AppContainer** — and may spawn child processes, which is why
bundling ffmpeg is legal. Writes to user-profile locations (AppData, Downloads, MyVideos)
**pass through unvirtualized at mediumIL**; the package install folder is read-only.
**`ViewModels/MainViewModel.Recording.cs` writes recordings to
`%USERPROFILE%\Downloads` / `SpecialFolder.MyVideos` (`DefaultRecordFolder()`,
`ChooseRecordFolder()` via `Microsoft.Win32.OpenFolderDialog`), and the whole
temp-write-then-move lifecycle stays in one directory.** That works *only* because we are full
trust. No `broadFileSystemAccess` capability is needed, and none should be added.
⚠️ **If anyone flips the manifest to `appContainer`, `Directory.CreateDirectory` and
`File.Move` start resolving to a per-package virtualized location and output vanishes with no
error.** A comment at `DefaultRecordFolder()` is owed **when the manifest lands** (TASK 48
item 6) — not before; a comment describing a manifest that doesn't exist is worse than none.
### Signing — do not buy EV
Microsoft **removed the SmartScreen instant-bypass for EV certificates in March 2024**. An
EV-signed file now accrues reputation exactly like an OV-signed one, so EV ($400+/yr) buys
what OV (~$150–300/yr) buys. Signing is **not** instant regardless — a valid cert stops the
*malware* warning immediately, but "unrecognized publisher" clears only as SmartScreen accrues
reputation from real download volume. Cert validity is capped at **460 days** (recurring
line item) and private keys must live on an **HSM or hardware token**. **The 2026-10-22 ruling
is to ship unsigned and accept the "unrecognized publisher" warning** until download reputation
accrues — buying a certificate is deferred, not rejected. `Distribution.md` §4.3 was corrected
2026-09-27 (it previously recommended EV).
### Distribution and licensing are independent — and the channel decision does not decide licensing
A **Store-distributed** app *may* still verify licences with **Polar** (Store policy 10.8.1
explicitly permits a secure third-party purchase API for non-game PC products), so "which
channel?" never implied "which billing?" — route B was a real option. The 2026-10-22 reversal
proves the point in the other direction: dropping the Store did not by itself restart Polar,
because the **2026-10-08 free-forever ruling** (TASK 54) had already removed the paid tier.
Polar is therefore *dormant*, ready if a paid tier ever returns, and nothing in v1 depends on it.
The lesson worth keeping: the licensing choice rides on a **monetization** decision, not on a
distribution decision.
### ⛔ "Only when the user says so" is enforced by Windows, not by us
Camera/mic consent is layered: Store policy **10.6** forbids circumventing OS checks; the
**Windows desktop-app camera/microphone toggle** (Settings → Privacy & security, since Win10
1903) is the gate; hardware lights and tray indicators are the OS's disclosure mechanism. So
**route everything through the standard MF/DShow/WASAPI paths and we never write that
certification ourselves** — the OS does it and gets neither our credit nor our blame.
⚠️ **Untested:** whether that toggle reliably gates a **DShow webcam and a capture card**.
Microsoft's own support page admits desktop apps "might still be able to access your camera
or microphone even when these settings are turned off." This is **not** a certification
failure (we declare `webcam` honestly and use supported APIs) but it is a real user-trust
issue — so **no privacy-forward marketing copy until it is measured.** Guard note added to
`MARCOM.md`.
### ⛔ Durable invariant: chat is rendered, never stored
`TASK 49`'s profanity filter is **local, on-device, opt-in, non-persistent**, with a
**user-supplied word list — never a hardcoded slur list baked into the binary** (unpleasant
artifact, false positives across dialects, extractable). It must **never match the SuperChat
amount or any reward field** — those are financial data under policy 10.5.5.
The non-persistence half is not just hygiene: it is the load-bearing half of the **11.12 UGC
certification answer** (mirrored YouTube chat is moderated at industrial scale upstream; ~1.6B
comments removed in Q1 2026 alone, child safety the #2 reason at 124.6M). **No future feature
— chat history, moderation logs, analytics, crash payloads — may quietly break that
assumption.** If one is ever proposed, the 11.12 position must be re-argued first.
### Minors
The **operator is 13+ by construction**, not by promise: LlamaCasty requires a YouTube
account to stream, and YouTube's ToS sets the account floor at **13** (regional variants reach
14; 13–17 need parent/guardian permission; under-13s are on supervised/YouTube Kids accounts).
So a COPPA theory premised on collecting PI from under-13s has no purchase against the
operator. The residual is **viewers** — still possible on supervised family accounts — and it
is weak: live chat is restricted there, YouTube moderates before we see it, and we persist
nothing. COPPA is very unlikely to bind (not child-directed), but note the enforcement trend
(FTC Sept 2025 *Apitor*: operators are responsible for what third-party components collect) —
so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no
persisted chat or reward payloads."
## Transitions (2026-10-27)
- **Fade starts from the outgoing picture.** `TransitionService.Start` with no "from" frame sets
`CaptureRequested`; the frame pump copies its last output at the top of its next tick and calls
`SupplyFromFrame`. Before that fix the blend had no starting frame and a scene swap was instant.
Guard: `ytLive.Tests/TransitionFadeTests.cs`.
- **Type and duration** are selected in App Settings and persisted with the layout. Cut, Fade, Move,
and Wipe are offered; Wipe is a CPU left-to-right reveal. Guard: `ytLive.Tests/TransitionPickerTests.cs`
and `ytLive.Tests/WipeTransitionTests.cs`.
- **A scene change animates the outgoing preview in every mode.** Fade and Move fade it out; Wipe
shrinks it away; Cut is immediate. Program output transitions when streaming or recording, while
offline scene changes affect only the preview. Guard: `ytLive.Tests/PreviewTransitionTests.cs`.
- **Still planned (see TASKS.md):** persistent layers for Move and a capability-gated GPU Cube.
## Brand logo (2026-10-27)
- One locked image layer per scene, `Source.IsBrandLogo` (persisted column, additive migration), named
"Brand logo", directly above the background. Healed on load by `EnsureBrandLogos`, which also copies the
App Settings image into every scene's layer. Removal is blocked like the background.
- The image lives in the asset table; the settings table keeps its asset id and file name
(`BrandLogoAssetId` / `BrandLogoFileName`). Eye visibility is the layer's ordinary per-scene `IsVisible`.
- Default placement: top-left (32,32), 240x120 master px.