Files
gramps 85fad1ab79 docs: distribution route decided — Microsoft Store MSIX + Store IAP
Creator ruling 2026-09-27. Criteria, verbatim: "zero headaches, minimal
maintenance (for me) while still providing accountability and a reasonably
easy upgrade flow." Route A is the only combination where all four are solved
by handing the work to Microsoft rather than to a certificate vendor: $0/yr,
no certificate, no HSM, no annual renewal, no SmartScreen ramp — plus Store
auto-update, Store-side payments/entitlements/refunds/support, and Microsoft
review as the accountability layer.

The rejected options and their reasons stay in research-store-certification.md
§3 so a later session reads the ruling instead of re-deriving it.

What this deletes:
- The entire licensing backend. PolarLicenseService, PolarLicense,
  MainViewModel.License.cs (PremiumUrl, customer portal, the OfflineGracePeriod
  = 14 days subscription-era artifact, renewal/lapse copy) and the wrong
  "Polar unlocks alerts" string all become dead code. IsPremium is derived from
  the Store entitlement instead of an HTTP call, which also removes the whole
  "network flaky -> app thinks I'm expired" bug class.
- Velopack, the update URL, and the self-hosted droplet — the Store updates.
- Distribution.md's premise: Polar as the distribution backbone, Polar file
  hosting, and code signing as our problem. The IP-protection sections (1, 5,
  6, 7) still stand and the build-posture ceiling is unchanged.

What does NOT change: the entitlement. Free gets everything; the branding
flash stays the only paid delta. Store IAP changes how IsPremium is obtained,
never what it gates.

Still open, deliberately: the price. The Store revenue share is unverified (do
not assume a percentage), and MONETIZATION.md's $29 -> $49 one-time decision is
re-opened against a fresh instinct toward ~$99/yr. No price encoded yet.

The first code unit is unchanged: bundle ffmpeg (TASK 48 item 1). That clears
the one hard certification gate and fixes a real user-facing 404.

MARCOM.md and MONETIZATION.md were edited too but are gitignored by design, so
those changes stayed local.
2026-09-27 14:23:51 -07:00

1906 lines
180 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ytLlive — AI Guide
> Memory map entry point. Conventions live in [`schema.md`](schema.md); task
> status and YouTube API research in [`TASKS.md`](TASKS.md); brand/marketing
> palette and launch strategy in [`MARCOM.md`](MARCOM.md) (gitignored — read
> it before any branding or marketing work); directory maps in each folder's
> `index.md`. Reading order: this file → `TASKS.md` → `MARCOM.md` → `<dir>/index.md` → source.
## 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.
### Product name vs repo/assembly branding (2026-09-14)
The product is **llamacasty** (llamacasty.com, llamachile.tube, `llgit.llamachile.tube`). The
repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths (`%APPDATA%\ytLlive\...`),
and most internal namings are the legacy **ytLive/ytLlive** — a re-brand that never renamed
the internals. Treat the two as separate: external/user-facing language says "llamacasty",
code/assembly/repo names stay `ytLive`. If a full internal re-brand is ever done, this note
and the UI locator (Premium/wordmark) are the checklists; it is NOT a goal pre-1.0.
**Say "LlamaCasty" when reporting to the creator** — never "ytLive" in prose. `ytLive` is
the internal repo/assembly/namespace name only. Exact current split:
| surface | name | note |
|---|---|---|
| product / site / marketing | **LlamaCasty** | llamacasty.com, llamachile.tube |
| repo dir, csproj, `RootNamespace` | `ytLive` | 230 files: `namespace ytLive.*` |
| `AssemblyName` → shipped exe | `ytLive` | `ytLive.exe` |
| app data dir | `%APPDATA%\ytLlive` | **capital L**, drifts from `ytLive` |
| `Distribution.md` tells customers | `LlamaCasty.exe` | ⚠ **mismatch vs the real `ytLive.exe`** |
**Consequences, so nobody re-hunts them:**
- Renaming `<AssemblyName>` to `LlamaCasty` is the cheap win (the customer-visible exe
name matches what `Distribution.md` already promises; namespaces can stay `ytLive.*`).
It is **4 lines**, but it hard-breaks three pack URIs that spell the assembly name:
`MainWindow.xaml:14` (the **window icon** — taskbar/Alt-Tab), `OverlayHost.xaml:96`,
`RenameRecordingDialog.xaml:5`. Miss those and you ship a broken icon. Queued in TASKS.md
under the shipping task, not done.
- Renaming the `%APPDATA%\ytLlive` folder is **breaking** — it would orphan every existing
user's layout DB, `Assets` and settings. Never do it casually; needs a migration.
- No code compares its own assembly name (no `GetExecutingAssembly().GetName().Name` checks),
so the assembly name is otherwise free to change.
### AI transparency
This project is built through AI-assisted pair-programming. All code is generated
under the supervision of an experienced developer with decades of coding experience.
Architecture decisions, product direction, and quality gates are human-owned;
AI accelerates implementation. This is a testimonial to successful human-AI
collaboration, not autonomous code generation.
## Run
```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 "<file1>" "<file2>" ...` (declared scope) before committing. It forces a
**clean** build (0 warnings), the full test suite (passes only the 2 known pre-existing `[FAIL]`s), and
the scope check in one shot. An **incremental** build can report `0 Warning(s)` while skipping
recompiles — that's how `CS8601` slipped past on 2026-08-29. Never claim 0 warnings from anything but
`verify.sh`'s clean build.
`EnableWindowsTargeting=true` in `ytLive.csproj` lets a cold restore work from WSL, but a Linux
`dotnet run`/`build` re-downloads 100M+ of `windowsdesktop.app.*` packs into the Linux NuGet cache
(which lacks them) over the slow 9p `/mnt/c` bridge — twice, because the WPF `_wpftmp` generated
project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the
packs resolve from `C:\Program Files\dotnet\packs`, so the Windows host never re-downloads.
Never use `--no-restore` right after an interrupted restore — the stale `project.assets.json`
produces misleading `NETSDK1064` "package not found" errors. Running requires Windows anyway.
## Tests
xUnit in `ytLive.Tests` (net8.0-windows10.0.19041.0, matches the app TFM). Run on Windows —
WSL can't run net8.0-windows tests:
```bash
dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
```
Good Dog Rule: ONE integration test per change (no feature branches pre-1.0 — work lands on
`main`, see AGENTS.md). Current: TokenStore DPAPI
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip,
LayoutStore background roundtrip, LayoutStore HasBackground roundtrip + v5→v6 non-Live backfill,
ScreenCaptureManager refcount + shared-bitmap + coalescing
(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackgroundTests, SceneCatalogTests,
the TASK 25 dirty-layout heal (BackgroundHealIntegrationTests), WebcamSafeguardTests,
SceneCompositorTests (full-scene composite + vertical tier), StretchMathTests, FfmpegLocatorTests,
FfmpegEncoderTests, FramePumpTests, the TASK 8/11/12 audio chain (AudioPipelineTests + MasterLimiter),
the Socials fediverse-heal roundtrip, AboutHubTests, NotificationAreaIntegrationTests (TASK 24),
GlobalHotkeyTests + HotkeyConfigTests (TASK 20), WebcamMenuGateTests (TASK 26), ChatLayerGateTests
(TASK 27), BroadcastPullOutTests (TASK 29), DefaultRecordFolder fallback (TASK 30), WebView2ManagerTests
(TASK 17), RecordingFileTests + OnAirSignTests (TASK 18), SessionTeardownTests +
WebcamOutputKeyTests + GameMeterHonestyTests (2026-09-01 recording-verification fixes) — **ZERO
known failures as of 2026-09-01. `AudioPipelineTests` 25/25 green in 26ms —
the "known failing" `Mix_HonorsProviderGains…` and the class's notorious STANDALONE HANG shared
one root cause: TASK 22's `_delayedMix` (nullable, never initialized) was dereferenced
(`delayed.Length` on null) every live-mix tick — a swallowed NRE starved the pipe the tests
read and flooded startup.log. Test was right, code drifted from the map's own contract
(loopbackGain = GameAudioVolume — now honored; the "unity because loopback scales with the
endpoint" assumption was disproven by the creator's 20%-volume meter observation). The former
RoundClip "known failure" (stale-test layers: namescoped `FindName` + `VisualTreeHelper.HitTest`
where `UIElement.InputHitTest` models input — see MyMistakes) was fixed the same day.
Per-class runs through the Windows dotnet.exe host execute the RealApp/MainWindow suites fine;
only the FULL-suite run still hangs (WASAPI teardown, pre-existing) — and the whole-suite
"247 total" era count is stale; trust per-class results.**
Reward-event capture (monetization awareness, see the Monetization section) will add its integration
tests here when it ships: one real chat-poll payload containing all seven reward event types →
assert the persisted canonical `RewardEvent` rows (type + amount/currency/tier/memberLevel/participant
ids) round-trip into SQLite.
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
The integration test boots a real `MainWindow` → `MainViewModel` → real `LayoutStore`
(`%APPDATA%\ytLlive\ytLlive.db`). `Shutdown()` on close **saves the layout** (full rewrite:
DELETE all scenes/sources, re-insert), so any source a test adds would be persisted over the
user's real ones — this happened and wiped the real webcam source (DeviceId replaced by the
test's fake `test-camera`). Rule: a test that constructs `MainWindow` MUST first set
`MainViewModel.LayoutPathOverride` to a temp DB path and reset it (plus
`SqliteConnection.ClearAllPools()` + delete) in `finally`. The seam is
`internal static string? LayoutPathOverride` (line ~529 in `MainViewModel.cs`),
`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`.
The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so
save/load round trips are exact: an element removed in the UI (`RemoveElement` →
`scene.Elements.Remove` → `OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
## Architecture
C# / WPF (.NET 8) following MVVM:
| Path | Role |
|------|------|
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition), and now also over `Services/AlertOverlayLayer.cs` (TASK 43 — the native alert box, same true-component pattern)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **alerts: `ChatOverlayLayer` + `AlertOverlayLayer`/`AlertRenderer` (the native celebration-zone overlay, TASK 43 — see "Native events & alerts")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `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)**, **BuildStamp (wordmark release counter `#N`, +1 per commit, generated by `GenerateBuildStamp` in ytLive.csproj via git rev-list; the per-build GUID now logs to startup.log only — 2026-09-04)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) |
### Key patterns
- `ViewModelBase.SetProperty<T>()` for property change notifications
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the 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 (`<vm:MainViewModel/>` as DataContext)
- **True decomposition beats partial-shuffling (2026-08-31, Commit G):** `MainViewModel` is a ~4100-line god-object split into 17 partials — but partials are a *myth of decomposition*: every partial shares the same class, same `SetProperty` state, same collaborators via `Scenes`/`StagedScene`/`IsLive`/`Source.VideoImageSource`, so an AI fetching one partial still reconstructs the whole class. The only thing that actually improves retrieval is a boundary where a feature **owns its own state + collaborators**. `Services/ChatOverlayLayer.cs` is the model (extracted from `MainViewModel.Chat.cs` 194→44): the layer owns the message buffer, `ChatBoxRenderer`, fade/mock timers, preview renders, and the live `RenderFrame`; the VM keeps only the binding surface (`ChatMessages` delegates to `_chatLayer.Messages` so XAML + LeftPanel `CollectionChanged` hold; the two computed gate props stay on the VM because their `OnPropertyChanged` is raised from VM setters). **`Services/AlertOverlayLayer.cs` (TASK 43, 2026-09-24) is a second true component built fresh on the same contract** — it owns the event queue, the 33ms playback ticker, the per-source preview renders and the live `RenderFrame`, with `Advance(double)` exposed as the deterministic test clock; the VM again keeps only the facade (`RenderAlertBox`, `UpdateAlertBoxPreview`, `CanAddAlerts`). Two COMPONENT layers, one facade partial. **Diagnostic — glue vs. component:** before extracting, count `OnPropertyChanged`/`SetProperty` touches (glue) and bound-property reads per partial. Zero/low-glue + a cohesive state blob (renderer/timers/buffer) = extract (chat). High glue over an already-extracted service (Audio, Webcam, Background, Scenes — `AudioMixer`/`CameraManager`/`ScreenCaptureManager`/`ChatBoxRenderer` already exist) = leave as VM glue; forcing it adds coupling, doesn't remove it. Don't manufacture seams to hit a line count — the line count is a guideline for context, not a design goal.
- **SceneGraph component + baked-crust compositor (TASK 31):** `Services/SceneGraph.cs` owns the scene collection (`Scenes` — the ViewModel's `Scenes` property delegates to it) and the element mutation surface (`AddElement`/`InsertElement`/`RemoveElement`/`MoveElement`, each invalidating the bake cache) plus queries that were scattered LINQ (`GetBackground`/`GetWebcam`/`GetChatBoxes`/`GetSplitPoint`/`IsStatic`). Elements expose `ElementKind Kind` (`Static` = images/background art, `Dynamic` = webcam/live capture/chat/web). The **split point** is the index of the first dynamic element; `SceneGraph.GetBakedBase` bakes/caches all static layers below it (keyed by scene id + static element identities), and `SceneCompositor.BakeStaticBase`/`CompositeLayers`/the `Render(.., staticBase, split)` overload composite the dynamic/above-split layers per frame. `FramePump.RenderScene` uses the optimized path when a `SceneGraph` is wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (via `InvalidateBake` from the VM's element-property and background-heal paths). **Defensive deviation from the TASK 31 spec:** `ChatOverlayLayer` keeps taking `IEnumerable<Scene>` instead of depending on `GetChatBoxes()` — it is deliberately decoupled from the graph (its doc comment says "without owning the scene graph"); and the static background helpers (`EnsureBackground`/`NormalizeBackgrounds`) stay on the ViewModel because `BackgroundTests.cs` unit-tests `MainViewModel.EnsureBackground` directly. Queries + invalidation moved; helpers stayed.
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load
- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug).
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary)
- 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`; the real API nests them under `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — the parse accepts that object shape and the legacy flat-string shape (fix 2026-09-22: the object shape crashed every poll with `requires an element of type 'String'`); banner decision in pure `StreamHealthReporter`
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED**, **frame-pipeline wiring (TASK 4 ship step 5) is SHIPPED**, **health stats (TASK 4 ship step 6) is SHIPPED**, **one-click go-live + private-only enforcement (TASK 4 ship step 7) is SHIPPED** — TASK 4 (RTMP Ingest) is fully done; full plan in `TASKS.md`
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) shipped — steps 1-7 (Velopack bootstrap, PolarLicenseService, license entry UI, LayoutStore entitlement, BrandFlash toggle, startup re-validation, PremiumUrl wired); Velopack update URL pending:
- TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates
- TASK 19/23 merged: Control Surface UX — director's control room, transitions (Cut/Fade/Move), thumbnails above central monitor, edit mode offline only, right panel eliminated
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
- Broadcast metadata pull-out + launch geometry — shipped 2026-08-24 ("Text" tab on the Live screen, see its section below)
- TASK 21: Media source (video file playback) — starting soon videos, BRB loops
- TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards
- These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in `TASKS.md`.
### Control Surface UX (TASK 19/23 — shipped 2026-08-24, verified by code survey)
The app is a **director's control surface**, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor.
**Layout:** Two-column. Left panel = one panel, two states. Center = thumbnails above central monitor.
- **Left panel:** When offline (edit mode) → LAYERS + PROPERTIES (same as current, minus SCREENS). When live/recording → YouTube Live Chat (entire panel replaced, never coexists with layers/properties). Mode is implicit (derived from `StreamStatus`), no toggle button.
- **Thumbnails:** Five scene previews above the central monitor, 16:9, drag-reorderable. Click = stage (offline) or transition (live). Active scene gets brand-red glow border.
- **Central monitor:** Shows staged scene (offline, editable in edit mode) or live output (live, read-only). During transitions: compositor-blended animation.
- **Right panel:** Eliminated. Chat moved to left panel (live mode).
- **Edit gating:** `IsEditMode` = `StreamStatus == Offline && !IsRecording`. When live/recording: all drag/resize, context menus, right-click property adjustments disabled. Gun safety — no accidental edits mid-stream.
**Scene reference split:** `ActiveScene` → `StagedScene` (central monitor target, editable offline) + `LiveScene` (encoder output). Go Live sets `LiveScene = StagedScene`. Thumbnail click sets `StagedScene` (offline) or fires transition (live).
**Transitions:** `TransitionService` (new file) — Cut/Fade/Move with configurable duration (default 300ms). `SceneCompositor.RenderBlend()` blends two scenes' frames. `FramePump` feeds `LiveScene` to encoder; during transitions, feeds blended frames. Go Live always = Cut (clean start).
**Thumbnails:** `Scene.Snapshot` property. `SceneCompositor.Render()` at 320×180 → `WriteableBitmap`. Only staged scene has live backdrop render; other 4 = static snapshots.
**Thumbnails:** `Scene.Snapshot` property. `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<LayoutStore>` seam — survives layout re-pointing; owns drawer state; `UpdateBroadcastCommand` gated by `CurrentBroadcastId != null && !IsUpdating`). `YouTubeStreamService.UpdateBroadcast` PUTs `liveBroadcasts?part=snippet,status` and **must echo scheduledStartTime — update replaces the whole snippet part**, omitting it clears the schedule. Go Live prefills the dialog from the form (`BroadcastForm.Title/Description`) and calls `CaptureGoLive(title, description, now)` on confirm. `MainViewModel.CurrentBroadcastId` exposes the private `_currentBroadcastId`; notified on create/end.
**The old Default Stream Title/Description** (un-persisted, App Settings overlay) are DELETED — the form owns those fields end-to-end; Go Live prefill routes through it.
**Launch geometry:** default **1920×1040**, MinWidth **1366**, MinHeight **768**, `WindowStartupLocation=Manual`. Window size/position persist on close via `RestoreBounds` (+ WindowState Normal/Maximized; Minimized treated as Normal) into Settings keys `Window.*`; restored in the MainWindow ctor BEFORE show, clamped to the minimums and the primary `SystemParameters.WorkArea` (a position saved on a since-disconnected secondary monitor falls back on-screen). Multi-monitor restore is not supported yet.
Test seam note: `YouTubeAuthService.SetSession()` is public — tests inject a channel with future `TokenExpiry` so `EnsureToken()` passes offline; pair with an `HttpClient(new StubHandler())`.
### 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:<n>`, `window:<hwnd>`, or `picker:<displayname>`. The Live row is
a real `Source` of `Type DisplayCapture`, inserted **first** (`MainViewModel.EnsureBackground(scene)`,
internal static — also used by the `StagedScene` setter so a scene switch can never expose a
missing Background), fixed at X=0/Y=0/1920×1080, and excluded from drag/hit-test/remove/reorder (remove is guarded in `RemoveElement`;
the element template sets `IsHitTestVisible=false` for
backgrounds; the row's remove button and "Remove Source" menu item are hidden). **`Scene.HasBackground`
(persisted) is true for all five canonical scenes** (see `SceneCatalog.HasBackground`). Capture controls —
"Change Capture…"/"Refresh Capture"/"Capture Display"/"Show Desktop" — exist only while the **Live** scene is
staged (`CanChangeBackground` = staged scene is Live; the preview CanvasGrid menu binds it directly, the layer-row
menu MultiBindings it with the row's own `IsBackground` via `Helpers/AllTrueToVisibilityConverter`). Non-Live
screens keep the properties panel pill ("Use default background") + Browse for custom art.
- **Settings hygiene:** `LayoutStore.Save` purges orphaned `BackgroundUseDefault_{id}` /
`BackgroundPath_{id}` Settings keys whose id is no longer a live Source row (TASK 25 heal drops duplicates; their keys must not accumulate).
- **Layering invariant (regression lesson, 2026-08-23):** `ActiveBackgroundImage` renders *above*
`BackgroundImage`, so it is fed by **static-art rows only** (`Type == Background`). The capture-flavored
Live row must never populate it or it permanently covers the desktop/game capture; Live's canvas shows
`DisplaySource` (capture, falling back to its AssetId art). Flavor-blind `IsBackground` lookups are for
persistence/heal/minis — not this overlay.
- **Detection (event-driven):** `Win32FullScreenDetector` hooks `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)`
— fires on the UI thread whenever the foreground window changes. The callback runs the same
`GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` +
`GetMonitorInfo` check; a window is full-screen when its frame covers all four monitor edges; own
process is excluded; monitor **index = `EnumDisplayMonitors` enumeration order** (the same order
`ScreenCaptureSourceFactory` uses to map index→HMONITOR via `Win32FullScreenDetector.GetMonitorHandle`).
`IFullScreenDetector` also exposes `GetDisplays()` (`DisplayInfo`: index/name/resolution/bounds/`IsPrimary`,
friendly name via `EnumDisplayDevices`) + `PrimaryMonitorIndex()` for the in-app "Capture Display"
submenu + `FullscreenMonitorChanged` event + `StartWatching()`/`StopWatching()`. At launch
`ReacquireScreenCaptures` heals backgrounds (`HealBackgrounds()`), then keys the Live background to the
full-screen game's
monitor; when **no game is detected**, `ResolveAutoCaptureKey()` returns `null` — stale `CaptureKey`
values are cleared, no capture session is started, and `Source.DisplaySource` falls back to
`_imageSource` (the static asset on the backdrop element). `StartWatching()` is called at the end of
`ReacquireScreenCaptures`. `OnFullscreenMonitorChanged` auto-redesignates when a fullscreen game appears
on a different monitor; null (no fullscreen / own window) is ignored — the existing capture keeps
running so loading screens / transitions don't break the stream. `RefreshBackdropAutoCapture` (bound to
"Refresh Capture" context menu + `Activated` handler) reads the current foreground directly as a manual
fallback. Deduplication: `_lastReportedMonitor` prevents redundant redesignates from rapid foreground
changes.
- **Capture (WinRT GraphicsCapture):** `ScreenCaptureFrameSource` creates a free-threaded
`Direct3D11CaptureFramePool` (2 buffers, `B8G8R8A8UIntNormalized`) + `GraphicsCaptureSession`; frames →
`SoftwareBitmap.CreateCopyFromSurfaceAsync` (alpha ignored) → `VideoFrame` (BGRA8), bytes read via
`WindowsRuntimeMarshal.TryGetDataUnsafe` (the same CsWinRT-safe read the webcam path uses) — the
`IMemoryBufferByteAccess` ComImport cast threw `Invalid cast` on **every frame** under CsWinRT, which
flooded `startup.log` (~5 MB in a session) and burned CPU, so it is gone. Surfaces larger than the
1920×1080 master are downscaled to the master (`DownscaleBgra`, integer 8.8 fixed-point bilinear —
slice 16: the same two-stage math as `SceneCompositor.Bilinear`; the previous double-per-pixel
version was ~30-45ms quiet / ~150ms under 240Hz-HDR load and froze the desktop layer ~90% of a take).
Conversions run as up to MaxConcurrentConversions (3) overlapping readbacks
(slice 17: the OS readback, not the downscale, is the ~47ms wall — see Slice 17) with a
monotonic LatestFrame publish gate (`MonotonicGate`: a slow OLDER completion can never
overwrite a newer frame), and are spaced by a 10ms floor
(`MinConvertInterval`): the monitor delivers at the **240Hz DWM cadence** (~4.2ms), far too fast for
the ~60/s the pump can use, so the extra arrivals are dropped (`skip busy`/`skip cadence` telemetry).
Hand-out buffers come from a **reuse-distance ring** (`FrameRingBuffer`, depth 8, redLine 4): a
buffer is only rewritten ≥4 rents after its last hand-out, else a fresh one is allocated — a frame a
consumer still holds (`session.LatestFrame` survives conversions; the dispatcher preview copy lags)
can never be read-while-overwritten (the 1742 tear). Rolling telemetry (frames/s, conv avg/max ms,
drops, ring allocs) is logged to `startup.log` every 2s while converting. Per-frame conversion
failures are logged at most once per 5 s (`ErrorLogThrottle`). DRM-protected content delivers black
frames (OS limitation, documented). Frame pool pauses while the app is minimized — capture keeps
running, the pool just stops delivering.
- **Ownership:** `ScreenCaptureManager` mirrors `CameraManager` — refcounted by target key, one shared
`WriteableBitmap` per key, dispatcher-coalesced latest-frame copies, `PreviewBitmapChanged`/`CaptureFailed`
events, `ReleaseAllAsync` on re-designation. `ScreenCaptureSourceFactory.Resolve(key)` parses the key into
a source; `PickAsync()` shows the OS `GraphicsCapturePicker` ("Change Capture…", owner window set via the
`IInitializeWithWindow` ComImport) and returns a **transient** `picker:` key — a reload falls back to
auto-detection.
- **"Capture Window…" — in-app window pin (TASK 38, 2026-09-21):** the Live backdrop context menus
(PreviewPane canvas + layer-row) offer a submenu listing visible top-level windows of other processes
(`Win32WindowEnumerator`, an `EnumWindows` pass filtered like `Win32FullScreenDetector`:
visible, non-cloaked via `DWMWA_CLOAKED`, non-empty rect, other-process, titled). Picking one runs
`RedesignateBackgroundAsync("window:0x{hwnd:X}")` — the same full-bleed layer-0 capture path as the
game/desktop, keyed straight into `ScreenCaptureSourceFactory` (`window:` already resolved there).
**Session-scoped, never persisted:** `window:` (and `picker:`) keys are `IsTransientCaptureKey` —
`ReacquireScreenCaptures` strips them before auto-detection on every load, because an HWND can be
recycled to an unrelated window after restart (resurrecting a dead pin could capture the wrong
window). **Pin wins while alive:** `OnFullscreenMonitorChanged` skips re-designation while
`IsAliveWindowPin(current.CaptureKey)` is true — the auto game/desktop detector never steals an
explicit pin. **No dead ends:** if the pinned window's capture session dies mid-session,
`OnScreenCaptureFailed` (transient key only) re-runs `ReacquireScreenCaptures` → auto-fallback
(fullscreen game → desktop → static art). Seam: `IWindowEnumerator` + `WindowInfo`
(`Label = "Title — Process"`), `WindowEnumeratorOverride` static test seam mirrors
`CameraEnumeratorOverride`.
- **CsWinRT projection gaps hand-rolled:** `Windows.Graphics.Direct3D11.Direct3D11Helper` is not projected,
so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first
feature array) → QI `IDXGIDevice` → the WinRT interop export
`CreateDirect3D11DeviceFromDXGIDevice` → `MarshalInterface<IDirect3DDevice>.FromAbi` (one shared device per
process). Do **not** switch back to the QI-for-`IDirect3DDxgiInterfaceAccess` trick: the raw D3D11 device
no longer exposes that interface on newer Windows (verified E_NOINTERFACE on build 26200, hardware and
WARP alike) while `CreateDirect3D11DeviceFromDXGIDevice` keeps working.
`IInitializeWithWindow` are ComImports in `CaptureInterop.cs`. All WinRT projections were verified by
reflection against the built `Microsoft.Windows.SDK.NET.dll` before writing the interop.
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
task); multi-monitor live re-targeting beyond the auto-detected game is behind the picker; picker- and
window-pin captures don't survive reload (session-scoped by design — see the "Capture Window…" bullet).
- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses
focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
the only focus hooks (`FullscreenMonitorChanged` event + `RefreshBackdropAutoCapture`) re-target the backdrop, never
pace it. The lag is inherited: `Windows.Graphics.Capture` is content-driven and DWM/OS-throttled while the
app is in the background (worse under a full-screen game on 24H2/26100), GPU readback
(`CreateCopyFromSurfaceAsync`) contends with the foreground game, the source's one-in-flight `_framePending`
gate drops frames during stalls, and the manager's `DispatcherPriority.Render` copies only run as fast as
WPF presents the window. Slice 16 shrank the per-conversion stall (fast downscale + 10ms cadence floor)
but the OS-level delivery throttle under focus loss remains. Recorded 2026-08-13; no mitigation attempted
yet (deferred by user decision).
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
to the encoder task.
- **Preview watermark:** the "Preview" placeholder hides while a background capture renders —
`ShowPreviewPlaceholder` also checks `BackgroundImage` (raised on change), so a scene with
live capture shows the feed instead of the "nothing here" label.
### Webcam capture (TASK 3 milestone 1)
- **Seam-first:** everything above the WinRT layer speaks only `VideoFrame` (normalized tightly-packed
BGRA8) + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces. Tests inject fakes;
screen capture and background removal later feed the same seam.
- **CPU-first:** `MediaCaptureInitializationSettings { MemoryPreference = Cpu, StreamingCaptureMode = Video,
SharingMode = SharedReadOnly }`, frames pulled via
`CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8)` — the pipeline does any format
conversion, so every `FrameArrived` yields a ready BGRA8 `SoftwareBitmap` (bytes read via
`WindowsRuntimeMarshal.TryGetDataUnsafe`, not marshalled copies).
- **Reader-output subtype ladder (2026-09-15 webcam-take fix):** the reader is created per-candidate
by `ReaderSubtypeCandidates(activeSubtype)` — Bgra8 for uncompressed cameras (fast path), NV12 then
source-default for cameras sitting in MJPG. A camera another app left in MJPG mode cannot be read as
Bgra8 by a `MediaFrameReader` (`StartAsync` → `OutputFormatNotSupported`; the MJPG pipeline exposes
NV12, not BGRA) — exactly what a browser grabbing the webcam produces. When the app can't re-negotiate
(our `SetMediaStreamPropertiesAsync` refuses with "file in use / CaptureMode is SharedReadOnly" under
msedge + NVIDIA Broadcast contention), an MJPG-active reader otherwise aborts the whole acquisition.
Each candidate is allocated AND started under its OWN catch — WinRT answers an unsupported subtype
with a THROW (`E_INVALIDARG`), not a status; one rejected format must never abort the ladder. Failures
degrade to the next candidate; rejections are logged + collected into the final error; only the outer
catch nets device-level errors (see `MyMistakes.md` → WINRT RESOURCE-ALLOCATION RECIPE). Anything not
Bgra8 is converted in `OnFrameArrived`, which already handled non-BGRA software bitmaps.
- **Source pick, not first hit:** the frame reader is bound to the first source that is `VideoPreview`
(preferred) or `VideoRecord`, not blindly the first preview source. If a camera exposes neither, the
failure names the device and the stream types it *does* expose. `SharedReadOnly` lets the capture
coexist with other apps that share the camera.
- **Known failure: NVIDIA Broadcast** — it opens the physical webcam exclusively, so `InitializeAsync`
fails with "camera in use" (or, if init slips through, the device exposes no preview source). Fix:
quit Broadcast while streaming, or pick its virtual "NVIDIA Broadcast" device from the picker and the
app captures the processed feed. This is a real-device finding (Logitech `VID_046D&PID_082D`).
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
- **One webcam, many scenes (schema v3) — app-level default (2026-09-17):** a singleton `Webcam`
row holds the identity (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig`
(position/size/clip/mirror/border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at
most once, the webcam (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager`
refcounts capture sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps
it; the last release stops + disposes). **The webcam is an app-level resource — ONE selection (the
app default), usable in every scene that can host it.** "Add Webcam" places the existing default
DIRECTLY (no picker); the Windows camera picker runs only for the initial selection
(`_webcam == null`). Choosing a different camera in the picker swaps it app-wide via
`SwapWebcamIdentityAsync` (same path "Change Webcam…" uses). **Per-scene gate, not app-wide**
(TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`
— a scene holding its own placement greys the row (one webcam per stream), but ANOTHER scene
holding one does not, so Chat's camera never greys Live. **Attainable means a live lock, not just a
saved identity:** `IsWebcamAttainable` = `_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`
— an identity whose device was unplugged, or whose session can't start, leaves the row greyed with
`WebcamAddToolTip` = "No webcam is currently available…" (identity alone no longer suffices). The
max-1 reason gets the gentle "a second camera means you've graduated to OBS" line.
Removing a scene's placement **keeps the identity** (it's the app default; `_webcam` is never
nulled by removal) — Add stays offered for the same camera, because the app holds its own base lock
(below).
- **Startup resource lifecycle (2026-09-17):** the webcam is a resource the app *allocates and
locks*, validated at startup — this answers "why is the WebCam row greyed" from inside the app
instead of a DB spelunk. `MainViewModel.ValidateWebcamResourceStartupAsync` (fired fire-and-forget
right after `LoadLayout`, stored as `WebcamStartupValidationTask` so tests await it) enumerates the
OS once and tri-states on the count: **0** → app runs on, layer inactive, no alarm; **1** → attempt
`CameraManager.AcquireAsync(device)` as an app-wide lock — on failure a **persistent red alert**
(`WebcamLockAlert`, bottom of the Layers panel with a Retry button) re-polls every 5 s
(`_webcamLockPollTimer`) until a lock succeeds, and any first real frame also clears it; **≥2** →
deliberately no auto-lock — selection belongs to the App Settings dialog (gear) **queued — TASK 40
Unit A (App Settings round, plan saved 2026-09-22)**.
The single-camera branch **always acquires** (it no longer skips when configs already hold the
session): that acquire is the app's own BASE ref, so removing every scene's placement leaves
`RefCount = 1` and the session alive — the gate stays satisfied and the Web Cam row stays offered,
i.e. the app default outlives the scenes that render it. Acquire on a running session just bumps
the refcount. `CameraManager.IsRunning(deviceId)` = a session exists (started OR still starting —
a mid-start session is not "locked yet", it's in flight), and a rolled-back (failed) session leaves
the dictionary so the pass can retry. After a successful single-cam lock, the pass **adopts the
camera as the app default** if no identity exists yet (fresh layout) — the Web Cam layer is then
offered in Live/Chat immediately (`CanAddWebcam` true); an already-loaded identity is never
overwritten. Test seams mirror `LayoutPathOverride`:
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
drive the probe without real hardware (0-cam no-alarm, 1-cam locked → identity adopted, lock-fail
→ alert → clears on retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure
below is a *suspect-list* claim — `CameraConflictProbe` reads only running process names, no device
handles; contention symptoms fit shared-mode/bandwidth just as well (see `MyMistakes.md`).
The **YouTube Chat layer follows the same one-per-layout rule** (TASK 27):
`CanAddYouTubeChat` greys out the (+) item while any scene carries a ChatBox source (raised on
staging + elements change; `AddSource` refuses a second as defense in depth). Chat layers created
before the "YouTube Chat" default still carried the legacy name "Chat" (commit `65641d8` era) —
LoadLayout heals exactly that un-renamed default to "YouTube Chat"; creator renames are untouched.
- **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()`
snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and
restores them when switching back — otherwise the Round resize lock (square) would leave a square
behind. The rect dims are **persisted** (`WebcamSceneConfig.RectWidth`/`RectHeight`, nullable), so a
reloaded Round webcam still restores its pre-Round aspect instead of staying square. A one-time
`HealLegacySquareRect` (load only) widens a pre-v4 `Traditional` config that ended up square to 16:9
(keeps height; Round and explicit rect dims are untouched).
- **Device swap / layout reload:** `ChangeWebcamAsync` (picker) and `ReacquireWebcam` (after load)
release the old device with `ReleaseAllAsync` — a forced full drop that zeroes the refcount and
stops the source regardless of how many scenes held it (the per-config count isn't known once the
scenes are replaced) — then `AcquireAsync` the new device once per config.
- **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread
at the device's frame size (first frame), forwarded to every `WebcamSceneConfig.VideoImageSource` via
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher
(at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
device never drowns the render thread.
- **Webcam added mid-session must get the live frames:** `PreviewBitmapChanged` fires **once** (the first
frame creates the shared bitmap); later frames only mutate that bitmap in place, so a config that didn't
exist at first-frame time would never receive it — the empty/transparent container you'd see adding a
webcam to Chat while Live already had the camera. `CameraManager.GetPreviewBitmap(deviceId)` exposes the
current shared bitmap; `AddWebcamToActiveSceneAsync` assigns it to the new config right before
`AcquireAsync`, and `ReacquireWebcam` re-propagates it to every config after a reload.
- **Clip/mirror/border:** per-element `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
(`ScaleX = -1`) + the OSB-standard static border (`BorderColor` `#RRGGBB` or `""`=none, `BorderOpacity`
0–1, `BorderWidth` 0–20, `BorderAnimation` `None|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow|
Electricity|Sparkles`). Rendered in the preview DataTemplate; toggled from the element's right-click
context menu (webcam menu: Change Webcam…, Border Effect submenu — all 9 items enabled, values persist,
rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in
this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty
preview canvas has its own Show Webcam entry.
- The Round webcam is an `Image Stretch="UniformToFill"` with an `EllipseGeometry` clip
(`Center=0.5,0.5` `RadiusX/Y=0.5`), inside a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it
renders as a true circle (diameter = the shorter element dimension) instead of an oval stretched to
the element rect — the traditional `Image` keeps `UniformToFill` over the full rect. The clip is
geometry, **not an `ImageBrush`**: a brush re-rasterizes the frequently-updated `WriteableBitmap` per
frame on the render thread, which is what made the live webcam crawl while round. The Round border is
a centered `Ellipse` at `Width/Height = RoundBorderSize`.
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
- **Webcam size clamp:** `ClampWebcamToBounds(config, sceneName)` (internal — test seam) enforces the
max per dimension at resize + load and no less than 10% of the master (192×108). The cap is picked
**by canonical scene name**: 50% per dimension (960×540) everywhere except the **Chat scene**, which
may reach half the screen **area** (~1358×764 @16:9) so the viewer sees the creator better
(`MaxWebcamWidthFor`/`MaxWebcamHeightFor`; a renamed Chat loses the bigger cap).
`RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp.
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
the element — making the corner handle ungrabbable. The element Grid carries
`Background="Transparent"` (whole rect draggable; the empty canvas Grid uses the same trick for
right-click Show Webcam) and the `SelectionOverlay` (dashed border + corner dot) is
`IsHitTestVisible="False"` so it never intercepts the click. Two things make the webcam menu work:
(1) the `ContextMenu` pins its own `DataContext` to `PlacementTarget.DataContext` — a `ContextMenu`
isn't in the visual tree, so without it the Click-handler `DataContext:` patterns (and the
IsChecked/slider bindings) silently fail; (2) `Themes/Controls.xaml` ships a full dark `MenuItem`
template — `PART_Popup` (submenu popups), a popup `ItemsPresenter` (the Border Opacity/Thickness
sliders live in Items, so they render in a hover flyout), a `✓` checkmark column, and a `›` arrow
driven by `HasItems`. An earlier bare `Border + Header` template dropped all three: submenus never
opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the
Click handlers were fine.
- **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
to the encoder task.
- **Background removal = 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.
- **Every global overlay must be composited on EVERY render path (bug found 2026-09-26).** The
frame pump has three exits: `RenderFull` (no graph / no static base), `CompositeLayers` (static base
+ dynamic tail), and the **fully-static shortcut**, which stretches the cached bake and returned it
directly. That third path silently **discarded every per-frame overlay** — the social bar, the alert
ticker and now the brand flash. A creator whose scene was one static background saw none of them,
and the flash looked "preview-only forever" for a reason unrelated to the tier. `SceneCompositor.Overlay`
is therefore `internal static` (it copies before blitting, so the cached bake is never mutated) and
the shortcut now composites overlays onto a copy before stretching. **When adding an overlay, grep
for all three paths** — a fourth exit that forgets it fails the same silent way.
- **`VideoFrame` buffers are recycled by some producers, and `Epoch` is not optional.** A producer that
hands out the same array across frames MUST bump `Epoch`, or `FramePump`'s buffer-identity paste cache
false-hits and freezes stale content (the take-11 class of bug). The `C4` cache signature mixes
`Epoch`, so a per-frame overlay naturally invalidates it. `BrandFlashPresenter` reuses one 8MB
master buffer and stamps `Epoch` per read for exactly this reason — allocating a fresh 8MB frame per
tick is not an option at 1080p30.
- **Dev-only: two instances side by side (`Helpers/InstanceProfile.cs`).** Pre-1.0 the creator runs one
instance to stream and another to screen-capture it. Nothing prevented a second instance (no mutex, no
port); what broke it was shared state — the whole-scene read/write layout DB (clobber), Chromium's
**exclusive** lock on the WebView2 user data folder (second instance won't start), the auth token store
(a test instance overwrites the real YouTube sign-in), and `startup.log`. Set
`YTLIVE_INSTANCE=<id>` and that process gets a private root at `%APPDATA%\ytLlive\instances\<id>\`
for the DB, the auth file, the log and the WebView2 folder. Recording folder and the ffmpeg `tools\`
cache stay shared on purpose; global hotkeys stay un-namespaced (Windows refusing the second
`RegisterHotKey` is the correct answer). The **entire implementation is inside `#if DEBUG`** — a
Release build compiles to `DataRoot => DefaultRoot` and `WebViewDataFolder => null`, and the call
sites are unconditional so Release cannot drift. Proof + harness: `ytLive.Tests/InstanceIsolationTests.cs`.
⚠️ Grepping the binary for `YTLIVE_INSTANCE` proves nothing — a `const` is inlined and appears in
neither build; check the `InstanceVariable` **field** in metadata instead.
- **Branding flash is composited by the output path too** (it's on the live output, per Monetization),
passed in as a pre-rendered `VideoFrame?` — the compositor core stays pure byte-math, no WPF. Likely a
bundled asset rather than runtime text rendering (deterministic, no font/layout risk).
- **Frame sources are injected** via a `Func<SceneElement, VideoFrame?>` 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<string, CancellationToken,
Task<byte[]>>`) 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).
**⛔ The pinned-download design is now formally a defect, not a cold-start edge case (2026-09-27).**
It **failed in production on 2026-09-01** — the previous daily pin aged out of BtbN's 14-day
retention and the download 404'd on the creator's first real recording attempt. Two facts make
the "pin is a const, just bump it" assumption wrong:
- **Microsoft Store policy 10.2.2 forbids dynamic code inclusion.** Downloading an executable
from the internet and running it is the exact pattern the policy names. An MSIX submission
is rejected over it.
- Bumping the pin is whack-a-mole against a retention window we do not control.
**Fix (TASK 48 item 1, recommended on EVERY route — it is not Store-conditional):** bundle
`ffmpeg.exe` + `ffprobe.exe` + the `libav*.dll` family **inside the package**; `FfmpegLocator`
resolves PATH → package-local → cache and never downloads. This also deletes the startup
network dependency entirely. The LGPL-**shared** build choice is **unaffected** — dynamic
linking was chosen for LGPL §6 compliance ("license text + source offer"), not to enable
downloading. Analysis: `TASKS/research-store-certification.md` §4.
### Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
The encoder is a **thin orchestrator over `ffmpeg.exe`** — no H.264/AAC code in the app. It spawns the
subprocess (path from `IFfmpegLocator`), feeds raw BGRA master frames into stdin, and parses `-stats`
stderr lines into `StreamHealth` (bitrate/FPS/duration, dropped-from-frame-count). `FfmpegEncoder`
(`IFfmpegEncoder` seam) holds: `StartAsync` (locate → probe `-encoders` → spawn → stderr loop →
drain loop), `SubmitFrameAsync` (slice 10: bounded-queue ENQUEUE — never a pipe write; the drain task
owns stdin writes), `StopAsync` (flush queue → stdin EOF → ffmpeg
finalizes + exits by itself; a 10s watchdog kills it), `Dispose` (force-kill + wait), and the
`HealthUpdated`/`ProcessFailed` events. **Pattern:** the encoder never touches `Process` — it drives the
`IEncoderProcess` seam (`FfmpegEncoderProcess` wraps the real `Process`, redirected stdin/stdout/stderr
+ exit control); a `Func<IEncoderProcess>` factory + the locator are constructor-injected, so the
integration test fakes the whole subprocess (probe + encoder) with a Channel-backed `TextReader` whose
`Complete()` is EOF (`null`), never a `ChannelClosedException`.
**Decisions (locked):** args are pure (`FfmpegArgs.Build`, no string building in the encoder):
`-f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` (**NO `-re`** — the FramePump
is the pacer since slice 9, 2026-09-10; `-re` added a second, fighting clock on the rawvideo demux)
+ a **real audio input — the
mixer writes IEEE-float stereo to a Windows named pipe** (`-f f32le -ar 48000 -ac 2
-i \\.\pipe\ytllive_audio`, name via `EncoderOptions.AudioPipeName`; replaced the old `-f lavfi -i
anullsrc` silence in the TASK 8 audio milestone) + explicit `-map 0:v -map 1:a` + `-c:v <enc> -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 <rtmpUrl>`.
**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 <rtmpUrl>`; record block =
`-f mp4 <RecordPath>`. `EncoderOptions.StreamEnabled`/`RecordEnabled`/`RecordPath` gate each block, so
the engine runs record-only (no RTMP) or stream-only — **stream+record simultaneously is OUT by creator
ruling (2026-09-01): the VOD is already the copy, and dual-encoding drags mid-range chassis and degrades
BOTH outputs ("we're not them"). The old "cheap on NVENC" claim was an unverified assumption; the
UI constraint is landed (2026-09-20): the REC/ON-AIR pills are radio-exclusive — arming one clears the
other — while the dual-block machinery stays generic.**
`FfmpegEncoder.StartAsync` throws unless at least one output is enabled.
**Encoder choice is probed from the binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure):
NVENC → QSV → AMF → OpenH264 fallback, **never libx264** (GPL; see Licensing). `EncoderOptions.VideoEncoder`
forces one and skips the probe. `EncoderOptions` also carries W×H/FPS/bitrate from the quality tier and
`GopSize` = FPS×4. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`),
driven by the `FramePump` below.
### Local recording (TASK 18 — shipped 2026-08-29, VM + top-bar UX; plan in TASKS.md)
Recording is **independent from streaming** and needs no YouTube sign-in. A single segmented
**REC|ON-AIR switch** in the top bar picks the mode (redesigned 2026-09-22 from the old two pills):
**REC** (local file, works signed-out) / **ON-AIR** (streaming; **armable signed-out** — intent may
light while offline, 2026-09-20: a greyed pill was the dead end the guard shipped to forbid). The switch
selection is intent; `IsRecording`/`IsLive` are reality — the reality line (dot + word + elapsed) only
lights when a session is actually running.
- **State model (`MainViewModel`):** pills `RecordPillOn`/`OnAirPillOn` stay the **routing model**
(radio-exclusive — arming one clears the other, record-OR-live 2026-09-01; the ON-AIR setter stays
armable signed-out (2026-09-20: intent must be able to light while offline)). **Top bar redesign
(2026-09-22):** the bar is ONE surface rendered by the FIRST decision — Record or Stream — and each
world is the whole bar (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`, wired by
`RaiseTopBarModes()`). The old sign-lights + two pill toggles + status dot + PRIVATE/TEST chips are
gone; the mode is one **segmented REC|ON-AIR switch** (`SegmentToggle`/`SegmentLabel` styles), and
"going back" is one tap on the other segment. Idle worlds: Record = switch + "Start Recording"
(`PrimaryStartButtonLabel` names the armed world); Stream = switch
+ "Go Live" + Test (Test is a **child of Stream** — visible only stream-armed AND signed-in) plus the
account zone on the right. **The account zone is WORLD-INDEPENDENT (2026-09-23):**
`ShowSignInButton` = signed-out and `ShowAvatarButton` = the signed-in avatar, in Record and Stream
worlds alike — it's app-level identity, and keeping it put pins the bar height (it no longer pops in
and out between worlds). Right-click the avatar = Change Account/Logout. Running retires the switch
and worlds; one reality line
`● word elapsed` + End replace them (`RunningVisible`/`RunningText`/`RunningDotBrush` — TEST/LIVE/REC,
gold/red/green). The account **status dot is gone** — the avatar/Sign In pair carries connectedness.
`ShowPrimaryStartButton` remains the always-idle face. `StopStream` clears both pills (see "Stop ends
everything; failures roll back" below).
- **Filenames** (pure `Services/RecordingFile`): auto-name `ty-<yyyyMMdd>-<HHmm start>-0000.mp4` at start;
**rename-on-stop** to `ty-…-<hh2mm2 actual length>.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`.
- **Save dialog: Cancel DISCARDS (creator ruling 2026-09-26).** The `RenameRecordingDialog` result is a
two-outcome decision, and it lives in the extracted `MainViewModel.CompleteRecordingSave(startPath, dir,
autoStem, chosenStem, creatorSaved)` (internal, so `ytLive.Tests` can drive it against real files without
a frame pump — see `ytLive.Tests/RecordingSaveDialogTests.cs`):
- `creatorSaved == false` (**Cancel / Escape / the X**) → **DELETE the temp file.** Nothing is moved,
nothing is left in the videos folder. A failed delete returns `DiscardFailed` and the toast NAMES the
file + folder, because a "discarded" recording still on disk is worse than no report.
- `creatorSaved == true` (Save, or **Enter on the pre-filled default**) → `File.Move` to the final name.
A blank/whitespace box still means "keep the auto name" — but only on an explicit Save.
- **The bug this fixed:** the caller used to `File.Move` UNCONDITIONALLY and only overwrite `stem` when
the dialog returned true, so `ShowDialog() == false` fell straight through into the save — Cancel
silently kept the recording under the default name. The modal itself was always correct; the
side effect was in the wrong place. **Any modal whose result gates a side effect must have every
branch handled explicitly — a falsy result must never fall through into the effect.**
- **Folder:** default `%APPDATA%\ytLlive\recordings\`, user-overridable via `ChooseRecordFolderCommand`
(`OpenFolderDialog`), persisted through `LayoutStore.Load/SaveRecordFolder` (`RecordFolder` key).
- **Explicit sign-out only:** `StopStream` no longer clears the session/token. Sign out via Logout /
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 (the dual-block machinery is unreachable from the UI since the pills are radio-exclusive).
Rest of the engine (FramePump/encoder) is unchanged — `BuildEncoderOptions` always returns an options
(flags from the pills + `_activeRecordPath`), so record-only reaches the pump.
### Live audio capture (TASK 4 ship step 4 — shipped 2026-08-12; game audio bar 2026-08-13; **TASK 8 audio milestone: real stream audio + filters + duck + TRAX, shipped 2026-08-14**, plan in TASKS.md)
**Capture runs for the app's lifetime and is KISS by rule**: desktop/game audio is automatic (WASAPI
loopback), the mic is the creator's only audio control (meter/mute/volume already shipped). The whole
layer sits behind an **`IAudioSource` seam** (`Services/Audio/`: `Start`/`Stop`/`Started`/`SampleReady`/
`Failed`, IDisposable) so the app never touches NAudio directly and tests inject hermetic fakes (no
devices, no timers).
- **Sources (NAudio 2.2.1 — `NAudio.Wasapi` + `NAudio.WinMM` (for `WaveOutEvent`), MIT — item 9 in
`THIRD-PARTY-NOTICES.txt`):** `WasapiLoopbackAudioSource` = `WasapiLoopbackCapture` on the default
render device; `WasapiMicAudioSource` = `WasapiCapture` with the device resolved by `FriendlyName`
matching `MicSourceName` (the app only persists the **DisplayName**), falling back to the default
capture endpoint. Both run at the **device's own mix format** — NAudio 2.2.1 exposes no overridable
`GetDefaultMixFormat` on `WasapiCapture`, so there is no forced 48 kHz path; the mixer's
`TinyResampler` normalizes any rate/channel layout to 48 kHz stereo (this replaced the earlier
"force IEEE-float 48k" plan — the resampler IS the design). Mic device resolution re-reads the name
provider `Func<string?>` 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<double>` 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<int> syncOffsetMs` seam (the VM's global `AudioSyncOffsetMs`, **−500..+500
ms**, persisted as `Audio.SyncOffsetMs`). This is OBS's documented fix for lip-sync: **positive**
delays audio when it runs ahead of video; **negative** advances audio when it runs behind by eating
the first `|N|` ms of the live stream head (see `AudioMixer.StartLive`; the delay line stays
clamped 0..500 internally — negative bypasses it entirely). The advance is armed once at `StartLive`
(eating the head mid-stream is impossible); positive is live-reactive (re-read per tick). A slider
on the mic bar (`"AUDIO SYNC"`, locked while live/recording via `IsEditMode`) surfaces it
(a one-time status dot next to it was removed 2026-09-23 per creator feedback — the slider +
numeric tooltip carry the state). Changing the delay flushes the line (a live change clicks
rather than smears).
- **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader`
(mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on
the default device, **looping on any clean natural end** (`PlaybackStopped` with `e.Exception == null`
→ rewind + replay; the earlier `Position >= Length` check was unreliable for `MediaFoundationReader`
and let tracks play once then stop). The desktop/game bar's mute toggles `LocalGain` (0 or 1;
`UpdateTraxLocalGain` runs on every `GameMuted` change and on TRAX load), so muting kills TRAX
in the headphones — matching the stream, where it rides the loopback channel. The volume slider
drives system volume (`PushSystemVolume`) — which scales the HEADPHONES; the loopback capture is
pre-endpoint-volume (disproven-then-fixed 2026-09-01), so the STREAM follows the knob via
`AudioGainProvider.LoopbackGain = GameMuted ? 0 : GameAudioVolume`, and the game meter multiplies
its display by the same. One knob, three honest paths. No third mixer input. The footer
**TRAX** button lives **inside the game audio bar** (in the preview overlay), **left of the
"Desktop Audio" label** (the creator's pick — it rides the desktop channel, so the music control
sits with the desktop audio controls): status dot (**red** no track / **yellow** loaded stopped /
**green** playing) + "TRAX"; **left-click** toggles play/pause (opens the in-app picker via `OpenFileDialog` when no track is loaded); **right-click** always opens
the picker (`TraxButton_PreviewMouseRightButtonUp`, `e.Handled = true`, code-behind pattern); tooltip
shows the loaded/playing track name or "No track — right-click to choose background music". The picked
track persists via **schema v9** single-row `Music` (`TrackPath`/`IsEnabled`) in `LayoutStore`. Known
wrinkle (out of scope, future feature): YouTube mutes VODs carrying copyrighted music — "music on
live, off VOD" is tabled.
- **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. **rawvideo is stamped by ARRIVAL:** ffmpeg assigns
pts from frame order at the declared fps — supply rate = output speed. A starved producer (pump < fps)
ships a time-lapse, truncated file with NO error (take-2 lesson, 2026-09-01); the pump therefore logs
`FramePump stats: n/target frames per 5s, avg render Xms (resolve R), avg submit Yms` so a slow stage names itself — and since 2026-09-04 so does the BUILD: every build gets a GUID stamped
into `Helpers/BuildStamp` (csproj `GenerateBuildStamp` target, fresh per compile — no lying incremental build), logged at startup
("Build xxxxxxxx (compiled ...)"). **Take 6 (stamp-less, ambiguous): render 35-41ms with slice 3 built or not unknown — attribution is why the stamp exists; takes 7+ are
readable.** If the breakdown says resolve dominates, the suspects are pre-cached chat/config thrash or the web frame; if blit dominates, the general-path element sizes. **Pattern — everything is a constructor-injected
seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` 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<string?>` 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`.
- **Deadline pacing + row-blit render (take-3 starvation fix, 2026-09-03):** take 3 recorded 30s into a
2.1s file and the 5s stats line named the cause in one number — `17/300 frames per 5s, avg render
258.1ms, avg submit 1.5ms`. Two defects, both fixed: (1) the pump slept the FULL interval after each
render, so period = render + interval — OBS's `video_thread` (libobs/media-io/video-io.c) pattern
replaces it: absolute `nextTick += intervalTicks` deadline, sleep only the remainder, and on overrun
skip the wait. (NOTE, slice 9: the original "skip the missed ticks (rebase)" half of this fix was
wrong — see the slice 9 correction below.) (2) the compositor did
per-pixel float sampling + `Math.Round` blending over all 2.07M master pixels (backdrop) and scanned
the whole destination per overlay (a 64px social-bar strip cost 2M iterations). `SceneCompositor` now
follows the libyuv pattern (BSD-3, chromium.googlesource.com/libyuv/libyuv — cited per the
derivative-work rule): 1:1 aligned blits take a row-walk fast path (bilinear at scale 1 + integer
offset is the identity) with per-pixel alpha branch and integer fixed-point blend; `BlitOverlay`
clips to the intersection rect; a full-cover live backdrop skips the opaque-black pre-fill. The
general per-pixel path handled scaled/round/mirrored elements. Test:
`Pump_Paces_To_The_Deadline_Compensating_Render_Cost` records the requested wait
via the pacing seam (a seam fake must genuinely await — a synchronous completed task runs the whole
pump loop on `StartAsync`'s continuation and hangs the run; MyMistakes).
- **Slice 2 of the render fix (2026-09-04, take 4):** the pacing held (file no longer truncated at
loop level) but render stayed at 58.9ms — the per-pixel row walk was still 2M managed iterations and
every tick allocated a fresh 8.3MB master buffer. Three changes: (1) **`VideoFrame.IsOpaque`** — a
producer-contract flag (only the screen-capture and webcam paths set it; DWM/MediaCapture fill alpha
255 by contract); a full-canvas, aligned, opacity-1 blit of an opaque frame is now ONE
`Buffer.BlockCopy` (~1.5ms) instead of the loop, and the black pre-fill is skipped when the backdrop
covers. (2) **integer fixed-point bilinear** in the general `BlitContent` path (row-hoisted invariants,
no divisions, no `Math.Round`) — within ±1 of the float reference, inside the ±2 test tolerance.
(3) **scratch pool in the pump** — `AcquireScratch`/`ReleaseScratch` recycle the master buffer
(max 4, keyed by length, owned-by-reference so bake-cache/social-bar/static-art arrays can never be
captured); release happens strictly AFTER `SubmitFrameAsync` returns (the write to stdin copies),
and the free-list Contains guard makes the transition Cut path safe (`BlendFrame` returns the
to-frame itself, aliasing the scratch). The per-tick `fromScene` render in the transition branch was
**dead weight** (BlendFrame uses `TransitionService.FromFrame` captured at `Start`, never the pump's) —
removed, and the pump's `fromSceneProvider` seam + `MainViewModel` call site went with it. Tests:
`Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels` (the ONE: alternating backdrop colors
pin every frame's content, repeated backing-array identity proves the pool recycles),
`Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch` (memcpy branch + scratch
sentinel). `FakeEncoder.SubmitFrameAsync` now snapshots bytes like the real stdin write — holding the
reference would race legitimate recycling. Take 5 must show `avg render ≤ ~10ms, ≈300/300`; the
vertical tier's final 1080×1920 `BilinearScale` still allocates fresh per frame (same GC lesson when
someone streams vertical — recorded as a follow-up, not silently "done").
- **Slice 3 — chat raster-on-change (2026-09-04, take 5):** take 5 measured `138/300, avg render
25.5ms` — the per-tick blits were cheap now, but `ResolveOutputFrame` → `RenderChatBox` ran a FULL
WPF raster (`ChatBoxRenderer`: FormattedText + `RenderTargetBitmap` + CopyPixels + channel swap)
EVERY tick whenever the message buffer was non-empty — and the buffer survives between sessions,
so even a signed-out record-only take paid it. Established answer, same as OBS text sources:
**raster on change, blit the cache every tick.** `ChatOverlayLayer` now keeps a content version
(`Messages.CollectionChanged` → `_contentVersion++`) plus a config key (box size + all Chat*
props); `RenderFrame` returns the cached `VideoFrame` by identity until either changes (the
compositor only ever reads a cached frame). Test: `ChatOverlayLayerCacheTests` (RealApp, real
renderer — `Same()` for unchanged inputs, `NotSame()` on message/config change, null on empty).
Accepted cost pending take 6: one slow tick (~15-25ms) per arriving message; if chat-burst frame
loss shows up, the next slice moves the re-render off-tick (debounced, dispatcher-side).
- **Slice 5 — paste cache for non-opaque layers (2026-09-04, take 7/8 data):** the render/resolve
split (added in the stamp commit) finally named the last thing honestly: `resolve ≈ 0` but
`render 26-27ms` (124-135/300, ≈2.2x) — the chat fix HAD worked; the remaining cost was the
compositor re-rasterizing EVERY layer per tick even when its pixels never changed (this creator's
Live scene: chat 159k + web widget 271k + image 95k + webcam 156k px ≈ 680k samples @ ~38ns each).
OBS's actual shape: sources cache their surface, the compositor pastes. `SceneCompositor` now
routes non-opaque layers through `BlitCachedLayer`: a layer rasterizes ONCE into an element-space,
TRANSPARENT-based frame keyed by (source-array identity, source W×H, ceil'd dst rect, round,
mirror), then every later tick PASTES it (integer position, row alpha-blend, opacity applied at
paste). **Correction (2026-09-12, the web-widget "black box"):** the raster builds on a
TRANSPARENT base, but the sampler's PARTIAL-alpha branch used the opaque-dst blend — it
premultiplied the color into RGB and forced `alpha=255`. A translucent widget pixel then pasted
as opaque darkened ink (the box in recordings) while the raw-bitmap preview stayed correct.
`BlitContentRaw` now takes `transparentDst` (the raster call passes `true` and writes straight
color + straight alpha; the paste rows do the source-over). Master paths are bit-identical.
Guard: `PasteCache_SemiTransparentLayer_RevealsBackdrop_NotOpaqueInk`. Producers hand out fresh immutable arrays, so array-identity keys can never serve stale
content; dict bounded at 48, cleared wholesale on overflow. Only changing content (webcam device
frames, web capture ticks, chat messages) resamples; the opaque backdrop keeps its memcpy path.
Position/opacity drags are now near-free (no resample — paste params, not cache keys).
ONE integration test: `PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates`
(raster-vs-paste byte equality incl. round-clip margins + new-array propagation); the whole
existing pixel suite guards sampler semantics. Take 9 must show `≈300/300, avg render ≤ ~8ms`;
if it lands there, the recording saga closes. Follow-ups unchanged: vertical-tier scale alloc,
debounced chat re-render under message bursts.
- **Slice 6 — the sleep quantum WAS the ceiling (take 9, 2026-09-04):** period measured ~37ms while
work (render+submit) was ~25 — the missing ~12ms is `Task.Delay` rounding EVERY request up to the
Windows clock tick (documented ~15.6ms default; learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay).
A frame finishing 3ms early requests a 3ms wait and sleeps a full 15.6 — capping the producer at
~27fps NO MATTER how fast the compositor ran. This is why takes 7→9 showed zero playback change
despite real render wins: the sleep floor dominated everything above it. Established fix (game-loop
canon — stackoverflow.com/questions/5441464; timeBeginPeriod — learn.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod):
`timeBeginPeriod(1)` for the pump's lifetime (paired `timeEndPeriod` in the finally), sleep only the
BULK of the remainder (request minus 2ms), `Thread.SpinWait` the last ~2ms across the deadline;
blown deadlines still rebase. (SUPERSEDED in part: slice 9 deleted the rebase — deadlines only
advance; slice 10 replaced the burst re-write with one fresh composite per iteration.) Stats gained
`avg wait Xms` so render+submit+wait must ≈ period —
the accounting is closed, no stage can hide again. Same slice: the webcam's `IsOpaque` paste-cache
bypass was removed (it re-sampled ~156k px every tick even between identical device frames; the
cached paste beats the sampler on hits and costs the same on misses). Take 10 verdict: `≈300/300`
honest fps — if short, the wait/render split names the remaining term with no ambiguity left.
- **Slice 7 — the loop was ON the UI thread the whole time (take-8 numbers, 2026-09-04):** with
the wait finally measured, take 8 (59a02a5b) showed the impossible pair — `render 22ms, wait 10ms`
against a 16.7ms deadline: a blown deadline rebases with NO wait. The wait was the producer
**queued behind the live preview on the dispatcher**: `PumpAsync` starts from a UI command handler
and its `await` continuations inherit the UI `SynchronizationContext`, so the "WPF-free" compositor
actually rendered on the UI thread and ran only when WPF let it. OBS runs its media loops on
dedicated threads for exactly this reason. Fix: `_pumpTask = Task.Run(() => PumpAsync(...))`
(no sync context inside → continuations stay on the pool). Side effects handled, not ignored:
`StaticPixelCache.Get` now locks (pool miss-decodes race UI callers); `ChatOverlayLayer.RenderFrame`
checks its cache off-thread but MARSHALS the rare raster miss to the dispatcher (DrawingVisual/
RenderTargetBitmap are UI-thread objects) and re-validates there; pump events already marshalled.
Also: `GCLatencyMode.SustainedLowLatency` for the pump's life, stats gained `worst render Xms`
(spike visibility — bimodal averages hid them), webcam now routes through the paste cache (bypass
re-sampled 156k px even between identical device frames). Tests: `Pump_Produces_OffTheStartingContext`
(the ONE — inline-pumping SyncContext proves continuations never return to the starting thread) +
70/70. Take 9 verdict: wait ≈ true remainder (period → 16.7, n → ~300); `worst render` names any
remaining raster-miss spikes; render+submit+wait still == period — the accounting holds.
- **Slice 8 — capture buffer ring + paste-cache Epoch (take 11, 2026-09-04):** take 11 validated
the architecture — typical frames now land `work ~10ms + wait ~6.8ms = 16.7`, exactly the deadline
(212/300). The whole remaining gap is PERIODIC 35-65ms worst-render spikes that worsened across
the take (189→147 frames/5s) — the signature of gen2 GC pauses, now provable via `gen2 +N` in
every stats window. Biggest churn was structural: `ScreenCaptureFrameSource` minted a fresh
~8.3MB `byte[]` per DWM frame (~500MB/s LOH). It now rotates a shared-frame ring (4-deep at slice 8,
deepened to 8-deep after the "flash" finding — consumer holds must never outlive depth × source
period), size-matched per slot, with reused downscale row scratch. Recycled
arrays would poison the paste cache (it keys on array IDENTITY), so `VideoFrame.Epoch` —
monotonic per producer frame, 0 for fresh-array producers — joins the key. Regression test
`PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits` fails on the old key by
construction. The camera producer carried the same fresh-array churn (110-220MB/s at 30-60fps):
it now rotates its own 8-deep ring + Epoch (same identity rule), and the WebView2 capture
reuses a canvas scratch + 8-deep output ring + a reused WriteableBitmap instead of minting two
fresh arrays + a new bitmap per 10Hz tick. Next suspect if gen2 stays hot: the WPF preview load
itself driving gen2 — recorded as follow-up, untouched this slice.
- **Slice 9 — the "rebase" WAS the recording time-lapse (2026-09-10, the recording playback-timing
take):** slice 6's "blown deadlines still rebase" was the recording-timing bug all along. In a real
take the pump emitted `215-219/300 per 5s` (~43fps) while `render+wait == period` still looked closed —
the rebase ERASED every missed slot (deadline = wall-now again), so the missed ticks never showed in
the stats. rawvideo has no per-frame timestamps: ffmpeg muxes by frame count at `-framerate 60`, so a
43fps reality was authored into a 60fps container and **every recording played ~1.4x fast** (takes
confirmed with a WSL ticker visible in the recording: file duration 11.44s vs ~15.5s wall). Two
defects, both fixed the OBS way (`libobs/media-io/video-io.c` — the video thread NEVER resets its
deadline; every interval tick outputs ONE frame, and a late render means repeated content — judder —
never a skipped timestamp):
(1) **count-based CFR emission:** `while (GetTimestamp() >= nextTick) { SubmitFrame(frame); nextTick += intervalTicks; statFrames++; }`
submits exactly one frame per crossed slot, re-writing the current composite when the render overruns
(duplicated footage = correct duration, not a time-lapse), and `nextTick` is NEVER reset to wall-now;
(2) **`-re` removed from `FfmpegArgs`** — it was a second, fighting pacer on the rawvideo demux
(its "Resumed reading … after a lag" grew 0.79s→4.82s across the take, leaving the pump behind its own
honest-stats count). One fix covers the game background AND the webcam — both flow through this one
pump. Muxed duration is now frame-count ÷ fps == wall time by construction. Test: the pump suite stays
green unchanged — the pacing test's `<interval` wait assertion still holds because the frame emits at
the next slot boundary, not immediately. (Unrelated pre-existing failure found the same day:
`Composite_FullScene_MasterPixels` pixel (1380,700) cyan-vs-magenta — reproduces with this fix stashed,
untouched by it, recorded as a follow-up.)
- **Slice 10 — bounded encoder queue + drop policy + burned-in frame counter (2026-09-10, the
playback-pacing take):** slice 9 made the DURATION right but the CREATOR still read the recorded
ticker as "1...23...4...56..." — variable pacing. The aggregates (301/300, uniform PTS, 15.6s wall vs
15.74s file) could not see it, and the cause was finally measured in `FfmpegEncoder.SubmitFrameAsync`:
it BLOCKED on `WriteAsync(8.3MB) + FlushAsync` whenever ffmpeg lagged the pipe, and slice 9's
burst `while` loop then re-wrote that SAME composite for every slot that ticked past — frozen
content runs (the "smeared ticker"). Fixed the OBS way (the encoder queue in `libobs/obs-encoder.c`:
the encoder's thread NEVER couples back into the video thread; overload = dropped content, never a
frozen video thread):
(1) **`FfmpegEncoder.SubmitFrameAsync` is now an ENQUEUE** into a bounded `Channel<byte[]>` (cap 120 ≈
2s at 60fps) owned by a dedicated drain task with the stdin write + flush; the caller NEVER blocks on
the pipe. Pixels are copied into an `ArrayPool` buffer before enqueue (the write now happens later on
the drain thread, so the pump's scratch reusable the moment submit returns — same contract as before).
(2) **drop-on-overflow (creator's choice: freshness over coverage):** when the queue is full the NEWEST
frame is dropped and counted (`IFfmpegEncoder.DroppedFrames`, `Interlocked`); the session never freezes
or smears — it drops. Stop flushes the whole queue then EOF (`Channel.TryComplete` → drain writes
leftovers → closes stdin → ffmpeg finalizes+exits), so no accepted frame is ever lost at stop.
(3) **pump loops ONE submit per iteration** (replacing slice 9's burst): every due slot gets a FRESH
composite — no catch-up slot ever repeats frozen content; missed slots vanish as a count-based gap.
The deadline counter is still never reset to wall-now (slice 9 ruling).
(4) **burned-in frame counter (the new judge):** `_outputIndex` is burned into a 6-digit dot-matrix
strip (white box + black 5×7 glyphs, bottom-right corner) of EVERY composite before submit. The WSL
ticker is demoted because its own timers smear under Windows host load; decoding the recording and
reading the strip is clock-independent: the number advances +1 per frame and jumps by exactly the
counted drops (queue overflow OR skipped catch-up slots). Stats gained `worst submit Xms`, `dropped N`,
`stalls K`; a stall logger names any iteration > 2× interval with its render/submit split — with the
queue, submit is ~1ms, so a stall means render/resolve. The strip sits above the social bar (bar is
composited, then burned over) — a debug judge, tiny at 1080p. Test:
`Backpressure_QueueOverflow_DropsFrames_AndNeverBlocks` (the ONE: a 40ms-per-frame sink fake makes
every enqueue fill the queue; submit must return instantly, drops are counted, stop flushes exactly
submitted − dropped bytes). Full suite 289/290 (the pre-existing compositor pixel failure unchanged).
Audio untouched (follow-up). WSL ticker-under-load reliability check still to run (informational — the
burned counter is the judge).
- **Slice 11 — web-layer animation ran at ~1/6 speed; capture cadence 10Hz→~30Hz + de-throttle
(2026-09-10, the "widget animates too slow" take):** the recording is 60fps but the WebView2
capture loop was a blind 100ms `DispatcherTimer` = 10Hz — a 60fps-designed widget was sampled 6×
under its native rate (repeated-footage slow-mo). Two stacked throttles:
> Superseded by **Slice 14** (below): `CaptureScheduler` and the cadence hooks were the polling
> design; frame-driven composition capture replaced them — nothing here is current architecture.
(1) **the 10Hz cap** (unconditional, code-proven) and
(2) **Chromium hidden-page throttling** (`requestAnimationFrame` parked, JS timers clamped to ~1s
per WebView2Feedback#1172/#3070 + Chrome-88 timer throttling) whenever the app window is unfocused
or covered — the page is off-screen at (-5000,-5000), so it is a hidden page the moment the host
window loses occlusion.
Fixed:
(1) **`CaptureScheduler` (new, `Services/CaptureScheduler.cs`)** replaces the per-session
`DispatcherTimer`: a dispatcher timer that DROPS ticks while a capture is in flight (latest-wins,
never queues) — the in-flight drop is what made raising the cadence safe (concurrent full-HD PNG
`CapturePreviewAsync` calls would stack ~10-30ms encodes and publish stale-after-fresh). Interval is
owner-configurable: recording 33ms (~30Hz), idle 200ms. Unit-tested without a WebView2 runtime
(the ONE test: `CaptureScheduler_Drops_Ticks_While_Capture_InFlight_And_Resumes` — overlapping
ticks dropped, capture resumes when idle, driven by a TCS so it is deterministic).
(2) **de-throttle browser args**: the shared `CoreWebView2Environment` is created with
`--disable-backgrounding-occluded-windows --disable-renderer-backgrounding
--disable-features=CalculateNativeWinOcclusion` (the Electron/Streamlabs-class embedder answer for
occluded-window animation throttling) BEFORE `EnsureCoreWebView2Async`.
(3) **cadence hook**: `MainViewModel` sets `SetCaptureInterval(33)` on record/stream start and
`(200)` on stop (`MainViewModel.Streaming.Operations.cs`).
(4) **capture-cost telemetry**: first 30 captures per session log elapsed ms to startup.log —
PNG-encode+decode cost decides whether ~30Hz stays or drops to ~20Hz; the FramePump drops frames
(never time-lapses, slice 10) if UI-thread GC churn starves it, so the cost is observable.
Full suite 290/291 passing, the sole failure the pre-existing compositor pixel test. The web-overlay
transparency verification (the first-capture diagnostic bound to about:blank) and the audio-silence
item remain open push-gate items — both untouched by this slice.
- **Slice 12 (audio diagnostics, 2026-09-10):** the recorded audio is full-length silent AAC
(−91dB, 1124 frames / 23.95s in the latest take) — the named-pipe delivered ~9.1MB of zeros to
ffmpeg for the entire recording, so the loop ran and connected, but both WASAPI capture sources
delivered nothing. Sources started fine (`Audio: using system default mic...` logged), no failure
callbacks fired, and the mixer's existing integration test (`Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe`)
proves the loop→pipe math is sound. The fault is capture-side: either the default render endpoint
carried nothing (audio played on a non-default device — common), or both endpoints were held
exclusive, or genuinely nothing played. Added permanent per-5s live-loop telemetry to startup.log
(`Audio live: pipe connected=, dropped writes=, micLevel=, loopLevel=, drained, peakMix`) and
`NamedPipeAudioWriter.DroppedWrites` — names the exact stage on the next take without new
code. `AudioMixer.FillAndMix` now returns `(MicRms, MicDrained, LoopDrained)` for the telemetry
accumulation. Full suite 290/291, same pre-existing sole failure.
- **Slice 13 (web transparency diagnostic re-point, 2026-09-10):** the "?" in MyMistakes'
"RESOLVED (? VERIFY)" is being closed. The one-shot widget dump + alpha stats bound to the
FIRST capture ever = the initial about:blank document — a blind instrument. NavigationCompleted
for the REAL widget URL now arms `WidgetDumpRemaining = 5`; those 5 post-paint captures dump
`%TEMP%\ytLive-web-<id>-w1..5.png` + the shared `AlphaStats(...)` line (alpha[min/max/mean/zero%])
+ FindContentBounds result to startup.log. That line alone decides the fix branch: zero% ⇒ the
pre-parse injection did not hold for this widget (opaque capture → chroma-key / stronger DOM
injection); large zero% + tight contentBounds ⇒ the capture IS transparent and the recording's
black block lives downstream (compositor blend/underlay). `AlphaStats` extracted as the shared
sampler. No new tests (WebView2 runtime not instantiable in tests; the re-point is log-only).
- **Slice 14 — web frames are now COMPOSITION-CAPTURED; the PNG poll + `CaptureScheduler` are gone
(2026-09-14, the "still not 60fps" take):** slice 11's ~30Hz was still a `CapturePreviewAsync` PNG
poll — every 1920×1080 full-HD encode+decode cost 35–165ms, so the session budget capped real
cadence at ~20Hz for a 60fps-designed widget. Root fix = frame-driven capture, not faster polling:
the WebView2 renderer now feeds `Windows.Graphics.Capture` directly through a
`CoreWebView2CompositionController` (the mechanism `WebView2CompositionControl` and Flutter's
`webview_windows` use — see `graphics_context.cc` `CreateGraphicsCaptureItemFromVisual`, which
captures the root `surface_` visual).
- **Pipeline (per session):** `CreateCoreWebView2CompositionControllerAsync(WindowHandle)` — the
parent HWND must exist, so `MainWindow.InitWebView2()` moved from the ctor to **Loaded** (was
`InitWebView2(WebViewHostPanel)`; the hidden XAML `WebViewHostPanel` overlay is deleted).
`controller.RootVisualTarget` = a child `ContainerVisual` (`RelativeSizeAdjustment = 1,1`) under a
root `ContainerVisual` (1920×1080, `IsVisible=true`) built on ONE `Windows.UI.Composition.Compositor`
(created once on the UI thread); `Bounds = 0,0,1920,1080`, `BoundsMode=UseRawPixels`,
`ShouldDetectMonitorScaleChanges=false`, `RasterizationScale=1.0`, `IsVisible=true`,
`DefaultBackgroundColor=Transparent`; then `GraphicsCaptureItem.CreateFromVisual(root)`.
Frames pull from a free-threaded `Direct3D11CaptureFramePool` (2 buffers) →
`SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, `BitmapAlphaMode.Straight` — the compositor
blends STRAIGHT alpha, so premultiplied readback would wreck corner anti-aliasing) → per-frame
`FindContentBounds` alpha-bbox on the capture worker → `VideoFrame.CropBounds` stamped → ring up to
2 fresh + Epoch (reuse; GC lesson slice 8) → dispatcher-coalesced copy (Render priority) into ONE
crop-sized shared `WriteableBitmap`; `PreviewBitmapChanged` raised only on bitmap (re)creation.
Also unpumped: the OBS-side de-throttle flags (slice 11) stay — they fix the hidden-page JS
throttling; the capture path itself no longer polls.
- **Implemented in:** new `Services/WebCaptureFrameSource.cs` (owns item+pool+session — mirrors
`ScreenCaptureFrameSource`, which servers as the copy template) + reworked `Services/WebView2Manager.cs`
(owns controllers, the visual tree, nav/script wiring, preview; internal seam ctor
`(Dispatcher, Func<string, IScreenCaptureSource>?)` so the tests instantiate zero WinRT; public
ctor `(Dispatcher)`).
- **CoreMessaging DQ recipe (record-once):** the 19041 CsWinRT projection has NO
`DispatcherQueueController.CreateOnCurrentThread()` (CS0117 — only `CreateOnDedicatedThread` +
`FromAbi(IntPtr)`). P/Invoke `coreMessaging.dll!CreateDispatcherQueueController` with struct
`DispatcherQueueOptions { DwSize, ThreadType = 2 (DQTYPE_THREAD_CURRENT), ApartmentType = 2 (DQTAT_COM_STA) }`,
wrap via `DispatcherQueueController.FromAbi(ptr)` (mirror of `CaptureInterop`), only then
`new Compositor()` — all once on the WPF UI thread (the app dispatches Render there).
- **Compile notes (each cost a build cycle):** `Compositor` collides with the repo's OWN
`ytLive.Services.Compositor` namespace → fully-qualify `Windows.UI.Composition.Compositor`;
`CoreWebView2CompositionController` exposes **`Close()`**, not `Dispose()`; `Color` is ambiguous
(`System.Drawing` vs `System.Windows.Media`) → `System.Drawing.Color.Transparent`.
- **Dropped:** `Services/CaptureScheduler.cs` (DELETED — no cadence to schedule), all three
`SetCaptureInterval` hooks in `MainViewModel.Streaming.Operations.cs`, the XAML `WebViewHostPanel`.
`TransparentBackgroundScript` const is unchanged (a test pins it).
- **Test model (Good Dog):** reworked `ytLive.Tests/WebView2ManagerTests.cs` — dropped the 4
control-size tests + the `CaptureScheduler_Drops…` test; `FindContentBounds` tests moved to
`WebCaptureFrameSource.FindContentBounds`; the ONE integration test
`Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds` drives the internal seam with
`FakeWebSource : IScreenCaptureSource` + a real background-STA `DispatcherPump` (copied from
`ScreenCaptureManagerTests`): register → one crop-sized preview bitmap published → `CropBounds` +
`IsOpaque=false` on `GetLatestFrame` → back-to-back pumps coalesce to latest. Byte assertion compares
the DENSE 2×2 crop (`CropBytes` helper — slices source rows with stride gaps, a contiguous range
spans rows wrongly).
- Full suite **293/293 green, 0 warnings**. Audio untouched. **NOT YET VERIFIED ON DEVICE** — the
composition path needs a real 60fps-widget run (open item; see HANDOFF). Web work commits stay
LOCAL (no push) until the user greenlights.
- **Slice 15 — one frame per deadline SLOT: duplicate-on-lag, never skip (2026-09-14, device takes
ty-1723/1726):** slice 10's freshness choice — a render overrun VANISHES the missed slots from the
file — authored ACCELERATED playback: ~35ms render cost vs the 16.6ms slot, one fresh frame per
35ms (log wall-1726: `FramePump stall: iteration 33ms (> 2× the 17ms interval): worst render
33-46ms`), and a 60fps container muxed on arrival → 1723: 697 frames = 11.62s video vs 11.84s
audio; 1726: 163 frames = 2.72s vs 2.93s. The video also ended 0.21–0.24s before the audio ("audio
speeds up, cuts off at the end"). OBS's answer is duplicate-on-lag: `libobs/media-io/video-io.c`
emits one frame per tick and a lagger DUPLICATES the newest frame, counted as "lagged frames due
to rendering lag/stalls" (obs-output.c) — a wall-time hole never exists (docs.obsproject.com/
backend-design: "If the video frame queue is full, it will duplicate the last frame"). The pump's
submit is now a bounded catch-up: `while (now >= nextTick) { submit; nextTick += intervalTicks; }`
over a `deadlineNow` captured once per iteration — the LATEST composite, fresh on the first missed
slot, repeated (OBS's duplication) for the rest. Duration == wall under any render load, at the
cost of a short judder during a stall. The burst is safe because `Channel.TryWrite`
never blocks (slice 10's queue — the take-9 smear was the BLOCKING pipe-write re-copying a stale buffer
during a long freeze; here each emit is nanoseconds). The burned `_outputIndex` (slice 10 judge)
moved INSIDE the submit loop so every emitted slot carries its own +1 (and the old unconditional
pre-gate bump no longer gaps the sequence on non-submitting fast-render iterations). **Good Dog
test** `Pump_Overrun_Renders_EmitsEverySlot_NotSkipped`: 60fps, resolver sleeps 35ms, asserts ≥0.65
of the wall slots are emitted (the skip-pump wrote ~1/35ms ≈ 200 in 7s; the slot pump ~415). Full
suite **294/294 green, 0 warnings**. The +0.6s audio-late clap reading on 1726 was confounded by
the 1.1x acceleration — re-measure on device; if a real residual remains it is the audio pipeline.
No push (web/A/V work stays local).
- **Slice 16 — the desktop capture conversion was the bottleneck: fast downscale +
cadence throttle + reuse-distance ring (2026-09-14, device take ty-1742):** slice 15
fixed pacing but the 1742 desktop layer was still jerky/frozen with a horizontal
tear. Decoded to raw frames and audited: the **desktop band was frozen 21s of 23.35s
(90%)** — ~6.1 content updates/s, freeze runs up to 2.28-2.78s, dup-run max 90
frames (1.5s). The render stat (`worst render 33-36ms`) was real but MOOT: the
capture CONVERSION was the wall. The monitor delivers at the **240Hz DWM cadence**
(~4.2ms); with one-in-flight conversions and each 2560×1440→1920×1080
`DownscaleBgra` at ~150ms under load, `LatestFrame` updated a handful of times/s —
the desktop feed inside a 60fps file read ~90% frozen. (Webcam + audio were their
own paths — fine, matching the report.) Three changes, all in
`Services/ScreenCaptureFrameSource.cs` (+ `Services/FrameRingBuffer.cs`):
(1) `DownscaleBgra` is now **integer 8.8 fixed-point, "shift only at the end"** —
the exact two-stage math of `SceneCompositor.Bilinear` (MyMistakes take-4 rule),
dropping double-per-pixel to a row-walk of integer ops (the capture downscale had
stayed the naive float twin of the 258ms disaster); (2) a **10ms conversion floor**
(`MinConvertInterval`) so the 240Hz tail stops queuing ~150ms of serialized
conversion per second — the open edge sits just above the ~60/s the pump can use;
(3) the ring is a **reuse-distance pool** (`FrameRingBuffer`, depth 8, redLine 4):
a buffer is only rewritten ≥4 rents after its last hand-out else a fresh allocation,
replacing the blind round-robin — since `ScreenCaptureManager` keeps
`session.LatestFrame` across conversions and the dispatcher preview copy lags,
"who released the buffer" needs a consumer API that doesn't exist; a reuse
DISTANCE needs no cooperation (the 1742 new-top/old-bottom tear is that
read-under-write, structural now). Telemetry added: a startup.log line every 2s
(`frames/s, conv avg/max ms, skip busy/cadence, ring allocs`) so the device take
can be judged numerically. **Good Dog test**
`Ring_NoLap_ReusesOnlyAfterRedLineRents` (depth 3/redLine 4 exercises the red-line
skip; the 8/4 config settles at 8 buffers and never grows). Full suite **295/295
green, 0 warnings**. C4 (compositor Epoch-cached downscale / blit-on-change) was
DEFERRED: the slice-15 approved plan assumed a native-res relocation; the
measurement recast it — relocating a ~30ms float downscale to the render thread
just moves the same cost into the slot budget. Re-measure on device; if render
still >16.6ms slots after capture feeds ≤60 real updates/s, add C4. No push.
- **Slice 17 — the OS readback was the real wall: overlapping conversions + monotonic
publish + deeper pool (2026-09-14, device take ty-1824):** slice 16's downscale fix
landed but the desktop was still ~90% frozen on the 1824 take (band 6.8/s updates, max
freeze 4.85s). The new 2s telemetry was decisive: `conv avg 46-50ms max ~61ms` with
`skip busy 37-83` — the **GPU→CPU readback (`CreateCopyFromSurfaceAsync`), not
`DownscaleBgra`**, is the ~47ms wall (240Hz HDR compositing + encoder + 2 capture
devices share the GPU); at one-in-flight that caps the desktop feed at ~17-20
updates/s, which is the file's whole-frame ~7 content-moments/s. Two facts reshaped
the fix: **(a)** shrinking the pool size does NOT scale the desktop (Microsoft docs:
"If content is larger than the frame, the contents are **clipped**") — readback stays
at native 2560×1440; **(b)** delivery is healthy (60-100 arrivals/s), so the lever is
conversion throughput, not the pool size. Changes in `Services/ScreenCaptureFrameSource.cs`:
conversions overlap up to **MaxConcurrentConversions = 3** (pool deepened to 5 buffers
so in-flight frames fit), each completion publishes ONLY if its Epoch is strictly
newer than the last published (`MonotonicGate` — a slow older completion must never
overwrite a newer LatestFrame), Epoch increments via `Interlocked`, and the
DownscaleBgra row-scratch became per-conversion locals (concurrent callers). The
render side (33-42ms → ~30 unique composites/s) is the NEXT cap after capture speeds
up — that's the C4 slice, queued right after this re-measure. **Good Dog test**
`PublishGate_TryPublish_OnlyStrictlyNewerWins`. Full suite **296/296 green, 0
warnings**. NOT YET DEVICE-VERIFIED; target: telemetry frames/s jumps ≥ ~30-40 and the
band audit drops below ~50% frozen. No push.
- **Slice 18 — C4: the FramePump blit-on-change composite cache (2026-09-15, take ty-1841
verdict):** the 1841 take proved capture fixed (band ~20 fresh updates/s, no tears, pacing
clean) but logged a FramePump **stall on EVERY iteration** (`totalMs 21-44`,
`render=full-render split=0 elements=6 dynamic=4`, worst render 166ms startup spike) →
~22-28 composites/s hard-caps the desktop inside the 60fps file. The SceneGraph split
CANNOT fix this wired: `GetSplitPoint` returns **0** because the live-capture backdrop is
element 0 and must stay dynamic — a baked capture goes stale — so every tick runs the full
re-composite even when no input changed (the compositor's per-layer paste cache already makes
identical frames identical per-element; the waste is re-COMPOSITING the whole 8.3MB frame).
Same shape as OBS (sources cache their surface, the compositor renders on update only —
the pattern this file has cited since the 2026-09-04 paste-cache slice) at the FRAME level.
`FramePump.RenderFull` now wraps both full-render call sites (no-SceneGraph + split=0
fallback): it computes `BuildFullRenderSignature` (a deterministic hash of the crop/output
dims, the social bar identity + top, and — per element, MIRRORING the compositor's own
resolution — the element ref, layout/visual bits, and the frame each element would resolve
through the SAME resolver seam, using buffer-array identity + Epoch + CropBounds; a changed
frame forces a re-composite, an unchanged one never serves stale bytes) and, when it matches
the last composite, reuses the cache with ONE `Buffer.BlockCopy` (~3ms) instead. The cache
buffer is a **separate long-lived array** — never the scratch pool — because the caller burns
the frame counter and recycles the scratch AFTER `RenderScene` returns; the cache is written
from the rendered scratch BEFORE returning (pre-burn, pre-recycle). Engagement is gated on
the 1:1 config (`SourceRect == Output`, the only deployed tier); the vertical tier's cached
fresh-scale-buffer follow-up stays exactly where it was. Telemetry: internal `CacheHits`/
`CacheRenders` counters surface as `cache {renders}R/{hits}H` on the 5s stats line, and the
ticks' key is an internal `OutputIndex` (burned counter, stable across one tick's resolver
passes). **Good Dog test**
`FullRenderCache_StaticInputs_RenderOnce_Then_Reuse_UntilInputChanges`: static scene →
exactly ONE composite (renders==1, hits>0, byte-identical content above the burn strip),
then a new frame (new array + monotonic Epoch) invalidates and propagates. Existing pool test
now passes a STABLE scene (production hands `StagedScene`; a fresh scene per tick churns the
element refs inside the signature and hides the cache — the tests that care about cache
behavior must mirror production) and keys its red/blue alternation on `OutputIndex`/frame
count so the tick's two resolver passes (signature + render) don't double-advance a call-count
flip. Full suite **297/297 green, 0 warnings**; verify.sh scope-locked to FramePump.cs +
FramePumpTests.cs + docs. Target next take: `cache` hits dominate on TV holds, `avg render`
drops toward the BlockCopy, stalls vanish, then the band audit + clap re-measure decide push.
No push.
- **Stop ordering matters:** `StopAsync` stops the encoder — since slice 10 it FLUSHES the pending
queue (`Channel.TryComplete` → drain writes the leftovers, closes stdin → EOF → ffmpeg finalizes+exits;
an accepted frame is never lost) — **before** awaiting the loop. The old reverse-order deadlock was
a submit stuck on pipe backpressure; the drain thread owns that wait now, and stopping the encoder
first keeps the pump's own (non-blocking) submits from racing the completed queue as a spurious drop.
`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(DeviceKeyForWebcam(WebcamId))` — **device-keyed** (configs carry the
identity GUID; sessions key on the Windows device id — the old direct-GUID lookup returned null
forever and the webcam was structurally absent from output; take-2 fix 2026-09-01,
`WebcamOutputKeyTests`), `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<string, IMediaFrameSource?>` 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<TimeSpan,CancellationToken,Task>? 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<IDecodeProcess>` 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
- **"We're not them" (2026-09-01):** assume the creator's hardware is mediocre, because it is.
Every feature spends the machine's budget once — never twice for the same result (record OR
stream, not both; one reusable stream; one webcam; two mixer inputs). If a feature only sings on
a high-end chassis, it doesn't ship — the OBS escape hatch is open by design.
## Monetization (design decision — the branding flash is the sword)
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 branding credit — "made with LlamaCasty!" in **full-frame neon** (white core,
feathered red/blue halo with the channels split in opposite directions), at a **random position
inside the frame on every presentation** (single pre-measured line, so it never wraps; travel range
is `0 … 1920−textWidth`), held **2s** (500ms fade in, 1000ms full, 500ms fade out), first credit
~5s after go-live then **every `rand(30s)+30s`** (30–60s), on the live output **and** local
recordings. An always-on watermark can be cropped or covered; an intermittent full-frame flash can't
be cropped and is impractical to edit around on a live feed.
**The flash is also the free tier's billboard** — every free stream advertises LlamaCasty to its own
viewers; the free tier is distribution, not compromise.
Implemented as `Services/Compositor/BrandFlashPresenter.cs` (raster + envelope + placement + licence
gate), published to **both** the frame pump (`FramePump(brandFlash:)` → the compositor's per-frame
`flashFrame` slot) and the preview (`BrandFlashElement`, bound to `BrandFlashImageSource`) from the
**same** `VideoFrame`, so the preview cannot drift from the broadcast. Deliberately NOT the
`flashFrame` parameter of `SceneGraph.GetBakedBase` — `SceneRegion.Matches` compares element ids
only, so a credit folded into the cached bake would freeze there and never expire.
**Pre-GA posture — REVERSED 2026-09-26 (TASK 36 shipped).** This used to render *in the preview
only*, never on the broadcast, so test VODs stayed clean. The creator ruled that the free tier must
be honest advertising: it now composites into the recording and the stream. Test VODs from an
unlicensed instance carry the credit — use a license key or the paid build for clean captures.
**Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting.
License activation still flips exactly one bit: `IsPremium` → flash off. Nothing else changes
between free and paid, ever.
**Cadence is APP-LIFETIME, not go-live (creator ruling 2026-09-26):** the presenter is
`Start()`ed **once in the `MainViewModel` ctor**, not from `UpdateLiveVisuals()`. It used to be
gated on `IsLive`, which meant a recording made without ever going live carried no credit — the
creator's ruling: *"the made with llamacasty flash should appear in all scenes, not just live"*
and *"should also appear in recordings"*. One start now covers every scene, the preview, the
stream and the recording, because go-live and local recording are the **same `FramePump`**
(`Streaming.Operations.cs:68` and `:199` both call `StartAsync` with the same `brandFlash:`
delegate) — verified, not assumed; there is no second encoder path needing a "redirect". The
licence gate needs no live branch: `IsPremium`'s setter pushes `Enabled` from anywhere.
**Consequence of app-lifetime — the premium edge restarts the timer.** `Enabled = false` stops
the `DispatcherTimer` (cutting the advertisement mid-credit). Because `Start()` is no longer
called per go-live, nothing would ever restart it, so a key entered mid-session would leave the
credit dead until the process restarted. The setter therefore restarts it when re-enabling while
`_running` (`IsCadenceTimerEnabled` is the test seam).
**Test-arithmetic trap, hit again 2026-09-26:** `Advance(5.1)` in ONE call cannot observe a
credit — `Advance` both *opens* the presentation and *ages* it by the same delta, so a single
5.1s step blows clean through the 2s window. Always step in 1/30s increments like the real timer
(the `Step` helpers in `BrandFlashOutputTests`).
- **Paid (one-time perpetual license):** branding flash removed. `BrandFlashEnabled` is now a
**derived, non-assignable** `!IsPremium` and the presenter's own `Enabled` gate is re-checked on
every frame, so a key entered (or revoked) mid-credit cuts the advertisement on the next tick
rather than letting it finish. That's it. No feature gating. Alerts, social bar slots, voice
filters, TRAX, recording — everything is free.
- **Pricing (2026-09-21 — switched from subscription to one-time "own it"):** **$29 lifetime** founder's
price (launch → 90 days) → **$49 lifetime** list at GA. The key is **perpetual** (`IsPremium` never
lapses; the old renewal/lapse path is dead). Rationale: a local app with no per-user server cost and a
renewal-averse, free-surrounded audience — minimize commitment size, don't charge rent. Accessibility is
the driver: the free tier is the *full* app (only the watermark differs), so a broke new streamer pays $0;
the low one-time price is the "no recurring bill" entry. Full reasoning, and the dropped $49.99/yr →
$99/yr subscription model, in `MONETIZATION.md`.
- **Billing:** **Polar (polar.sh)** — open-source MoR (Apache 2.0), handles payments, **one-time orders**,
license keys, and global tax compliance. Startup Program gives Scale plan free for 12 months.
Product: `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (⚠️ currently a $99/yr *subscription* product — must be
re-created as a one-time product for the new model). 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 — now moot** (perpetual keys never expire, one-time model 2026-09-21); paid users see zero
license UI. TASK 36 item 5 (expiry reminder) is superseded.
**Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1
ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by
design, for **every** creator, free and ungated (the only subscriber swap remains the branding-flash
removal above). It has three layers, all free — this is the product's differentiator, not a paid tier:
- **Reward-event capture (the data foundation).** The live-chat poll (`YouTubeChatService.Poll`)
receives every reward event over `liveChat/messages` and — since TASK 43 (2026-09-24) —
**decodes all six types**, not two: `superChatEvent`, `superStickerEvent`, `newSponsorEvent`,
`membershipGiftingEvent`, `giftMembershipReceivedEvent`, `memberMilestoneChatEvent` land as
`ChatMessage.Kind` (`ChatEventKind`) with their payload fields (display string, sticker
description, gift count, gifter name, milestone months); free subscriptions emit NO event
(API fact — a sub mention is a plain text row). The remaining half of the original capture
plan stays open (TASK 3 item 20): persist each event to the canonical SQLite `RewardEvents`
table (broadcastId, type, timestamp, amountMicros, currency, tier...) + `superChatEvents.list`
(30-day lookback) backfill — that persistence is what the session report and the journey
tracker read. Alerts did not wait on it: the shipped alert path (TASK 43) renders directly
from the in-memory `ChatMessage` feed.
- **Session / broadcast report.** A session is created at Go Live; the poll + End roll that session's
reward totals (new members, Super Chat $, stickers $, gifts, milestones). This is the in-app
replacement for the emailed external "stream activity report" — the incumbents (Streamlabs et al.)
email near-useless post-stream summaries; we track the same data natively and maintain it.
- **Journey tracker (TASK 39, slice 1 shipped 2026-09-22).** A **"YPP"** tab below the
Stream Settings pull-out (Live screen right edge) shows the connected creator's position
toward each YPP tier threshold. Slice 1 = current-scope data only: `channels.list?mine=true
&part=statistics,contentDetails` (subs/views/videos + uploads-playlist id) +
`playlistItems.list` for 90-day public uploads. Every refresh appends
a `YppSnapshot` to SQLite (schema v10) — the history a later slice needs for velocity/ETA.
**The `auditDetails` part is OUT (fixed 2026-09-23):** readding it 403s the WHOLE request —
that part alone requires the `youtubepartner-channel-audit` scope no app for normal creators
should request (the standing flags sat unreadable since slice 1 shipped; the app's `youtube` +
`youtube.force-ssl` scopes were never sufficient for them — the "no re-consent" slice-1 claim
was wrong for that part). Channel standing now deep-links to the Earn page; API stats come from
`statistics,contentDetails` only. YPP/API failures log the response body's `error.reason`.
**YPP thresholds are versioned, date-aware DATA, never constants** — the Tier-2 bar doubles
for new applicants on **2027-02-01** (long-form 4,000 → 8,000 qualified hrs / 365d; Shorts
10M → 20M / 90d), while the Tier-1 fan-funding bar (500 subs / 3,000 hrs / 3M Shorts) is
unchanged. Hardcoding the pre-2027 numbers would ship the wrong ETA. Watch-hours / Shorts /
velocity / ETA are **slice 2**: Analytics-API scopes (`yt-analytics.readonly`, additive
`yt-analytics-monetary.readonly`) behind the `IChannelStatsProvider` seam (additive
capability, requires an OAuth re-consent). Compliance items no API exposes (2FA, AdSense
linkage) are in-app self-reported checkboxes with deep links; channel standing (exposed only
via the partner-audit scope) deep-links to the Earn page. Slice 2's once-daily refresh gate +
snapshot history were baked into slice 1.
**What was rejected:** always-on watermark (obscurable — replaced by the flash), hard stream-time
cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams
routinely run 2-4 hours), soft-limit nagging, freemium feature tiers, and donation-only (relies on
the kindness of strangers). Resolution/quality ceilings stay rejected (fixed 2026-09-01, no longer
"deferred"): the free tier never loses quality — auto step-down (**TASK 33**) is a *protect the
stream* feature, never a monetization penalty.
## Auth gates Go Live, but not exploration
The app is fully usable without authentication: users can build scenes, add sources, compose
previews, and audition the software with zero commitment. But **going live requires authentication** —
it's the one capability gated behind YouTube sign-in. The sign-in should never pressure the user
("sign in (optional)", not a modal wall): the two-state top bar shows **Start Stream** (offline) /
**End Stream** (live), and the Start Stream dialog hosts the account — a saved session appears as
the default with "Change Account"; with none saved, a "Sign in to YouTube" button starts OAuth and
the Start button stays disabled until signed in.
## Account assumption (do not build an account setup flow)
Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no
YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to
YouTube" button; going live is unreachable until an account is connected.
## YouTube Live API — design constraints (do not violate)
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. **Parsing (fix 2026-09-22):** the real API
nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — reading it as
a string crashed every poll (`requires an element of type 'String'`, seen 2026-09-22 on both test
sessions); the parse now reads the nested `.status`/`configurationIssues` and tolerates the old
flat-string shape. UI = a full-width banner strip under the
top bar, `HealthIssueBanner` text + `HealthIssueBackground` (amber `#b8860b` warning / dark red
`#8f1f1f` error), hidden by `NotNullToVis`. The design's bottom-strip YouTube logo + green/red dot
(clickable → dialog) is still queued.
- **One dialog, three states** — not connected / connected-offline (all editable) / live
(title + description + visibility editable; quality + account greyed out). Both entry points
(Start Stream button + bottom strip) open it; prefilled from saved session profile.
- **End stream (SHIPPED 2026-09-01; pre-flight hardened 2026-09-22)** — stop encoder → `YouTubeStreamService.EndBroadcastAsync` POSTs `liveBroadcasts/transition?broadcastStatus=complete&id=…&part=status` (AFTER RTMP EOF, so no frames post-date the end — VOD finalizes immediately instead of ~1min of frozen "stream offline"). **2026-09-22: every end logged 403 `invalidTransition`** — a blind complete races enableAutoStop/YouTube's auto-complete (the old doc's "already fired" guess was wrong; it's a broadcast state race). The call now pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only transitions from `live`/`testing`, skipping silently otherwise (`enableAutoStop` finishes every skip — never throws, never toasts a finished session). Inconclusive pre-check still posts (old behavior, backstopped). Record-only stops make ZERO API calls (guard: `wasLive && _currentBroadcastId != null`).
- **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz,
mono/stereo only. YouTube flags violations via health status.
- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD
(`recordFromStart` + `enableDvr` default true).
- **Test Stream mode (TASK 41, SHIPPED 2026-09-22)** — every broadcast is private-only anyway
(see the visibility lock above), so "Test" is a **session variant over the real go-live
pipeline**, not a new mode: `BeginTestStream` → `StartStreamingSession(alsoRecord:false)`
(the dialog path was extracted into this shared boot — no Go Live dialog for a Test, no
`BroadcastForm.CaptureGoLive` "last live" stamp, no recording). `IsTestStream` flips the face:
gold top bar + glow (`#9c6f1c`), a **TEST** badge, and the End button reads **"End Test"**;
`StopStream` clears it in every path (including pump-failure rollback). Once the liveChatId
resolves, `TestSession.BeginTest(broadcastId, liveChatId)` opens the **TEST drawer** (third
right-rail pull-out, three-way one-open-at-a-time exclusivity wired in `MainViewModel.Ypp.cs`;
any left-click outside the rail closes whichever drawer is open — TEST included (fixed
2026-09-25, TASK 46; the code-behind outside-click close in `MainWindow` had skipped
TestSession, so only Stream Settings + YPP dismissed on an outside click).
**liveChatId resolution (fixed 2026-09-25, TASK 44):** the id is `snippet.liveChatId` —
`contentDetails` has NO liveChatId property — and YouTube only populates it once the broadcast
is LIVE (the official GetLiveChatId.java sample lists broadcastStatus=active). So
`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` reads
part=snippet and polls with a bounded retry, and `PrepareAndStartLiveAsync` starts the frame
pump FIRST (the RTMP push is what flips ready→live via enableAutoStart) before resolving it.
Chrome-debugging note: a fetch at broadcast-insert time (lifecycleStatus ready) legitimately
returns no id — missing liveChatId is the ready state, not an auth failure, until ~20s post
push.
Drawer tooling: **Mock Chat Input** = a REAL `liveChat/messages.insert` (`YouTubeStreamService.InsertChatMessageAsync`)
that round-trips through the real ~2s poll and renders through the live overlay path;
**simulated Subscriber / New Member / Super Chat** = `YouTubeChatService.InjectSimulatedMessage`,
which raises the poller's `MessageReceived` seam, marked `ChatMessage.IsSimulated`. **API fact
(researched + hard constraint): `liveChat/messages.insert` only creates `textMessageEvent`s —
SuperChats / memberships / sponsorships cannot be inserted via any API**; simulated non-text
events are local-only by design and never reach YouTube. **Insert body shape (fixed 2026-09-25,
TASK 45):** the body MUST include `snippet.type` = `"textMessageEvent"` alongside `liveChatId`
and `textMessageDetails.messageText` — omitting type returns `400 MISSING_REQUIRED_FIELD`
(found live on the next Test Stream; the TASK 41 test had asserted body presence but not
type, so it stayed false-green). Scopes `youtube` + `youtube.force-ssl`
already cover insert. **Real stinger/TTS alert widgets are DONE (TASK 43, shipped
2026-09-24) as the native alert box** — see the next section; Text source (TASK 3 item 16)
and the RewardEvent persistence (item 20) remain open.
### Native events & alerts (TASK 43, shipped 2026-09-24 — StreamElements replaced)
The creator asked to replace the external StreamElements feed with native YouTube events;
the answer is **native events + a native alert box**, and the product decisions are
published decisions (recorded in `TASKS/task-43-native-alerts.md`):
- **No third party.** The chat feed IS the event feed: `liveChat/messages` carries all six
monetization events (superChat / superSticker / newSponsor / membershipGifting /
giftMembershipReceived / memberMilestoneChat). `streamList` is the *connection semantics*
of that same endpoint (pollingIntervalMillis-driven), not a separate call — the service
re-arms its one-shot poll on the server's cadence (clamp 1000–6000ms), `maxResults=2000`.
Cite: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- **`SourceType.AlertBox` — one "Stream Alerts" celebration zone per layout**, OBS
alert-box style: a designated draggable canvas area, idle = transparent. Mirrors the chat
gate (`CanAddAlerts`; `AddSource` refuses a second). Defaults 620×430 → 680×200.
- **Six events → six unique animations** (`Services/AlertRenderer.cs`): SuperChat slide-up
+ gold count-up + shine sweep (green), SuperSticker scale-pop on a tilted chip (purple),
NewMember drop-in + flash band (violet), MemberGift slide-left + gift-chip `×N` fan
(amber), GiftReceived confetti scale-in (green→purple), MemberMilestone rise + tenure
growth bar (orange). Phases enter/hold/exit (~4–5s); every card draws the **brand line
"made with LlamaCasty!"** — each broadcasted event is free product placement.
- **`Services/AlertOverlayLayer.cs`** — the component: enqueues events one-at-a-time,
advances on a 33ms dispatch ticker, serves the live frame through `RenderFrame` (same
cache-first pattern as chat) and the preview through `UpdatePreview`; **`Advance(double)`
is the deterministic test clock** (tests drive playback with no timer). Queue cap 10
(floods drop the tail, never stall). `ChatEventKind.None` rows (plain chat, sub mentions)
**never enqueue** — per the creator ruling, a subscriber mention is sufficient; there is
no free-sub alert (YouTube emits none) and no viewer count (vetoed — demotivating,
weaponizable).
- **Alert box VIDEO (TASK 47, 2026-09-26):** since TASK 47 the box plays a clip on every
alert instead of (only) an animation — `Enqueue`→`BeginClip` spawns a **per-play**
`IAlertClipDecoder` (ffmpeg `rawvideo bgra -vf scale=W:H` frame pipe + `f32le` audio
pipe, both real-time paced via `RawVideoFrameReader`; **not** the refcounted
`MediaVideoSourceManager` — alerts are one-shot plays that must be disposed at drain).
Custom clip wins per-source over the built-in ONLY when `AlertUseDefaultVideo==false`
AND the file exists; the shipped `Assets/alert-default.mp4` is stamped into the `Asset`
table at startup (`AlertDefaultVideoKey` setting; the prune keeps it via UNION) and
read-at-play; any decode failure falls back to the six `AlertRenderer` animations
(deliberate, not an error). Fades in/out via **`FadeDurationSeconds = 0.30`**
(`AlertOverlayLayer` const): fade-in by `elapsed` on straight-alpha frame copies;
EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears
`_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio
forwards to the mixer sink scaled by `volume × fade`.
- **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes
"Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` —
a strip rasterized **on the UI thread once per text** (cached, unpremultiplied
straight-alpha pill) then positioned by pure byte-math per call, so the pump calls it
from its own thread with no locking. **It is a dynamic overlay, NEVER baked** (the
social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` +
`CompositeLayers` take a trailing `tickerFrame` threaded from
`FramePump._alertTicker` (`Func<VideoFrame?>` ctor seam) through
`RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll
changes the cache key. Toggled by `AlertShowTicker`.
- **It draws INSIDE the Stream Alerts box (creator ruling 2026-09-26).** Was a global
1920px bar pinned to the top edge: *"the ticker should appear over the stream alerts
video, not over the entire preview window"*. The strip is now rendered at the
**alert box's own size** (`AlertTickerRenderer.Render(..., width, height)`, defaults
still 1920×48) and the box's origin rides on **`VideoFrame.Placement`**
(`(int X, int Y)?`, with `OriginX`/`OriginY` = 0 when unset). Every blit site reads
`tickerFrame.OriginX/OriginY` instead of a literal `0, 0` — `SceneCompositor` ×2 and
`FramePump`'s static-bake `Overlay` path. The preview binds the same numbers
(`AlertTickerLeft/Top/Width/Height` → `AlertTickerElement`'s `Canvas.Left/Top` +
`Width`/`Height`), so preview and output cannot drift — the same guarantee as the
branding credit. Product default box is **680×200 at (620, 430)**
(`MainViewModel.Sources.cs`); height is clamped to the natural 48px strip.
**No alert box in the scene ⇒ no ticker at all** — there is no global position left.
- **Why the position travels on the frame, not in a full-canvas frame:** a 1920×1080
overlay is 8.3MB of large-object-heap garbage per tick — ~2.5GB churned over one 10s
alert at 30fps. A box-sized strip is ~370KB. The branding flash *does* render
full-canvas, but it **recycles** one 8MB buffer and bumps `Epoch`, so it never
allocates per frame; the ticker allocates per call, so it must stay small.
- `CopyStrip` clips **rows** to the target height: the pill rasterises at its natural
48px, so a box shorter than 48px would otherwise write past the end of the buffer.
- **Three display methods** (`Source.AlertDisplayMethod`, DB column, panel "Display"
selector): `TickerScroll` (marquee), `Flash` (0.5s on / 0.5s off), `Solid` (centred,
still). The marquee is paced in **reads per alert** — `TickerReadsPerAlert = 3`
passes inside the alert's own length, speed derived from that — **never px/s**; the
old fixed `140px/s` took ~17s per pass, so a 10s alert showed the message once.
Same convention as the incumbent (Streamlabs: "Alert Duration" + "Text Delay", not a
scroll-speed slider). Flash's off half returns null.
- **A ticker still needs its own preview element, not a `Source`.** It is a frame-pump
producer, so it never rides a per-element `Image`; `AlertOverlayLayer` takes a
`tickerPreviewSink` and publishes from `RefreshAlertPreviews()` (UI thread only —
the pump thread must never touch a `WriteableBitmap`); the VM writes it into one
reused `WriteableBitmap` (`AlertTickerImageSource`/`AlertTickerVisible`) bound to
`AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`. The rect
is re-raised on **every** published frame, not just on a bitmap resize, so a box
moved or resized mid-alert tracks live.
- **Two measurement traps here:** a seamless marquee never goes blank (a wrapped copy
enters as the pill clears), so count a pass by the pill's leading edge resetting, not
by an empty frame; and the run is phase-started half a frame in, or the first
published frame is blank (the pill sits exactly off the right edge).
- **Alert audio under the master limiter:** `AudioMixer.EnqueueAlertAudio` writes a
dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` before the
limiter and added at **unity — never ducked** (creator ruling: "don't lower my game
audio for a tip"); `StartLive` clears it. Per-alert loudness = the Volume slider.
---
## Windows packaging & distribution (✅ DECIDED 2026-09-27 — Store MSIX + Store IAP)
**The distribution route is settled: Microsoft Store, MSIX package, Store IAP.** Creator's
criteria, verbatim: *"zero headaches, minimal maintenance (for me) while still providing
accountability and a reasonably easy upgrade flow."* Route A was chosen because it is the
only option where all four are solved by handing the work to Microsoft rather than to a
certificate vendor: **$0/yr, no certificate, no HSM, no annual renewal, no SmartScreen
ramp**, plus Store auto-update, Store-side payments/entitlements/refunds/support, and
Microsoft review as the accountability layer. The rejected options and their reasons live in
`TASKS/research-store-certification.md` §3 — **read that before reopening the question**, not
before re-deriving it. Executable checklist: `TASKS/task-48-distribution-msix.md`.
**Consequences that are architecture, not packaging detail:**
- **⛔ Velopack and the update URL are deleted** — the Store auto-updates. 3 edit sites,
`ytLive.csproj:92` + `App.xaml.cs:3,38-46`. The self-hosted DO droplet dies with it.
- **⛔ The Polar licensing subsystem is deleted** — `PolarLicenseService`, `PolarLicense`,
`MainViewModel.License.cs` (`PremiumUrl`, customer portal, the
`OfflineGracePeriod = 14 days` subscription-era artifact, renewal/lapse copy), and the
wrong "Polar unlocks alerts" string in `OverlayHost.xaml`. **`IsPremium` is derived from the
Store entitlement**, not a remote HTTP call. That deletes the entire "network flaky → app
thinks I'm expired" bug class along with the offline-grace machine.
- **⛔ Bundle ffmpeg — no runtime downloads** (Store policy 10.2.2; also the 2026-09-01 404).
See the FFmpeg locator section above. **This is the first code unit.**
**What does NOT change: the monetization posture.** Free gets everything; the branding flash
stays the **only** paid delta (`TASK 36` item 2). Store IAP changes how `IsPremium` is
*obtained*, never what it *gates*. `Monetization` above still governs.
### ⛔ Durable invariant: full trust, or recording silently breaks
MSIX packages run at one of two trust levels. A packaged WPF app declaring
`rescap:Capability Name="runFullTrust"` runs at **mediumIL — the same permissions as a
standard desktop app, NOT AppContainer** — and may spawn child processes, which is why
bundling ffmpeg is legal. Writes to user-profile locations (AppData, Downloads, MyVideos)
**pass through unvirtualized at mediumIL**; the package install folder is read-only.
**`ViewModels/MainViewModel.Recording.cs` writes recordings to
`%USERPROFILE%\Downloads` / `SpecialFolder.MyVideos` (`DefaultRecordFolder()`,
`ChooseRecordFolder()` via `Microsoft.Win32.OpenFolderDialog`), and the whole
temp-write-then-move lifecycle stays in one directory.** That works *only* because we are full
trust. No `broadFileSystemAccess` capability is needed, and none should be added.
⚠️ **If anyone flips the manifest to `appContainer`, `Directory.CreateDirectory` and
`File.Move` start resolving to a per-package virtualized location and output vanishes with no
error.** A comment at `DefaultRecordFolder()` is owed **when the manifest lands** (TASK 48
item 6) — not before; a comment describing a manifest that doesn't exist is worse than none.
### Signing — do not buy EV
Microsoft **removed the SmartScreen instant-bypass for EV certificates in March 2024**. An
EV-signed file now accrues reputation exactly like an OV-signed one, so EV ($400+/yr) buys
what OV (~$150–300/yr) buys. Signing is **not** instant regardless — a valid cert stops the
*malware* warning immediately, but "unrecognized publisher" clears only as SmartScreen accrues
reputation from real download volume. Cert validity is capped at **460 days** (recurring
line item) and private keys must live on an **HSM or hardware token**. The **Store MSIX route
needs no certificate at all** — Microsoft re-signs. `Distribution.md` §4.3 was corrected
2026-09-27; it previously recommended EV.
### Distribution and licensing are independent — but the ruling collapsed them
A **Store-distributed** app *may* still verify licenses with **Polar** (policy 10.8.1
explicitly permits a secure third-party purchase API for non-game PC products), so "should we
use the Store?" did not logically imply "drop Polar?" — route B was a real option. **The
creator chose route A instead**, so Polar is gone. Recording the distinction because it was
the reason the decision was thought-through rather than assumed: the licensing choice rode on
a distribution decision, and the *only* route that removed the licensing backend was the one
that also removed the certificate. Both burdens had the same single solution.
### ⛔ "Only when the user says so" is enforced by Windows, not by us
Camera/mic consent is layered: Store policy **10.6** forbids circumventing OS checks; the
**Windows desktop-app camera/microphone toggle** (Settings → Privacy & security, since Win10
1903) is the gate; hardware lights and tray indicators are the OS's disclosure mechanism. So
**route everything through the standard MF/DShow/WASAPI paths and we never write that
certification ourselves** — the OS does it and gets neither our credit nor our blame.
⚠️ **Untested:** whether that toggle reliably gates a **DShow webcam and a capture card**.
Microsoft's own support page admits desktop apps "might still be able to access your camera
or microphone even when these settings are turned off." This is **not** a certification
failure (we declare `webcam` honestly and use supported APIs) but it is a real user-trust
issue — so **no privacy-forward marketing copy until it is measured.** Guard note added to
`MARCOM.md`.
### ⛔ Durable invariant: chat is rendered, never stored
`TASK 49`'s profanity filter is **local, on-device, opt-in, non-persistent**, with a
**user-supplied word list — never a hardcoded slur list baked into the binary** (unpleasant
artifact, false positives across dialects, extractable). It must **never match the SuperChat
amount or any reward field** — those are financial data under policy 10.5.5.
The non-persistence half is not just hygiene: it is the load-bearing half of the **11.12 UGC
certification answer** (mirrored YouTube chat is moderated at industrial scale upstream; ~1.6B
comments removed in Q1 2026 alone, child safety the #2 reason at 124.6M). **No future feature
— chat history, moderation logs, analytics, crash payloads — may quietly break that
assumption.** If one is ever proposed, the 11.12 position must be re-argued first.
### Minors
The **operator is 13+ by construction**, not by promise: LlamaCasty requires a YouTube
account to stream, and YouTube's ToS sets the account floor at **13** (regional variants reach
14; 13–17 need parent/guardian permission; under-13s are on supervised/YouTube Kids accounts).
So a COPPA theory premised on collecting PI from under-13s has no purchase against the
operator. The residual is **viewers** — still possible on supervised family accounts — and it
is weak: live chat is restricted there, YouTube moderates before we see it, and we persist
nothing. COPPA is very unlikely to bind (not child-directed), but note the enforcement trend
(FTC Sept 2025 *Apitor*: operators are responsible for what third-party components collect) —
so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no
persisted chat or reward payloads."