# 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. ## 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 | .86 | ODROID-HC4, 12TB RAID5, media storage (NFS). SSH: `ssh mshallop@192.168.50.86` | | 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`. ### 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. ### 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 ```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 ``` **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 (2026-09-01 rollback) — **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. ### 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)**; 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 "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "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)**, 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). **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 - **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) - 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) ### ⚠️ 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** (Logout / Change Account → `SignOutYouTubeAsync`): `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` callback (test seam + save hook; services are still constructed in `MainViewModel`) - 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`; 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. `RefreshSnapshotsAsync` fills minis from each scene's background art (`LoadBackgroundImage`); **minis never render live captures** — while the Live screen is staged its mini shows the green placeholder, unstaged it shows `live-background.jpg` (real-time surface = center monitor only; user decision 2026-08-23). ### 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 bilinearly to the master (`DownscaleBgra`) before the copy, and 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. - **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); window capture (`window:`) and multi-monitor live re-targeting beyond the auto-detected game are behind the picker; picker-based captures don't survive reload. - **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. 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). - **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`). - **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):** 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). **"Add Webcam" ALWAYS opens the Windows camera picker** — the creator is never silently handed the previous camera (which used to happen after deleting one scene's webcam while another scene still used it; that identity survived, so re-adding bypassed the choice). Picking a different camera than the current app-wide one swaps it everywhere via `SwapWebcamIdentityAsync` (the same path "Change Webcam…" uses), keeping the singleton honest; picking the same one just places the config. The Add Webcam menu item greys out whenever a webcam exists **anywhere** — one camera identity app-wide (`CanAddWebcam` = `StagedScene != null && _webcam == null`, TASK 26; the old per-staged-scene gate let a second picker run from a scene that lacked the config). **Removing the last webcam config anywhere clears the identity** (`_webcam = null`, raising `CanAddWebcam`/`CanChangeWebcam`), which also drops "Change Webcam…" and re-enables Add. 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 = milestone 2** — ONNX Runtime + DirectML (CPU fallback), MediaPipe Selfie Segmentation (Apache-2.0), wired into the same `VideoFrame` seam. Not part of milestone 1. ### Scene compositor (TASK 4 ship step 1 — shipped 2026-08-10, plan in TASKS.md) The encoder needs the master 1920×1080 frame **without** the preview's editing chrome (SelectionOverlay, DimRects, output-rect outline, badge, placeholder). WPF's `RenderTargetBitmap` is software-rendered and captures the visual tree *including* chrome, so the preview can't be captured — the output is a **second, parallel software compositor** over the `VideoFrame` (BGRA8) seam, and the XAML preview (`MainWindow.xaml` CanvasGrid + element DataTemplate) is the rendering contract it replicates. Two renderers must agree: geometry, `UniformToFill` cover-crop, round clip, mirror, border, z-order. Preview stays XAML (editing view); the compositor is the output view. - **Render the active tier's output rect directly** (`CompositorOptions {SourceRectX/Y/W/H, OutputWidth, OutputHeight}`, fed from `MainViewModel.OutputRect*`): 16:9 = full 1920×1080 1:1; the vertical 9:16 tier = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920. - **CPU posture:** with FFmpeg as a subprocess the master crosses a CPU readback to the pipe every frame anyway, so GPU compositing buys little at this layer count (2-3 live layers; static layers pre-composite once into a cached base). GPU effort belongs to **NVENC** (the encoder), not composition; if composition grows (wipes, filters, many layers), a D3D11 compositor can replace this one **behind the same seam** — the CPU master buffer stays the contract. - **Branding flash is composited by the output path too** (it's on the live output, per Monetization), passed in as a pre-rendered `VideoFrame?` — the compositor core stays pure byte-math, no WPF. Likely a bundled asset rather than runtime text rendering (deterministic, no font/layout risk). - **Frame sources are injected** via a `Func` resolver (`SceneCompositor.Render(scene, frameFor, flashFrame, options)`) — the caller maps each element to its frame (webcam → `DeviceId`, image → `AssetId` via `StaticPixelCache`, backdrop → `CaptureKey`), so the compositor is pure, WPF-free, and hermetic to test. The capture managers wire into that resolver in the encoder step, not the compositor step. The master buffer (the compositor's return value) is the seam a future D3D11 compositor would honor identically. ### FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10, plan in TASKS.md) The encoder's one external dependency is `ffmpeg.exe`; it's never shipped in the repo. `IFfmpegLocator` resolves an absolute path on demand: **PATH probe first** (the user's own install wins — their choice, their responsibility), then the cache (`%APPDATA%\ytLlive\tools\ffmpeg.exe`), then a **pinned** BtbN LGPL-**shared** win64 zip (~75 MB) from which `ffmpeg.exe` **and the `libav*.dll` family** are extracted (staged temp-write + move so a crash never corrupts the cache; Windows resolves the DLLs from the exe's own directory). 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. The pin is a dated autobuild tag (immutable); BtbN retention keeps the last 14 daily + each month-end for 2 years, so a cold cache can outlive the pin → the seam throws a clear, logged error (recoverable; the pin is one const). Constructor-injected search dirs / tools dir / downloader (`Func>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the encoder step (not yet — this PR ships the seam + impl + tests only). ### 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), `SubmitFrameAsync` (serialized stdin writes under `SemaphoreSlim`), `StopAsync` (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): `-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` + 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), stream-only, or stream+record (video encoded twice off the one raw input pipe — cheap on NVENC). `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. Two pill toggles in the top bar declare intent: **REC pill** (local file, works signed-out) and **ON-AIR pill** (streaming; greyed/disabled until `IsConnected`). The pill is intent; `IsRecording`/`IsLive` are reality — the REC status dot only turns green when a session is actually recording, the ON-AIR dot when actually live. - **State model (`MainViewModel`):** pills `RecordPillOn`/`OnAirPillOn` (ON-AIR setter no-ops if `!CanToggleOnAir`), derived `PrimaryButtonText` ("Start Streaming" vs "Start Recording"), `ShowPrimaryStartButton` (offline + [connected OR record-only]), `IsStreamingStart`, `ShowEndStreamButton`, `CanStartSession`, `AccountStatusLightToolTip`. `IsEditMode` now also requires `!IsRecording` (lock scrubbing while recording). `StartSession()` routes: ON-AIR on → GoLive dialog then stream (± record); only REC → `BeginRecordOnly()` starts the pipeline directly, no YouTube dialog. - **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`. - **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 / Change Account. 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. Rest of the engine (FramePump/encoder) is unchanged — `BuildEncoderOptions` now always returns an options (with 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) → `Compressor` (threshold 0.5, ratio 4:1); all pure TDF2 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 with **+10 dB input amplification**). **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`, 0..500 ms, persisted as `Audio.SyncOffsetMs`). This is OBS's documented fix for audio running ahead of the video (delay it a few ms); positive-only, since advancing audio would need a video-side delay. A slider on the mic bar + a status dot (`IntToSyncBrushConverter`) surface it. 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. - **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). ### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, health stats 2026-08-13, plan in TASKS.md) The **`FramePump`** (`Services/Encoder/`) is the live frame producer: while live it snapshots the active scene each tick, resolves every element to its latest frame, composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. **Pattern — everything is a constructor-injected seam:** `Func`, `Func` resolver, `Func`, `Func`, `Func`, `Action` log, and an injectable pacing delay (default `Task.Delay`; tests inject `Task.Yield`). The pump is free of WPF and of the capture managers. - **`StartAsync` never throws** — the VM fires-and-forgets it from the sync command handler; failures log + surface via the `Failed` event. `BuildEncoderOptions` now always returns options (TASK 18) with `StreamEnabled`/`RecordEnabled`/`RecordPath` baked from the pills + `_activeRecordPath`; the pump skips the encoder only when `options == null`, i.e. "no output configured". `MainViewModel._rtmpUrlProvider` is that seam — a `Func` that now yields the reusable stream's ingest URL (TASK 9, shipped 2026-08-16): loaded from the `LayoutStore` cache at startup and set fresh by `PrepareAndStartLiveAsync` before the pump starts. **The pump reads the URL once at startup**, which is why go-live ensures the stream BEFORE `StartAsync`. - **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits) **before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips `StreamStatus.Error` (minimal). - **Health stats (ship step 6, shipped 2026-08-13):** `HealthUpdated` is bound to the bottom bar — `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the encoder's stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing `CurrentHealth.*` bindings). `ResetHealth(status)` zeroes dropped/duration on go-live and on End so stats never linger from a previous session; bitrate/FPS stay on the tier's targets (`ApplyStreamQuality`). The bar shows real encoder values since TASK 9 (2026-08-16) wired the reusable stream URL into `_rtmpUrlProvider`. - **`MainViewModel` owns the resolver** (`ResolveOutputFrame`): `WebcamSceneConfig` → `CameraManager.GetLatestFrame(WebcamId)`, `Source { IsLiveCapture, CaptureKey }` → `ScreenCaptureManager.GetLatestFrame(CaptureKey)` (the new accessor mirroring `CameraManager`), image/ background → `StaticPixelCache.Get(AssetId)`. `BuildCompositorOptions` rounds the VM's `OutputRect*` doubles to ints — the vertical 607.5 half-pixel crop rounds to a perfectly-centered **608** (`Math.Round`, ToEven); `BuildEncoderOptions` fills W×H/FPS/bitrate from the tier once the URL provider yields one. - **Media source decoder (TASK 21, Increment B — 2026-08-31):** `MediaVideoSource` (`Services/MediaVideoSource.cs`) decodes a local file to `VideoFrame`s by spawning ffmpeg with `-f rawvideo -pix_fmt bgra -vf scale=WxH -an` and draining the raw BGRA stdout pipe through the pure `RawVideoFrameReader` (`Services/RawVideoFrameReader.cs`), raising `FrameAvailable` per frame. Implements `IMediaFrameSource` (`Services/IMediaFrameSource.cs`: `Key`, `FrameAvailable`, `Completed`, `StartAsync`/`StopAsync`). The subprocess sits behind `IDecodeProcess`/`FfmpegDecodeProcess` (binary-stdout mirror of the encoder's `IEncoderProcess`), and the path comes from `IFfmpegLocator` — codec-agnostic, uses the ffmpeg already shipped, testable with a fake process. Decode sessions are owned app-wide by `MediaVideoSourceManager` (`Services/MediaVideoSourceManager.cs`): refcounted by `MediaPath`, `Func` factory seam (mirror of `ScreenCaptureManager`), `AcquireAsync`/`ReleaseAsync`/`ReleaseAllAsync`/`GetLatestFrame`, coalescing each file's frames onto the UI dispatcher onto one shared `WriteableBitmap`. Frames emit as fast as the pipe produces them. Wired into the live pipeline (slice 1 step 4): `ResolveOutputFrame` reads `_mediaManager.GetLatestFrame(MediaPath)` for a `MediaSource`, and `Source.DisplaySource` (`Models/Source.cs`) routes `VideoImageSource` for `MediaSource` so the preview/canvas show decoded video. Slice 2a shipped the probe seam: `FfmpegFrameRateParser` (pure, `avg_frame_rate=` then `r_frame_rate=` rational N/N/M, unknown→null) + `IFrameRateProbe`/`FfmpegFrameRateProbe` (derives sibling `ffprobe.exe` from the located ffmpeg dir, reuses the `IDecodeProcess` seam for the ffprobe subprocess text; null if ffprobe absent) + `FfmpegLocator.ProbeFileName` now also extracts `ffprobe.exe` from the pinned archive (conditional — old caches without it just get no pacing). Slice 2b wired pacing: `MediaVideoSource` takes optional `IFrameRateProbe?` + `Func? delay` (default `Task.Delay`) seams, probes FPS once in `RunAsync`, and delays by 1/fps after each emitted frame; no probe/unknown → no pacing (ffmpeg's own pipe backpressure already throttles the decode). Slice 3 added loop control: `IMediaFrameSource.Looping` (bool); `MediaVideoSource` takes a `Func` process factory (a single `Process` can't be re-`Start()`ed, so each loop pass creates a fresh decoder) and wraps the decode in a `do…while (Looping)` — restart on natural EOF instead of raising `Completed`. Production wiring (MainViewModel media factory): passes `() => new FfmpegDecodeProcess()` as the factory, `FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess())` as the probe. Still open: session acquisition on add/remove (UI picker) + wiring `Source.MediaIsLooping` into `IMediaFrameSource.Looping` (needs a manager-level per-path loop provider, comes with the picker slice). - **Social bar on the output (bar bug-fix branch):** the `FramePump` takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam, re-read **every frame** (so a mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs` (WPF glue — WPF glue here is fine because the strip is rendered once per config change on the UI thread, the resulting immutable frame is then composited pure-CPU by `SceneCompositor`), and `SceneCompositor.Render` blits it **last — above the branding flash** at `socialBarTop` (0 = top, `SourceRectHeight − barHeight` = bottom) in master space. `MainViewModel` owns the frame (`_socialBarFrame`, rebuilt by `RenderSocialBarFrame` on load/save/`NotifySocialsChanged`). - Known consideration: the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation exception is contained (logged + `Failed` + the pump stops) rather than crashing. The background thread + video pipeline is the new reality since this step. ### 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 (GA = paid product; see `THIRD-PARTY-NOTICES.txt`) This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT guardrails below — written down so a future "quick fix" never reintroduces a GPL binary. **NEVER:** - **Use a GPL FFmpeg build** — gyan.dev's builds are GPLv3 and ship libx264; BtbN's `gpl` variant is GPL too. GPL in a distributed paid product is the #1 lawsuit risk. Only BtbN `lgpl` / `lgpl-shared` builds are allowed. - **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 a copyleft or attribution license would contaminate the paid product. The About hub's logo is the creator's own art ("llama fortnite superman logo", `Assets/llama-logo.png`) — owned by us, so it carries no third-party license either. - **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 ## Monetization (design decision — the branding flash is the sword) Full pricing, discount codes, and Polar product details in `MONETIZATION.md` (gitignored). 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 between free and paid. The **only difference is watermarking**. This is deliberate: no creator will tolerate a watermark, and as a good-guy developer, we give them complete access to every feature so no one can call us crooked. - **Free:** a periodic full-frame branding flash — "made with LlamaCasty!" rendered big and centered at ~25% opacity for about one second (soft 250ms fade in/out), repeated every 300s, on the live output (and on local recordings). Implemented as `BrandFlashLayer` in the preview compositor (`MainWindow.xaml` CanvasGrid) + `BrandFlashTimer` in `MainViewModel` — cadence 300s, first flash ~5s after go-live, only while live or recording. An always-on watermark can be cropped or covered; an intermittent full-frame flash can't be cropped and is impractical to edit around on a live feed. **The flash is also the free tier's billboard** — every free stream advertises LlamaCasty to its own viewers; the free tier is distribution, not compromise. **Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting — intervals shorten with use, starting at the 300s cadence and creeping toward a floor (the exact curve is a build-time design knob). License activation still flips exactly one bit: `IsPremium` → flash off. Nothing else changes between free and paid, ever. **Pre-GA posture:** while the app is unreleased the flash renders **in the preview only** and is never composited onto the live output or local recording — test VODs stay clean (same channel- protection stance as the visibility lock), and creators can be shown what free looks like without it ever touching a real broadcast. Flipping the flash live-on is a **TASK 36** unlock item. - **Paid (annual subscription):** branding flash removed (flips `BrandFlashEnabled` off). That's it. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording — everything is free. - **Pricing:** early-access founders rate **$49.99/yr** → **$99/yr list at GA** (v1). **Grandfathering: early adopters keep $49.99/yr for as long as the subscription is maintained**; a lapse means renewal at list. That's the whole policy — no escalation matrix (a realistic product lifetime is a few years; keep the promise simple). - **Billing:** **Polar (polar.sh)** — open-source MoR (Apache 2.0), handles payments, subscriptions, license keys, and global tax compliance. Startup Program gives Scale plan free for 12 months. Product: `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0`. Checkout: `llamacasty.com` → Polar hosted page. Org ID: `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` omitted from API calls). Key prefix: `LCYT-`. Discounts: `LLAMAFOUNDER` (100% off, 50 uses), `LLAMA50` (50% off, 12 months). Details in `MONETIZATION.md`. - **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. The only license chatter is the paid-user expiry reminder (TASK 36 item 5); active subscribers see zero license UI. **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`) already receives every reward event over `liveChatMessages` but today only decodes two (`superChatEvent`, `newSponsorEvent`); the other five — `superStickerEvent`, `membershipGiftingEvent`, `giftMembershipReceivedEvent`, `memberMilestoneChatEvent`, `giftEvent` — are silently dropped. The plan: decode all seven into a canonical `RewardEvent` and persist each to a SQLite table (`RewardEvents`: broadcastId, type, timestamp, amountMicros, currency, tier, memberLevelName, gifter/recipient channelIds). `superChatEvents.list` (30-day lookback) backfills prior broadcasts. This single feed is the foundation that Alerts, the session report, and the journey tracker all read. - **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.** On sign-in/startup, pull current `channels.list?mine=true&part=statistics` (`subscriberCount`/`viewCount`/`videoCount` — the current OAuth scopes suffice). Compute position toward each YPP tier threshold and project an ETA curve from the creator's own per-stream/per-day velocity (subs + watch hours). **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 a wrong ETA. Analytics-API scopes (`yt-analytics.readonly`, additive `yt-analytics-monetary.readonly`) are an additive capability behind a seam (`IChannelStatsProvider`), not a v1 blocker — current-scope data is the v1 baseline; the Analytics scopes would require an OAuth re-consent, deferred. **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) 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. - **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. - **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. 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)** — 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"), log-only on failure (`invalidTransition` when autoStop already fired — never throws, never toasts a finished session); `enableAutoStop` remains the safety net. The design had always called for this call; it was never built until the recording-verification session caught the gap. 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).