# 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` → `
/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 `` 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=` 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 "" "" ...` (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 "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "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`, +1 per commit, generated by `GenerateBuildStamp` in ytLive.csproj via git rev-list; the per-build GUID now logs to startup.log only — 2026-09-04)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) |
### Key patterns
- `ViewModelBase.SetProperty()` 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(..., 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 Socials+TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row. 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 (`` 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` 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 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; the vertical tier uses 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. Bottom bar: gear icon far left, stream stats (bitrate/FPS/dropped/duration) centered under preview, resolution dropdown far right. 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, transitions (Cut/Fade/Move), thumbnails above central monitor, edit mode offline only, right panel eliminated
- 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 — shipped 2026-08-24, verified by code survey)
The app is a **director's control surface**, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor.
**Layout:** Two-column. Left panel = one panel, two states. Center = thumbnails above central monitor.
- **Left panel:** When offline (edit mode) → LAYERS + PROPERTIES (same as current, minus SCREENS). When live/recording → YouTube Live Chat (entire panel replaced, never coexists with layers/properties). Mode is implicit (derived from `StreamStatus`), no toggle button.
- **Thumbnails:** Five scene previews above the central monitor, 16:9, drag-reorderable. Click = stage (offline) or transition (live). Active scene gets brand-red glow border.
- **Central monitor:** Shows staged scene (offline, editable in edit mode) or live output (live, read-only). During transitions: compositor-blended animation.
- **Right panel:** Eliminated. Chat moved to left panel (live mode).
- **Edit gating:** `IsEditMode` = `StreamStatus == Offline && !IsRecording`. When live/recording: all drag/resize, context menus, right-click property adjustments disabled. Gun safety — no accidental edits mid-stream.
**Scene reference split:** `ActiveScene` → `StagedScene` (central monitor target, editable offline) + `LiveScene` (encoder output). Go Live sets `LiveScene = StagedScene`. Thumbnail click sets `StagedScene` (offline) or fires transition (live).
**Transitions:** `TransitionService` (new file) — Cut/Fade/Move with configurable duration (default 300ms). `SceneCompositor.RenderBlend()` blends two scenes' frames. `FramePump` feeds `LiveScene` to encoder; during transitions, feeds blended frames. Go Live always = Cut (clean start).
**Thumbnails:** `Scene.Snapshot` property. `SceneCompositor.Render()` at 320×180 → `WriteableBitmap`. Only staged scene has live backdrop render; other 4 = static snapshots.
**Thumbnails:** `Scene.Snapshot` property. Fill = per-scene tile artwork first — each canonical scene's `ThumbnailAssetId` (an Asset-table image, the creator's CRT-styled renders seeded at heal into the internal DB), else background art; **minis never render live captures**. The scene currently staged shows the shared **on-air image** (`OnAirImageAssetId` setting → Asset row, seed path `MainViewModel.OnAirSeedPath`) in its tile and it follows the scene swap — "the on-air screen jumps around the top bar" (user ruling 2026-10-10). The Live screen's tile only shows the green placeholder when it is staged and no on-air art is stamped. The strip labels are FontSize 20 (user ruling 2026-10-10); border = `#e94560` when staged, `#22c55e` when live. The preview's own live signal is the outer `PreviewGlow` red border — NOT the 6px green `#22c55e` rectangle that previously sat in the BackdropLayer (removed 2026-10-10).
### 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` 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())`.
### Screen backdrop capture (TASK 3 ship task #1) — model reworked by TASK 25
**The Background is a locked per-screen layer (TASK 25): exactly one per canonical
scene, always named "Background", pinned at index 0, never addable/removable/reorderable,
absent from the (+) menu. Flavor is decided by scene: Live = `DisplayCapture` (the real-time
desktop/game surface with Show Desktop / monitor switch / fullscreen-game detect, falling back
to `live-background.jpg`); Starting/BRB/Chat/Ending = static `Background` art layers.**
`MainViewModel.NormalizeBackgrounds(scenes)` (internal static) heals any loaded layout to this
model on every load: keeps the correctly-flavored row (or converts a survivor in place — note
`Source.Type`'s setter derives `IsBackground`, so conversions must re-assert the flag), drops
duplicates/stale rows, seeds missing ones via `CreateBackground(name)`, renames, moves to index 0;
non-canonical scenes get `HasBackground=false` and lose any backgrounds. `HealBackgrounds()`
(instance) runs Normalize + stamps default art (`StampDefaultBackgroundAsset` reads
`Assets/{scene}-background.jpg` pack resources through `AddAsset`; custom Browse art wins).
This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Background`,
`SeedLiveBackgroundAsset`, `EnsureDefaultBackground`) and `EnforceBackgroundPolicy`.
- **Model (schema v6):** `Source.IsBackground` (persisted) marks the one Background per scene; `Source.CaptureKey`
(persisted) names the target — `monitor:`, `window:`, or `picker:`. The Live row is
a real `Source` of `Type DisplayCapture`, inserted **first** (`MainViewModel.EnsureBackground(scene)`,
internal static — also used by the `StagedScene` setter so a scene switch can never expose a
missing Background), fixed at X=0/Y=0/1920×1080, and excluded from drag/hit-test/remove/reorder (remove is guarded in `RemoveElement`;
the element template sets `IsHitTestVisible=false` for
backgrounds; the row's remove button and "Remove Source" menu item are hidden). **`Scene.HasBackground`
(persisted) is true for all five canonical scenes** (see `SceneCatalog.HasBackground`). Capture controls —
"Change Capture…"/"Refresh Capture"/"Capture Display"/"Show Desktop" — exist only while the **Live** scene is
staged (`CanChangeBackground` = staged scene is Live; the preview CanvasGrid menu binds it directly, the layer-row
menu MultiBindings it with the row's own `IsBackground` via `Helpers/AllTrueToVisibilityConverter`). Non-Live
screens keep the properties panel pill ("Use default background") + Browse for custom art.
- **Settings hygiene:** `LayoutStore.Save` purges orphaned `BackgroundUseDefault_{id}` /
`BackgroundPath_{id}` Settings keys whose id is no longer a live Source row (TASK 25 heal drops duplicates; their keys must not accumulate).
- **Layering invariant (regression lesson, 2026-08-23):** `ActiveBackgroundImage` renders *above*
`BackgroundImage`, so it is fed by **static-art rows only** (`Type == Background`). The capture-flavored
Live row must never populate it or it permanently covers the desktop/game capture; Live's canvas shows
`DisplaySource` (capture, falling back to its AssetId art). Flavor-blind `IsBackground` lookups are for
persistence/heal/minis — not this overlay.
- **Detection (event-driven):** `Win32FullScreenDetector` hooks `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)`
— fires on the UI thread whenever the foreground window changes. The callback runs the same
`GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` +
`GetMonitorInfo` check; a window is full-screen when its frame covers all four monitor edges; own
process is excluded; monitor **index = `EnumDisplayMonitors` enumeration order** (the same order
`ScreenCaptureSourceFactory` uses to map index→HMONITOR via `Win32FullScreenDetector.GetMonitorHandle`).
`IFullScreenDetector` also exposes `GetDisplays()` (`DisplayInfo`: index/name/resolution/bounds/`IsPrimary`,
friendly name via `EnumDisplayDevices`) + `PrimaryMonitorIndex()` for the in-app "Capture Display"
submenu + `FullscreenMonitorChanged` event + `StartWatching()`/`StopWatching()`. At launch
`ReacquireScreenCaptures` heals backgrounds (`HealBackgrounds()`), then keys the Live background to the
full-screen game's
monitor; when **no game is detected**, `ResolveAutoCaptureKey()` returns `null` — stale `CaptureKey`
values are cleared, no capture session is started, and `Source.DisplaySource` falls back to
`_imageSource` (the static asset on the backdrop element). `StartWatching()` is called at the end of
`ReacquireScreenCaptures`. `OnFullscreenMonitorChanged` auto-redesignates when a fullscreen game appears
on a different monitor; null (no fullscreen / own window) is ignored — the existing capture keeps
running so loading screens / transitions don't break the stream. `RefreshBackdropAutoCapture` (bound to
"Refresh Capture" context menu + `Activated` handler) reads the current foreground directly as a manual
fallback. Deduplication: `_lastReportedMonitor` prevents redundant redesignates from rapid foreground
changes.
- **Capture (WinRT GraphicsCapture):** `ScreenCaptureFrameSource` creates a free-threaded
`Direct3D11CaptureFramePool` (2 buffers, `B8G8R8A8UIntNormalized`) + `GraphicsCaptureSession`; frames →
`SoftwareBitmap.CreateCopyFromSurfaceAsync` (alpha ignored) → `VideoFrame` (BGRA8), bytes read via
`WindowsRuntimeMarshal.TryGetDataUnsafe` (the same CsWinRT-safe read the webcam path uses) — the
`IMemoryBufferByteAccess` ComImport cast threw `Invalid cast` on **every frame** under CsWinRT, which
flooded `startup.log` (~5 MB in a session) and burned CPU, so it is gone. Surfaces larger than the
1920×1080 master are downscaled to the master (`DownscaleBgra`, integer 8.8 fixed-point bilinear —
slice 16: the same two-stage math as `SceneCompositor.Bilinear`; the previous double-per-pixel
version was ~30-45ms quiet / ~150ms under 240Hz-HDR load and froze the desktop layer ~90% of a take).
Conversions run as up to MaxConcurrentConversions (3) overlapping readbacks
(slice 17: the OS readback, not the downscale, is the ~47ms wall — see Slice 17) with a
monotonic LatestFrame publish gate (`MonotonicGate`: a slow OLDER completion can never
overwrite a newer frame), and are spaced by a 10ms floor
(`MinConvertInterval`): the monitor delivers at the **240Hz DWM cadence** (~4.2ms), far too fast for
the ~60/s the pump can use, so the extra arrivals are dropped (`skip busy`/`skip cadence` telemetry).
Hand-out buffers come from a **reuse-distance ring** (`FrameRingBuffer`, depth 8, redLine 4): a
buffer is only rewritten ≥4 rents after its last hand-out, else a fresh one is allocated — a frame a
consumer still holds (`session.LatestFrame` survives conversions; the dispatcher preview copy lags)
can never be read-while-overwritten (the 1742 tear). Rolling telemetry (frames/s, conv avg/max ms,
drops, ring allocs) is logged to `startup.log` every 2s while converting. Per-frame conversion
failures are logged at most once per 5 s (`ErrorLogThrottle`). DRM-protected content delivers black
frames (OS limitation, documented). Frame pool pauses while the app is minimized — capture keeps
running, the pool just stops delivering.
- **Ownership:** `ScreenCaptureManager` mirrors `CameraManager` — refcounted by target key, one shared
`WriteableBitmap` per key, dispatcher-coalesced latest-frame copies, `PreviewBitmapChanged`/`CaptureFailed`
events, `ReleaseAllAsync` on re-designation. `ScreenCaptureSourceFactory.Resolve(key)` parses the key into
a source; `PickAsync()` shows the OS `GraphicsCapturePicker` ("Change Capture…", owner window set via the
`IInitializeWithWindow` ComImport) and returns a **transient** `picker:` key — a reload falls back to
auto-detection.
- **"Capture Window…" — in-app window pin (TASK 38, 2026-09-21):** the Live backdrop context menus
(PreviewPane canvas + layer-row) offer a submenu listing visible top-level windows of other processes
(`Win32WindowEnumerator`, an `EnumWindows` pass filtered like `Win32FullScreenDetector`:
visible, non-cloaked via `DWMWA_CLOAKED`, non-empty rect, other-process, titled). Picking one runs
`RedesignateBackgroundAsync("window:0x{hwnd:X}")` — the same full-bleed layer-0 capture path as the
game/desktop, keyed straight into `ScreenCaptureSourceFactory` (`window:` already resolved there).
**Session-scoped, never persisted:** `window:` (and `picker:`) keys are `IsTransientCaptureKey` —
`ReacquireScreenCaptures` strips them before auto-detection on every load, because an HWND can be
recycled to an unrelated window after restart (resurrecting a dead pin could capture the wrong
window). **Pin wins while alive:** `OnFullscreenMonitorChanged` skips re-designation while
`IsAliveWindowPin(current.CaptureKey)` is true — the auto game/desktop detector never steals an
explicit pin. **No dead ends:** if the pinned window's capture session dies mid-session,
`OnScreenCaptureFailed` (transient key only) re-runs `ReacquireScreenCaptures` → auto-fallback
(fullscreen game → desktop → static art). Seam: `IWindowEnumerator` + `WindowInfo`
(`Label = "Title — Process"`), `WindowEnumeratorOverride` static test seam mirrors
`CameraEnumeratorOverride`.
- **CsWinRT projection gaps hand-rolled:** `Windows.Graphics.Direct3D11.Direct3D11Helper` is not projected,
so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first
feature array) → QI `IDXGIDevice` → the WinRT interop export
`CreateDirect3D11DeviceFromDXGIDevice` → `MarshalInterface.FromAbi` (one shared device per
process). Do **not** switch back to the QI-for-`IDirect3DDxgiInterfaceAccess` trick: the raw D3D11 device
no longer exposes that interface on newer Windows (verified E_NOINTERFACE on build 26200, hardware and
WARP alike) while `CreateDirect3D11DeviceFromDXGIDevice` keeps working.
`IInitializeWithWindow` are ComImports in `CaptureInterop.cs`. All WinRT projections were verified by
reflection against the built `Microsoft.Windows.SDK.NET.dll` before writing the interop.
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
task); multi-monitor live re-targeting beyond the auto-detected game is behind the picker; picker- and
window-pin captures don't survive reload (session-scoped by design — see the "Capture Window…" bullet).
- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses
focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
the only focus hooks (`FullscreenMonitorChanged` event + `RefreshBackdropAutoCapture`) re-target the backdrop, never
pace it. The lag is inherited: `Windows.Graphics.Capture` is content-driven and DWM/OS-throttled while the
app is in the background (worse under a full-screen game on 24H2/26100), GPU readback
(`CreateCopyFromSurfaceAsync`) contends with the foreground game, the source's one-in-flight `_framePending`
gate drops frames during stalls, and the manager's `DispatcherPriority.Render` copies only run as fast as
WPF presents the window. Slice 16 shrank the per-conversion stall (fast downscale + 10ms cadence floor)
but the OS-level delivery throttle under focus loss remains. Recorded 2026-08-13; no mitigation attempted
yet (deferred by user decision).
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
to the encoder task.
- **Preview watermark:** the "Preview" placeholder hides while a background capture renders —
`ShowPreviewPlaceholder` also checks `BackgroundImage` (raised on change), so a scene with
live capture shows the feed instead of the "nothing here" label.
### Webcam capture (TASK 3 milestone 1)
- **Seam-first:** everything above the WinRT layer speaks only `VideoFrame` (normalized tightly-packed
BGRA8) + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces. Tests inject fakes;
screen capture and background removal later feed the same seam.
- **CPU-first:** `MediaCaptureInitializationSettings { MemoryPreference = Cpu, StreamingCaptureMode = Video,
SharingMode = SharedReadOnly }`, frames pulled via
`CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8)` — the pipeline does any format
conversion, so every `FrameArrived` yields a ready BGRA8 `SoftwareBitmap` (bytes read via
`WindowsRuntimeMarshal.TryGetDataUnsafe`, not marshalled copies).
- **Reader-output subtype ladder (2026-09-15 webcam-take fix):** the reader is created per-candidate
by `ReaderSubtypeCandidates(activeSubtype)` — Bgra8 for uncompressed cameras (fast path), NV12 then
source-default for cameras sitting in MJPG. A camera another app left in MJPG mode cannot be read as
Bgra8 by a `MediaFrameReader` (`StartAsync` → `OutputFormatNotSupported`; the MJPG pipeline exposes
NV12, not BGRA) — exactly what a browser grabbing the webcam produces. When the app can't re-negotiate
(our `SetMediaStreamPropertiesAsync` refuses with "file in use / CaptureMode is SharedReadOnly" under
msedge + NVIDIA Broadcast contention), an MJPG-active reader otherwise aborts the whole acquisition.
Each candidate is allocated AND started under its OWN catch — WinRT answers an unsupported subtype
with a THROW (`E_INVALIDARG`), not a status; one rejected format must never abort the ladder. Failures
degrade to the next candidate; rejections are logged + collected into the final error; only the outer
catch nets device-level errors (see `MyMistakes.md` → WINRT RESOURCE-ALLOCATION RECIPE). Anything not
Bgra8 is converted in `OnFrameArrived`, which already handled non-BGRA software bitmaps.
- **Source pick, not first hit:** the frame reader is bound to the first source that is `VideoPreview`
(preferred) or `VideoRecord`, not blindly the first preview source. If a camera exposes neither, the
failure names the device and the stream types it *does* expose. `SharedReadOnly` lets the capture
coexist with other apps that share the camera.
- **Known failure: NVIDIA Broadcast** — it opens the physical webcam exclusively, so `InitializeAsync`
fails with "camera in use" (or, if init slips through, the device exposes no preview source). Fix:
quit Broadcast while streaming, or pick its virtual "NVIDIA Broadcast" device from the picker and the
app captures the processed feed. This is a real-device finding (Logitech `VID_046D&PID_082D`).
Correction (2026-10-05): **Windows Studio Effects is NOT a second option of this kind** — it runs
inside the physical camera's own driver, so it never appears as a selectable device. Only tools
that install a virtual camera (NVIDIA Broadcast, XSplit VCam) do. "Pick the vcam" is a user action in
the camera picker, never an automatic substitution: creator ruling 2026-10-05 — the app must not
bypass the user's explicit camera choice, so there is deliberately **no fallback-camera setting**.
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
- **One webcam, many scenes (schema v3) — app-level default (2026-09-17):** a singleton `Webcam`
row holds the identity (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig`
(position/size/clip/mirror/border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at
most once, the webcam (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager`
refcounts capture sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps
it; the last release stops + disposes). **The webcam is an app-level resource — ONE selection (the
app default), usable in every scene that can host it.** "Add Webcam" places the existing default
DIRECTLY (no picker); the Windows camera picker runs only for the initial selection
(`_webcam == null`). Choosing a different camera in the picker swaps it app-wide via
`SwapWebcamIdentityAsync` (same path "Change Webcam…" uses). **Per-scene gate, not app-wide**
(TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`
— a scene holding its own placement greys the row (one webcam per stream), but ANOTHER scene
holding one does not, so Chat's camera never greys Live. **Attainable means a live lock, not just a
saved identity:** `IsWebcamAttainable` = `_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`
— an identity whose device was unplugged, or whose session can't start, leaves the row greyed with
`WebcamAddToolTip` = "No webcam is currently available…" (identity alone no longer suffices). The
max-1 reason gets the gentle "a second camera means you've graduated to OBS" line.
Removing a scene's placement **keeps the identity** (it's the app default; `_webcam` is never
nulled by removal) — Add stays offered for the same camera, because the app holds its own base lock
(below).
- **Startup resource lifecycle (2026-09-17):** the webcam is a resource the app *allocates and
locks*, validated at startup — this answers "why is the WebCam row greyed" from inside the app
instead of a DB spelunk. `MainViewModel.ValidateWebcamResourceStartupAsync` (fired fire-and-forget
right after `LoadLayout`, stored as `WebcamStartupValidationTask` so tests await it) enumerates the
OS once and tri-states on the count: **0** → app runs on, layer inactive, no alarm; **1** → attempt
`CameraManager.AcquireAsync(device)` as an app-wide lock — on failure a **persistent red alert**
(`WebcamLockAlert`, bottom of the Layers panel with a Retry button) re-polls every 5 s
(`_webcamLockPollTimer`) until a lock succeeds, and any first real frame also clears it; **≥2** →
deliberately no auto-lock — selection belongs to the App Settings dialog (gear) **queued — TASK 40
Unit A (App Settings round, plan saved 2026-09-22)**.
The single-camera branch **always acquires** (it no longer skips when configs already hold the
session): that acquire is the app's own BASE ref, so removing every scene's placement leaves
`RefCount = 1` and the session alive — the gate stays satisfied and the Web Cam row stays offered,
i.e. the app default outlives the scenes that render it. Acquire on a running session just bumps
the refcount. `CameraManager.IsRunning(deviceId)` = a session exists (started OR still starting —
a mid-start session is not "locked yet", it's in flight), and a rolled-back (failed) session leaves
the dictionary so the pass can retry. After a successful single-cam lock, the pass **adopts the
camera as the app default** if no identity exists yet (fresh layout) — the Web Cam layer is then
offered in Live/Chat immediately (`CanAddWebcam` true); an already-loaded identity is never
overwritten. Test seams mirror `LayoutPathOverride`:
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
drive the probe without real hardware (0-cam no-alarm, 1-cam locked → identity adopted, lock-fail
→ alert → clears on retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure
below is a *suspect-list* claim — `CameraConflictProbe` reads only running process names, no device
handles; contention symptoms fit shared-mode/bandwidth just as well (see `MyMistakes.md`).
The **YouTube Chat layer follows the same one-per-layout rule** (TASK 27):
`CanAddYouTubeChat` greys out the (+) item while any scene carries a ChatBox source (raised on
staging + elements change; `AddSource` refuses a second as defense in depth). Chat layers created
before the "YouTube Chat" default still carried the legacy name "Chat" (commit `65641d8` era) —
LoadLayout heals exactly that un-renamed default to "YouTube Chat"; creator renames are untouched.
- **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()`
snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and
restores them when switching back — otherwise the Round resize lock (square) would leave a square
behind. The rect dims are **persisted** (`WebcamSceneConfig.RectWidth`/`RectHeight`, nullable), so a
reloaded Round webcam still restores its pre-Round aspect instead of staying square. A one-time
`HealLegacySquareRect` (load only) widens a pre-v4 `Traditional` config that ended up square to 16:9
(keeps height; Round and explicit rect dims are untouched).
- **Device swap / layout reload:** `ChangeWebcamAsync` (picker) and `ReacquireWebcam` (after load)
release the old device with `ReleaseAllAsync` — a forced full drop that zeroes the refcount and
stops the source regardless of how many scenes held it (the per-config count isn't known once the
scenes are replaced) — then `AcquireAsync` the new device once per config.
- **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread
at the device's frame size (first frame), forwarded to every `WebcamSceneConfig.VideoImageSource` via
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher
(at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
device never drowns the render thread.
- **Webcam added mid-session must get the live frames:** `PreviewBitmapChanged` fires **once** (the first
frame creates the shared bitmap); later frames only mutate that bitmap in place, so a config that didn't
exist at first-frame time would never receive it — the empty/transparent container you'd see adding a
webcam to Chat while Live already had the camera. `CameraManager.GetPreviewBitmap(deviceId)` exposes the
current shared bitmap; `AddWebcamToActiveSceneAsync` assigns it to the new config right before
`AcquireAsync`, and `ReacquireWebcam` re-propagates it to every config after a reload.
- **Clip/mirror/border:** per-element `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
(`ScaleX = -1`) + the OSB-standard static border (`BorderColor` `#RRGGBB` or `""`=none, `BorderOpacity`
0–1, `BorderWidth` 0–20, `BorderAnimation` `None|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow|
Electricity|Sparkles`). Rendered in the preview DataTemplate; toggled from the element's right-click
context menu (webcam menu: Change Webcam…, Border Effect submenu — all 9 items enabled, values persist,
rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in
this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty
preview canvas has its own Show Webcam entry.
- The Round webcam is an `Image Stretch="UniformToFill"` with an `EllipseGeometry` clip
(`Center=0.5,0.5` `RadiusX/Y=0.5`), inside a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it
renders as a true circle (diameter = the shorter element dimension) instead of an oval stretched to
the element rect — the traditional `Image` keeps `UniformToFill` over the full rect. The clip is
geometry, **not an `ImageBrush`**: a brush re-rasterizes the frequently-updated `WriteableBitmap` per
frame on the render thread, which is what made the live webcam crawl while round. The Round border is
a centered `Ellipse` at `Width/Height = RoundBorderSize`.
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
- **Webcam size clamp:** `ClampWebcamToBounds(config, sceneName)` (internal — test seam) enforces the
max per dimension at resize + load and no less than 10% of the master (192×108). The cap is picked
**by canonical scene name**: 50% per dimension (960×540) everywhere except the **Chat scene**, which
may reach half the screen **area** (~1358×764 @16:9) so the viewer sees the creator better
(`MaxWebcamWidthFor`/`MaxWebcamHeightFor`; a renamed Chat loses the bigger cap).
`RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp.
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
the element — making the corner handle ungrabbable. The element Grid carries
`Background="Transparent"` (whole rect draggable; the empty canvas Grid uses the same trick for
right-click Show Webcam) and the `SelectionOverlay` (dashed border + corner dot) is
`IsHitTestVisible="False"` so it never intercepts the click. Two things make the webcam menu work:
(1) the `ContextMenu` pins its own `DataContext` to `PlacementTarget.DataContext` — a `ContextMenu`
isn't in the visual tree, so without it the Click-handler `DataContext:` patterns (and the
IsChecked/slider bindings) silently fail; (2) `Themes/Controls.xaml` ships a full dark `MenuItem`
template — `PART_Popup` (submenu popups), a popup `ItemsPresenter` (the Border Opacity/Thickness
sliders live in Items, so they render in a hover flyout), a `✓` checkmark column, and a `›` arrow
driven by `HasItems`. An earlier bare `Border + Header` template dropped all three: submenus never
opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the
Click handlers were fine.
- **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
to the encoder task.
- **Background removal — REVERSED, now IN for v1 (creator ruling 2026-10-08, TASK 51).** The
2026-10-05 "permanently out" ruling stood on "an in-app model means a runtime, a GPU/driver matrix,
and per-scene tuning". The delivery shape changed: a ~430 KB Apache-2.0 model embedded in the exe,
run on the CPU by onnxruntime — no download, no GPU requirement, no driver matrix. NVIDIA's Video
Effects SDK stays **ruled out** (RTX-only + ~1 GB user-side download). See "Webcam background
effects" below and `TASKS/task-51-webcam-bg-effects.md`.
### Webcam background effects (TASK 51 — built 2026-10-09; jitter fix 2026-10-09)
- **Model:** Google's **MediaPipe Selfie Segmenter, landscape** (Apache-2.0, model card 2021-05-06),
official file `storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/
float16/latest/selfie_segmenter_landscape.tflite`. Converted to ONNX by our own
`tools/selfie-seg/convert_tflite_to_onnx.py` (the graph ends in MediaPipe's custom op
`Convolution2DTransposeBias`, which tf2onnx/tflite2onnx can't convert — 11 op types, so a direct
translation was smaller than any workaround). **Verified against Google's own runtime:**
`verify_against_mediapipe.py` feeds the same frame to MediaPipe's `ImageSegmenter` (original
.tflite) and onnxruntime (our .onnx): max |diff| 5e-5, 100% binary agreement on two images.
⛔ Do NOT swap in PINTO model-zoo #109 (it's ML Kit's model, extracted — not Apache provenance).
obs-backgroundremoval is GPL-3.0 — license-compatible since LlamaCasty went GPL-3.0 (2026-10-09),
but none of its code is used; if any is ever borrowed, it needs its copyright notice kept.
- **Contract:** input `[1,144,256,3]` RGB /255 NHWC → output `[1,144,256,1]` person probability. The
whole frame is stretched (not cropped) to 256×144, so the mask maps back by normalized coordinates.
- **Shipping:** `Assets/Models/selfie_segmenter_landscape.onnx` is an **EmbeddedResource** (in git,
inside the exe — nothing to lose next to it, nothing fetched at runtime). Runtime =
`Microsoft.ML.OnnxRuntime` 1.31.0 (MIT), CPU execution provider, 2 intra-op threads. Note the native
`onnxruntime.dll` is ~17 MB (win-x64); an RID-less build copies every RID's native folder — the MSIX
publish (TASK 48) must pin the RID so only win-x64 ships.
- **Code (`Services/BackgroundEffects/`):** `IPersonSegmenter` seam (`Segment(VideoFrame) →
SegmentationMask?`, tests inject fakes); `OnnxPersonSegmenter` (lazy session from the embedded
resource; **never throws** — a load/run failure logs once and returns null forever after);
`BackgroundEffectProcessor.Apply(frame, BackgroundEffect)` — `None` = same instance (true
passthrough), `Remove` = copy with alpha = bilinear-upscaled mask × source alpha, RGB untouched,
`IsOpaque = false` (the compositor's straight-alpha blend shows the layers below), strength =
edge feather (mask band 0.5±(0.05+0.45·s) → alpha); `Blur` (slice 2) = background downscaled 4×
by box average, 3 separable box-blur passes and bilinear-upsampled, blended by the mask — person
pixels (mask = 1) are copied
untouched, opacity preserved. **Blur radius is quadratic** (`BlurRadius(strength)`: 1→6 small px,
default 0.5 = 2 — the "blur heavy" fix 2026-10-09; the old LINEAR 1→12 small px ≈ 48 full px was
far beyond what the established tools use: StreamFX defaults its Box blur Size to 5 and its author
stops downscale-based blurs around 8 px). The mask is computed once per frame (`TrySegment`) and
rendered per effect (`Render`), so several variants share one inference. No mask / segmenter
exception → passthrough + one log line, never a dropped layer.
- **Config (slice 3):** per scene, on `WebcamSceneConfig` — `BackgroundEffect` + `BackgroundStrength`
(0..1, default 0.5), persisted as two additive guarded columns on `WebcamSceneConfig`
(`BackgroundEffect TEXT DEFAULT 'None'`, `BackgroundStrength REAL DEFAULT 0.5`, no version bump —
the TASK 47 convention). `RemoveBackground`/`BlurBackground` are the radio-pill bools the UI binds.
- **Hookup (slice 4) — the ONE processing point is `CameraManager`.** Effects are per scene but the
camera session is shared, so the manager renders **variants** keyed by `WebcamEffectKey(effect,
strength rounded to 0.01)`. `MainViewModel.SyncWebcamEffects()` declares the union of every
config's key (`SetRequestedEffects`; unused variants are dropped so their CPU stops) and points each
config's `VideoImageSource` at its variant's bitmap; it re-runs on load, add, remove, device swap,
and any `BackgroundEffect`/`BackgroundStrength` change (configs are watched via `PropertyChanged`,
which also `ScheduleSave`s). Per raw frame a background worker (latest-frame-drop, one at a time)
segments ONCE and renders every variant; each variant keeps its own latest frame (the resolver:
`GetLatestFrame(device, key)`) and WriteableBitmap (`EffectPreviewBitmapChanged` on first
creation). **The raw path is untouched** — a slow/failed model never stalls the plain webcam — and
an effect variant shows **nothing** until its first processed frame (Remove never flashes the
real room). The compositor needed no change: its sampler already honours source alpha.
- **Temporal smoothing (2026-10-09, creator check: "removal is jittery"):** the segmenter classifies
every frame independently, so a verdict that wavers at an edge makes the cut-out flutter. Fix:
`MaskSmoother` blends each raw mask with the previous smoothed one — **0.75 × new + 0.25 × previous**
(`CurrentWeight`, internal const). State is **per `CameraSession`** (one per-camera worker), NOT in
the segmenter — the `OnnxPersonSegmenter` is shared app-wide and its bundled-model contract test
pins the raw per-frame output. A null mask (model hiccup) resets history; the first frame and any
size change pass through un-blended; the returned mask never aliases the caller's array and two
buffers ping-pong (no per-frame allocation — a 256×144 mask is 147 KB, past the 85 KB LOH). Weights
come from the established tools, not a guess: OBS's obs-backgroundremoval smoke-tests
`temporal_smooth_factor` at its 0.85 default, Jitsi's webcam background filter (jitsi/jitsi-meet#17080)
found 0.7/0.3 transformed best — a midpoint that still reacts to real movement; 0.5/0.5 leaves a
smearing edge ghost. The blur "heavy" follow-up was fixed the same day: quadratic radius, 1→6
small px (see the `Blur` bullet above).
- **Perf (synthetic 1280×720, Debug build):** segment 1.6 ms, Remove 3.3 ms, Blur 17.5 ms per frame
(Release is several × faster). Pixel loops are row-parallel, capped at min(4, cores/2); the
bilinear upsampling is separable (horizontal rows precomputed per mask/small row, one vertical lerp
per pixel) — the first naive per-pixel 2-D bilinear cost 46/80 ms. `CameraManager` logs the real
average once per session after 120 processed frames (`startup.log`: "background effects avg …").
`MainViewModel.PersonSegmenterOverride` is the hermetic-test seam.
- **UI (slice 5, creator spec 2026-10-09):** the webcam's properties in the Layers panel
(`Controls/LeftPanel.xaml`) gain a **Background** section — two `PillToggle` switches, Remove and
Blur, bound to `RemoveBackground`/`BlurBackground` (radio-exclusive: one on turns the other off,
turning the active one off = None) and a Strength slider (0..1, 5% snaps, visible only while an
effect is on; Blur = amount, Remove = edge softness). Dragging the slider never blanks the webcam:
a new strength variant inherits the dropped same-effect variant's last frame + bitmap
(`CameraManager.SyncVariants`), and the VM keeps the shown bitmap until the new one renders.
- **Tests:** `BackgroundEffectEngineTests` — the Remove integration test (fake segmenter), and a
model contract test that runs the BUNDLED model through the C# preprocessing on a fixture and
matches the MediaPipe-produced reference mask (≥99.5% agreement; fixture = MediaPipe's public test
image `mozart_square.jpg`, public-domain painting, in `ytLive.Tests/Fixtures/`). `CameraManagerTests`
— the ONE integration test for the jitter fix (a `SteppingMaskSegmenter` whose two consecutive
masks disagree at an edge; asserts the second frame keeps a partial alpha where the raw mask would
have cut to 0: x=6 → 58 upstairs of the raw 0, person core 255, background 0).
`Blur_default_strength_is_gentle_and_full_strength_is_strong` pins the quadratic radius mapping
(`BlurRadius(0.5)` = 2, `BlurRadius(1)` = 6) and the behaviour at a step edge: default smears
light ~9px into the dark half vs ~26px at full strength.
### Vertical (9:16) tier camera placement (TASK 57 — "vv" Units 1+2, built 2026-10-21)
**The problem.** The vertical tier (1080×1920) is a centred **band crop** of the SAME 1920×1080
master: `OutputRect*` = x 656.25, y 0, w 607.5, h 1080. A camera placed for 16:9 (the default
webcam sits at x≈1408) lands **outside the cropped window entirely** — in the vertical stream the
creator is simply not in the picture.
**The rule (creator's locked table, 2026-10-17).** Each scene keeps its **own** vertical
placement — position, size and shape — and switching back to 16:9 restores the desktop layout
exactly:
| mode | shape | size | position |
|---|---|---|---|
| 16:9 | as today | as today | as today |
| **vv · Live** | circle **or** rect (the creator's choice, per scene) | small tile | one of **six** spots: top/bottom × left/centre/right |
| vv · Chat | **rect only** (the circle option is not offered) | the small tile (normal), or enlarged to fill one 607×540 area | the same six spots; enlarged fills the top or the bottom area |
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
**Design — geometry only, no render-path change.** The preview and the compositor both read the
element's OWN `X`/`Y`/`Width`/`Height` + `ClipShape`, so the tier switch **swaps the live
geometry** instead of teaching the render path about tiers:
- `WebcamSceneConfig` carries the vertical placement (`VerticalX/Y/Width/Height/VerticalClipShape`
+ `VerticalRectWidth/Height` — seven additive DB columns, NULL = "this scene has no vertical
placement yet") and the **parked** 16:9 layout (`LandscapeX/…/LandscapeRectHeight`, transient).
- `BeginVerticalLayout(...)` parks the live 16:9 layout and brings the vertical one in;
`EndVerticalLayout()` commits the vertical placement back into the stored slot and restores the
parked 16:9 layout. The live geometry IS the render input → **the compositor and
`PreviewPane.xaml` are untouched**.
- The save keeps the X/Y/W/H/ClipShape columns as the **16:9** layout (while the vertical tier is
live, the parked values are written there and the live ones into the `Vertical*` columns).
- `SceneElement.X/Y/Width/Height` became **virtual** so the webcam can CONSTRAIN every write
(drag, resize handle, properties fields) into the band — one choke point instead of three
writers re-implementing the rule. Outside the band = invisible in the vertical stream.
**The band and the six spots** (`Models/VerticalCameraLayout.cs`, pure math): the band is
656.25…1263.75 × 0…1080; it splits into two 607×540 areas (top/bottom), each with three columns
(left/centre/right) → six spots. A tile snaps to the spot its **centre** falls in, inset 32 master
px from the band's outer edge (the app's own placement margin) and never outside the band. The
default tile is **270×152 master px** (480×270 in the 1080×1920 output) in the **bottom-right**
spot — the corner the 16:9 default webcam uses. The Live scene gets that tile automatically the
first time the vertical tier is picked (otherwise the vertical stream has no camera at all); the
other scenes keep their 16:9 placement until the creator drags them into the band.
**The Chat scene's own rules (Unit 2).** In the vertical tier the Chat camera is **rect only**:
the circle is not *offered* (`MainViewModel.CanToggleClipShape` is false → the Shape button
disappears) and the model refuses it too — `WebcamSceneConfig.ClipShape` is **overridden**, so a
locked camera cannot become Round by ANY writer (the toggle, the button, the placement swap). The
lock is `WebcamSceneConfig.VerticalShapeLocked`, set by the VM per scene and cleared **before** the
16:9 layout comes back, so the desktop shape is restored. Its **size is discrete**: the small tile
(normal) or one whole 607×540 area (enlarged) — the resize handle snaps to the nearer of the two
(`VerticalCameraLayout.SizeFor`), and an enlarged camera **fills the area its centre is in** (top
or bottom). The Chat scene also gets its tile automatically the first time the vertical tier is
picked (like Live) — otherwise the vertical chat stream has no camera at all.
**Manual entry** (creator rule): the webcam's Layers properties gained a **Geometry** section
(X/Y/W/H) — it edits the LIVE placement, i.e. the one the current tier shows. The webcam's
geometry setters clamp it into the band, so a typed value can never push the camera out of the
cropped window.
**Derivative work.** OBS has no built-in way to store a scene's layout per aspect ratio — the
maintainers' answer is "create a new empty scene and add the scene you want to send as source"
(obsproject.com/forum/threads/…161307), and Streamyard's dual-aspect guide says the same ("no
built-in way to store separate layouts per aspect ratio, so you rely on manual workarounds and
plugins" — streamyard.com/blog/dual-aspect-ratio-streaming-tool). Our per-scene, per-tier stored
placement, resolved on the tier change, is the step past that.
**Tests.** `VerticalWebcamLayoutTests` — the ONE integration test for Unit 1: the vertical tier
is picked → the Live camera comes INTO the band as a small tile → dragged to the top-right it
lands top-right inside the band → back to 16:9 the desktop layout is restored exactly → and both
placements survive a save + reload. (The store writes the webcam rows only when the app has a
webcam identity — the config rows hang off it — so the test supplies one.)
`VerticalChatCameraLayoutTests` — the ONE integration test for Unit 2: the vertical tier is picked
→ the Chat camera comes into the band as a small RECT tile → the circle option is not offered and
cannot be set even on the model → dragged up and enlarged it fills the **top** area exactly → back
to 16:9 the desktop layout is restored exactly and the circle option is offered again.
**Known trap (see `MyMistakes.md`):** a second properties panel bound to `SelectedElement.IsWebcam`
broke `WebcamBackgroundPillTests` — that test finds the webcam's panel by **binding text**, so a
new panel must use a different binding (`IsWebcamSelected`) or the selector matches twice.
### 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.
### FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10; **rewritten to BUNDLED 2026-09-28**, plan in TASKS.md)
The encoder's one external dependency is `ffmpeg.exe`. **It is BUNDLED — never downloaded, never
fetched at runtime.** `IFfmpegLocator` resolves an absolute path on demand: **the bundle first**
(`\ffmpeg\ffmpeg.exe`), then **PATH** as the escape hatch for someone who
deliberately installed their own. The order is the opposite of the original ("the user's own install
wins") on purpose: a PATH `ffmpeg` may be a different major version or a **GPL** build, and the
bundle is the one that was pinned, license-vetted and tested. If neither hits, it throws a loud
`IOException` naming the missing folder and the fetch script — the old "found something on disk"
cache path is gone deliberately, because silently satisfying a missing bundle with a stale copy is
exactly the silent drift a certification-gated dependency must not have.
**The bundle is not in git** (135 MB of incompressible DLLs would tax every commit on this
9p-bridged tree). `scripts/fetch-ffmpeg.sh` populates `./ffmpeg` from a pinned month-end BtbN
LGPL-**shared** win64 artifact, **verifies SHA-256 before extracting**, and is idempotent; set
`FFMPEG_ZIP=` to reuse a pre-downloaded archive. `ytLive.csproj` copies `ffmpeg\**` to the
output (`CopyToPublishDirectory="Always"` for the MSIX package) and the `GuardBundledFfmpeg` target
**fails the build** if the folder is absent, so a fresh clone finds out before the creator presses
Record rather than during a take. `ffmpeg.exe` + `ffprobe.exe` + the `libav*.dll` family must stay in
one folder — Windows resolves a shared build's DLLs from the executable's own directory.
`ffplay.exe` (18 MB) is deliberately **not** shipped; nothing calls it.
BtbN LGPL-shared (not gyan.dev, not static): it drops GPL-only libx264/x265 while keeping
NVENC/QSV/AMF + libopenh264 + native AAC, and dynamic linking means LGPL compliance is "license text +
source offer" with no static-relink (§6) material — see the Licensing guardrails below. libopenh264
is **compiled into `avcodec-63.dll`** (verified 2026-09-28: the encoder is registered there, and there
is no separate OpenH264 binary to ship), so Cisco's BSD terms + patent grant ride along inside item 1
of `THIRD-PARTY-NOTICES.txt`. Pin: `autobuild-2026-08-31-13-27` (month-end on purpose — BtbN keeps
~14 dailies but month-end builds ~2 years; a daily pin is what 404'd, see below).
**⛔ Why this was rewritten (2026-09-27 → shipped 2026-09-28).** The pinned-download design was a
defect, not a cold-start edge case. It **failed in production on 2026-09-01** — the previous daily
pin aged out of BtbN's 14-day retention and the download 404'd on the creator's first real recording
attempt. Two facts made "the pin is a const, just bump it" wrong:
- **Microsoft Store policy 10.2.2 forbids dynamic code inclusion.** Downloading an executable
from the internet and running it is the exact pattern the policy names. An MSIX submission
is rejected over it.
- Bumping the pin is whack-a-mole against a retention window we do not control.
Bundling deletes the startup network dependency entirely. The LGPL-**shared** build choice is
**unaffected** — dynamic linking was chosen for LGPL §6 compliance ("license text + source offer"),
not to enable downloading. Analysis: `TASKS/research-store-certification.md` §4.
**Guardrail:** `FfmpegLocatorTests.TheLocatorCarriesNoNetworkCapability` asserts the locator's
source contains no `HttpClient`/URL/downloader at all, so the policy cannot rot back.
**Every other third-party binary already ships via NuGet — ffmpeg was the only download-and-execute.**
Verified 2026-09-28: `e_sqlite3.dll` and `WebView2Loader.dll` land in `runtimes\win-x64\native\`
(SQLitePCLRaw + WebView2 packages) and NAudio/Notification.Wpf/Microsoft.Data.Sqlite/ProtectedData
are managed. Two gaps remain and are **not** fixed by bundling: the **WebView2 Evergreen runtime** is
a machine prerequisite (a manifest framework-dependency + graceful-failure path, TASK 48 item 2), and
**Velopack's updater downloads and executes** — its teardown is a compliance action, not just cleanup.
### Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
The encoder is a **thin orchestrator over `ffmpeg.exe`** — no H.264/AAC code in the app. It spawns the
subprocess (path from `IFfmpegLocator`), feeds raw BGRA master frames into stdin, and parses `-stats`
stderr lines into `StreamHealth` (bitrate/FPS/duration, dropped-from-frame-count). `FfmpegEncoder`
(`IFfmpegEncoder` seam) holds: `StartAsync` (locate → probe `-encoders` → spawn → stderr loop →
drain loop), `SubmitFrameAsync` (slice 10: bounded-queue ENQUEUE — never a pipe write; the drain task
owns stdin writes), `StopAsync` (flush queue → stdin EOF → ffmpeg
finalizes + exits by itself; a 10s watchdog kills it), `Dispose` (force-kill + wait), and the
`HealthUpdated`/`ProcessFailed` events. **Pattern:** the encoder never touches `Process` — it drives the
`IEncoderProcess` seam (`FfmpegEncoderProcess` wraps the real `Process`, redirected stdin/stdout/stderr
+ exit control); a `Func` factory + the locator are constructor-injected, so the
integration test fakes the whole subprocess (probe + encoder) with a Channel-backed `TextReader` whose
`Complete()` is EOF (`null`), never a `ChannelClosedException`.
**Decisions (locked):** args are pure (`FfmpegArgs.Build`, no string building in the encoder):
`-f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` (**NO `-re`** — the FramePump
is the pacer since slice 9, 2026-09-10; `-re` added a second, fighting clock on the rawvideo demux)
+ a **real audio input — the
mixer writes IEEE-float stereo to a Windows named pipe** (`-f f32le -ar 48000 -ac 2
-i \\.\pipe\ytllive_audio`, name via `EncoderOptions.AudioPipeName`; replaced the old `-f lavfi -i
anullsrc` silence in the TASK 8 audio milestone) + explicit `-map 0:v -map 1:a` + `-c:v -b:v K
-maxrate K -bufsize 2K` + **`-g fps×4 -keyint_min fps×4 -sc_threshold 0 -bf 0 -pix_fmt yuv420p`**
(≤4s keyframes, closed GOP, H.264 compliance) + `-c:a aac -ar 48000 -ac 2 -f flv `.
**Record output (TASK 18):** `FfmpegArgs.Build` emits one self-contained block per output, each its own
`-map 0:v -map 1:a` + codec tags (`AddVideoTags` helper). Stream block = `-f flv `; record block =
`-f mp4 `. `EncoderOptions.StreamEnabled`/`RecordEnabled`/`RecordPath` gate each block, so
the engine runs record-only (no RTMP) or stream-only — **stream+record simultaneously is OUT by creator
ruling (2026-09-01): the VOD is already the copy, and dual-encoding drags mid-range chassis and degrades
BOTH outputs ("we're not them"). The old "cheap on NVENC" claim was an unverified assumption; the
UI constraint is landed (2026-09-20): the REC/ON-AIR pills are radio-exclusive — arming one clears the
other — while the dual-block machinery stays generic.**
`FfmpegEncoder.StartAsync` throws unless at least one output is enabled.
**Encoder choice is probed from the binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure):
NVENC → QSV → AMF → OpenH264 fallback, **never libx264** (GPL; see Licensing). `EncoderOptions.VideoEncoder`
forces one and skips the probe. `EncoderOptions` also carries W×H/FPS/bitrate from the quality tier and
`GopSize` = FPS×4. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`),
driven by the `FramePump` below.
### Local recording (TASK 18 — shipped 2026-08-29, VM + top-bar UX; plan in TASKS.md)
Recording is **independent from streaming** and needs no YouTube sign-in. A single segmented
**REC|ON-AIR switch** in the top bar picks the mode (redesigned 2026-09-22 from the old two pills):
**REC** (local file, works signed-out) / **ON-AIR** (streaming; **armable signed-out** — intent may
light while offline, 2026-09-20: a greyed pill was the dead end the guard shipped to forbid). The switch
selection is intent; `IsRecording`/`IsLive` are reality — the reality line (dot + word + elapsed) only
lights when a session is actually running.
- **State model (`MainViewModel`):** pills `RecordPillOn`/`OnAirPillOn` stay the **routing model**
(radio-exclusive — arming one clears the other, record-OR-live 2026-09-01; the ON-AIR setter stays
armable signed-out (2026-09-20: intent must be able to light while offline)). **Top bar redesign
(2026-09-22):** the bar is ONE surface rendered by the FIRST decision — Record or Stream — and each
world is the whole bar (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`, wired by
`RaiseTopBarModes()`). The old sign-lights + two pill toggles + status dot + PRIVATE/TEST chips are
gone; the mode is one **segmented REC|ON-AIR switch** (`SegmentToggle`/`SegmentLabel` styles), and
"going back" is one tap on the other segment. Idle worlds: Record = switch + "Start Recording"
(`PrimaryStartButtonLabel` names the armed world); Stream = switch
+ "Go Live" + Test (Test is a **child of Stream** — visible only stream-armed AND signed-in) plus the
account zone on the right. **The account zone is WORLD-INDEPENDENT (2026-09-23):**
`ShowSignInButton` = signed-out and `ShowAvatarButton` = the signed-in avatar, in Record and Stream
worlds alike — it's app-level identity, and keeping it put pins the bar height (it no longer pops in
and out between worlds). Right-click the avatar = Change Account/Logout. Running retires the switch
and worlds; one reality line
`● word elapsed` + End replace them (`RunningVisible`/`RunningText`/`RunningDotBrush` — TEST/LIVE/REC,
gold/red/green). The account **status dot is gone** — the avatar/Sign In pair carries connectedness.
`ShowPrimaryStartButton` remains the always-idle face. `StopStream` clears both pills (see "Stop ends
everything; failures roll back" below).
- **Filenames** (pure `Services/RecordingFile`): auto-name `ty----0000.mp4` at
start; **rename-on-stop** to `ty-…-.mp4` (`FinalizeRecordingAsync` after the pump
stops & the file closes), numeric `-2`/`-3` suffix on collision (`UniquePath`). Length comes from
`_liveElapsed`, which the session timer walks while `IsLive || IsRecording`. The **`` millisecond
segment is load-bearing, not decoration** (2026-09-27): both names derive from `_recordStartTime`, so it
survives the rename, and without it two instances started in the same minute compute the *same* start
path and both ffmpeg processes write one file — which is exactly the dev multi-instance case
(`Helpers/InstanceProfile`, one instance recording the other).
- **Private broadcasts end the same way (creator ruling 2026-10-09).** After `StopStream`'s
transition(complete), `OfferToKeepPrivateBroadcastAsync` reads the broadcast's privacy **from YouTube**
(`GetBroadcastPrivacyAsync` — visibility can change mid-stream, local state isn't trusted) and, only if
it is `private`, shows `KeepBroadcastDialog` (repo root): **Enter = Keep** (`IsDefault`), **Escape /
Delete / the X = delete** via `DeleteFinishedBroadcastAsync` — `videos.delete?id=` (a live
broadcast's id IS its video id; 204), falling back to `liveBroadcasts.delete` on 404. An unreadable
status asks nothing and deletes nothing; a failed delete toasts that the video is still on YouTube.
Decision logic = `MainViewModel.ClosePrivateBroadcastAsync` (internal static, tested in
`PrivateBroadcastCloseOutTests`); `KeepBroadcastPromptOverride` is the dialog seam.
- **Save dialog: Cancel DISCARDS (creator ruling 2026-09-26).** The `RenameRecordingDialog` result is a
two-outcome decision, and it lives in the extracted `MainViewModel.CompleteRecordingSave(startPath, dir,
autoStem, chosenStem, creatorSaved)` (internal, so `ytLive.Tests` can drive it against real files without
a frame pump — see `ytLive.Tests/RecordingSaveDialogTests.cs`):
- `creatorSaved == false` (**Cancel / Escape / the X**) → **DELETE the temp file.** Nothing is moved,
nothing is left in the videos folder. A failed delete returns `DiscardFailed` and the toast NAMES the
file + folder, because a "discarded" recording still on disk is worse than no report.
- `creatorSaved == true` (Save, or **Enter on the pre-filled default**) → `File.Move` to the final name.
A blank/whitespace box still means "keep the auto name" — but only on an explicit Save.
- **The bug this fixed:** the caller used to `File.Move` UNCONDITIONALLY and only overwrite `stem` when
the dialog returned true, so `ShowDialog() == false` fell straight through into the save — Cancel
silently kept the recording under the default name. The modal itself was always correct; the
side effect was in the wrong place. **Any modal whose result gates a side effect must have every
branch handled explicitly — a falsy result must never fall through into the effect.**
- **Folder:** default `%APPDATA%\ytLlive\recordings\`, user-overridable via `ChooseRecordFolderCommand`
(`OpenFolderDialog`), persisted through `LayoutStore.Load/SaveRecordFolder` (`RecordFolder` key).
- **Explicit sign-out only:** `StopStream` no longer clears the session/token. Sign out via **Logout
only** — Change Account re-authenticates *in place* and deliberately does not sign out first
(2026-09-27; signing out first deleted `ytLlive.auth` before the browser even opened, so a
cancelled switch lost a working session). Stopping a recording leaves the creator signed in.
- **Stop ends everything; failures roll back (2026-09-01):** `StopStream` also clears both intent
pills (`RecordPillOn`/`OnAirPillOn`) — a lit pill with no session behind it is a lie. Every
frame-pump death and every go-live prep failure now runs the full `StopStream` teardown instead of
leaving a limbo (`StreamStatus.Error` with `IsRecording=true` over a dead encoder — the first-launch
zombie recording). Toast copy distinguishes "Recording stopped" from "stream pipeline stopped";
`AudioMixer.StopLive` is idempotent-safe for rollbacks that never reached StartLive. Seams:
`OnFramePumpFailed` + `IsRecording` setter made internal (test-only, InternalsVisibleTo).
Test: `SessionTeardownTests`.
- `EncoderOptions.StreamEnabled/RecordEnabled/RecordPath` gate outputs; record+stream is one ffmpeg with two
output blocks (the dual-block machinery is unreachable from the UI since the pills are radio-exclusive).
Rest of the engine (FramePump/encoder) is unchanged — `BuildEncoderOptions` always returns an options
(flags from the pills + `_activeRecordPath`), so record-only reaches the pump.
### Live audio capture (TASK 4 ship step 4 — shipped 2026-08-12; game audio bar 2026-08-13; **TASK 8 audio milestone: real stream audio + filters + duck + TRAX, shipped 2026-08-14**, plan in TASKS.md)
**Capture runs for the app's lifetime and is KISS by rule**: desktop/game audio is automatic (WASAPI
loopback), the mic is the creator's only audio control (meter/mute/volume already shipped). The whole
layer sits behind an **`IAudioSource` seam** (`Services/Audio/`: `Start`/`Stop`/`Started`/`SampleReady`/
`Failed`, IDisposable) so the app never touches NAudio directly and tests inject hermetic fakes (no
devices, no timers).
- **Sources (NAudio 2.2.1 — `NAudio.Wasapi` + `NAudio.WinMM` (for `WaveOutEvent`), MIT — item 9 in
`THIRD-PARTY-NOTICES.txt`):** `WasapiLoopbackAudioSource` = `WasapiLoopbackCapture` on the default
render device; `WasapiMicAudioSource` = `WasapiCapture` with the device resolved by `FriendlyName`
matching `MicSourceName` (the app only persists the **DisplayName**), falling back to the default
capture endpoint. Both run at the **device's own mix format** — NAudio 2.2.1 exposes no overridable
`GetDefaultMixFormat` on `WasapiCapture`, so there is no forced 48 kHz path; the mixer's
`TinyResampler` normalizes any rate/channel layout to 48 kHz stereo (this replaced the earlier
"force IEEE-float 48k" plan — the resampler IS the design). Mic device resolution re-reads the name
provider `Func` at each `Start`, so a mic picked mid-session takes effect **immediately**
(the mixer restarts the mic on pick). Both sources raise `Started` once their capture loop actually
begins — the mixer turns that into `MicConnected`.
- **`AudioMixer`** owns both sources; **`StartMicCaptureAsync` starts the mixer once at startup** and
`Shutdown` disposes it — NOT go-live — so the meters preview live. Mic samples: downmix to mono →
`TinyResampler` → **`VoiceFilterChain` per sample** (bass `LowShelfFilter` 120 Hz +4 dB → treble
`HighShelfFilter` 8 kHz +3 dB → `NoiseGate` (threshold 0.005, hysteresis 0.5, attack 0.5, release
0.0005) → **`SpeechLeveler`** (2026-10-09: automatic gain steering speech RMS to −20 dBFS over a
0.3 s window, gain −6..+20 dB, rises ≤10 dB/s / falls ≤30 dB/s, learns only while the gate is open
and above −54 dBFS so pauses/hum are never pumped) → `Compressor` (threshold 0.5, ratio 4:1); all
pure DSP) → ring buffer → the
**post-filter** `AudioLevelMeter` (RMS, 0.2 exponential smoothing) → `MicLevelChanged` →
marshalled to the UI thread → `AudioLevel`. Loopback samples: resample → ring buffer → second meter
→ `LoopbackLevelChanged` → the game bar's `GameAudioLevel`. The raw linear level maps to the meter's
display scale by `AudioLevelMeter.ToDisplay` (−60..0 dBFS → 0..1, **honest** — the +10 dB display boost
was removed 2026-10-09; the mic meter is `ToDisplay(AudioLevel × MicVolume)`, true dB math, no ×1.2).
**Why (2026-10-09):** a creator's YouTube upload measured −28.5 LUFS / −8.9 dBFS peak against YouTube's
−14 LUFS reference (YouTube only turns loud videos down, never quiet ones up) while the boosted meter
looked "dialed in". Nothing in the chain added gain — the compressor folds only above −6 dBFS and the
limiter only caps. Re-running that same audio through the new chain (mic volume 0.8): **−17.9 LUFS,
−0.7 dBFS peak**. Recording and streaming share this one mix, so both were (and now are) the same.
Measure with the bundled ffmpeg: `ffmpeg -i file -af ebur128=peak=true:framelog=quiet -f null -`.
**Mic connection state surfaces as events:** `MicConnected` (source `Started`), `MicFailed` (source
`Failed`), and `RestartMic()` re-resolves + restarts just the mic (loopback keeps running). Failures
log via `AppLog`; a mic failure zeroes the meter, a loopback failure never kills the mic. Meter `Push`
is unconditional (a `?.` on the event would skip the meter update when nothing is subscribed yet).
- **Go-live audio (TASK 8):** `BeginGoLive` → `StartLive(pipeName)` (idempotent) creates the named pipe
server (`NamedPipeAudioWriter`, behind the `IAudioPipeWriter` seam) and a live loop task; every
`mixInterval` (default 10 ms) `FillAndMix` reads a chunk from each ring buffer (silence-fills
underruns), computes the post-filter mic RMS → `AutoDucker` (threshold 0.02, duck ×0.25 / −12 dB,
attack 0.05, release 0.005, always on), applies the **honest gains** via `Func` seams
(`micGain = MicMuted ? 0 : MicVolume`; `loopbackGain = GameMuted ? 0 : GameAudioVolume`, × duck),
mixes mono mic + stereo loopback into interleaved stereo, runs the
**`MasterLimiter`** (a **−1 dBFS ceiling** so the honest gains can never sum past 0 dBFS and clip the
AAC encode — instant attack per frame, smoothed release toward unity), and writes the floats to the pipe. **The gains are wired through `AudioGainProvider`**
(`Services/Audio/AudioGainProvider.cs`) — the VM hands its `MicGain`/`LoopbackGain` Funcs to the
mixer at construction, read live each tick, so the volume sliders and mute buttons are stream-honest.
(Before this wiring the seams defaulted to unity and the controls were decorative.) `StopStream` →
`StopLive()` (closes the pipe → ffmpeg audio EOF) **BEFORE** stopping the frame pump (video EOF) —
the reverse order stalls on pipe backpressure. `FfmpegEncoder` itself is untouched; the VM owns the
pipe lifecycle.
- **Audio sync offset (TASK 22):** after the limiter, `FillAndMix` routes the whole interleaved stereo
mix through a **`AudioSyncDelay`** (`Services/Audio/AudioSyncDelay.cs`) — a pure delay line whose
offset comes from a `Func syncOffsetMs` seam (the VM's global `AudioSyncOffsetMs`, **−500..+500
ms**, persisted as `Audio.SyncOffsetMs`). This is OBS's documented fix for lip-sync: **positive**
delays audio when it runs ahead of video; **negative** advances audio when it runs behind by eating
the first `|N|` ms of the live stream head (see `AudioMixer.StartLive`; the delay line stays
clamped 0..500 internally — negative bypasses it entirely). The advance is armed once at `StartLive`
(eating the head mid-stream is impossible); positive is live-reactive (re-read per tick). A slider
on the mic bar (`"AUDIO SYNC"`, locked while live/recording via `IsEditMode`) surfaces it
(a one-time status dot next to it was removed 2026-09-23 per creator feedback — the slider +
numeric tooltip carry the state). Changing the delay flushes the line (a live change clicks
rather than smears).
- **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader`
(mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on
the default device, **looping on any clean natural end** (`PlaybackStopped` with `e.Exception == null`
→ rewind + replay; the earlier `Position >= Length` check was unreliable for `MediaFoundationReader`
and let tracks play once then stop). The desktop/game bar's mute toggles `LocalGain` (0 or 1;
`UpdateTraxLocalGain` runs on every `GameMuted` change and on TRAX load), so muting kills TRAX
in the headphones — matching the stream, where it rides the loopback channel. The volume slider
drives system volume (`PushSystemVolume`) — which scales the HEADPHONES; the loopback capture is
pre-endpoint-volume (disproven-then-fixed 2026-09-01), so the STREAM follows the knob via
`AudioGainProvider.LoopbackGain = GameMuted ? 0 : GameAudioVolume`, and the game meter multiplies
its display by the same. One knob, three honest paths. No third mixer input. The footer
**TRAX** button lives **inside the game audio bar** (in the preview overlay), **left of the
"Desktop Audio" label** (the creator's pick — it rides the desktop channel, so the music control
sits with the desktop audio controls): status dot (**red** no track / **yellow** loaded stopped /
**green** playing) + "TRAX"; **left-click** toggles play/pause (opens the in-app picker via `OpenFileDialog` when no track is loaded); **right-click** always opens
the picker (`TraxButton_PreviewMouseRightButtonUp`, `e.Handled = true`, code-behind pattern); tooltip
shows the loaded/playing track name or "No track — right-click to choose background music". The picked
track persists via **schema v9** single-row `Music` (`TrackPath`/`IsEnabled`) in `LayoutStore`. Known
wrinkle (out of scope, future feature): YouTube mutes VODs carrying copyrighted music — "music on
live, off VOD" is tabled.
- **⛔ The load is behind a bounded probe, because a share that is gone must never
freeze startup (2026-10-04).** `LoadLayout` → `LoadTrax` used to call `MusicPlayer.Load`
synchronously on the UI thread, and `new MediaFoundationReader(path)` does **not** fail
fast on a dead SMB session — it burns the whole ARP/TCP/SMB timeout (~40s, measured)
and *then* throws `0x80070035`. Measured: `OnStartup` 08:42:59 → `MainWindow loaded`
08:43:43, with 42 of those 44s inside `LoadLayout` on that one track. The rule now:
**a probe may time out; only a bind is allowed to be on the creator's path.**
`IsTrackReachableAsync(path, budget, File.Exists)` runs the probe on a thread-pool
thread under a `Task.WhenAny` ceiling (`TraxProbeBudget` = **2s**) and reports
offline/missing/slow as a normal state, not an error. Only a probe that passed gets
the real bind, and **that bind stays on the UI thread** with every other
`MusicPlayer` mutation, so no new cross-thread race is introduced. The orphan probe
thread stays parked until SMB gives up — deliberate: it is not the thread anyone
waits on. Same principle covers the webcam: a device held by another app fails fast
(sharing violation) but a virtual-camera driver can stall enumeration, so device
probes belong off the startup path too, not behind a long timeout.
- **Mic status dot (`Models/MicStatus.cs`)** on the preview-bottom MIC button: green = `MicConnected`,
yellow = `MicFailed` (in use/unplugged), red = no mic device at startup (the mixer is never started,
so loopback and the game bar can't run either — no capture devices at all). **The picked mic persists:
`LayoutStore`'s `Settings` key/value table stores `MicSourceName` (saved on pick via
`SaveMicSourceName`), and the VM restores it at construction BEFORE the mixer's first `Start` — so a
restart reconnects the same already-vetted device (green) or reports it missing (yellow), instead of
silently falling back to the default endpoint.**
- **Game audio bar** (desktop/game, **always visible** — TASK 4's `IGameAudioDetector` show/hide gating was **removed 2026-08-15**: the bar used to appear only while a full-screen game with sound was up, which kept hiding the creator's desktop meter; the detector stack is gone, desktop audio is just automatic WASAPI loopback): overlaid **at the bottom of the preview window** (bottom-center, dark translucent
chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI **in the preview
grid** — the meter is display-only (fill = `ToDisplay(GameAudioLevel) × GameAudioVolume` — the ×volume
term was missing until 2026-09-01: loopback capture does NOT shrink with the endpoint, so an
unscaled meter left it pegged at 20% volume; `GameMeterHonestyTests` guards it), but its **volume
slider drives system volume** (`PushSystemVolume` — live headphones follow the knob; the capture
side is scaled in the mix, see TRAX note above) **and mute kills TRAX** (`MusicPlayer.LocalGain` = 0). Relabelled
**"Desktop Audio"** (TASK 8) since TRAX rides the same channel.
- **`WaveToFloat`** (pure, shared): WASAPI mix formats → interleaved float — IEEE float 32-bit direct,
PCM 16-bit normalized to -1..1, `WaveFormatExtensible` with the IEEE-float subformat GUID
(`NAudio.Dmo.AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT`), trailing partial samples ignored.
- Build **0 warnings**; **197 passing** (DSP/ring-buffer/ducker/resampler/pipe + mixer/hysteresis/
game-detector/meter-scale unit tests + the TASK 8 integration test
`AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe` + the polish-batch integration
test `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` proving the provider gains reach the
pipe and mute silences the loopback).
### 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` 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 the FFmpeg locator section above.
- **⛔ 64-bit Windows only (creator ruling 2026-10-09).** Both csproj files set
`x64` (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."