ai.md is the mandatory read every session; at 2,702 lines / ~64k tokens it costs the whole subsystem history even when the task never touches it. This is a verbatim move, not a compression - nothing is deleted and each stub is written from the moved text. - docs/ai/frame-pipeline.md: 'Frame cadence', 'Per-element composite timing + scene dump', 'What the first instrumented take said', 'Render budget', 'Scene compositor' and 'Live frame pipeline', byte-identical, plus a short 'Related in ai.md' footer and two stale 'below' pointers corrected. - ai.md keeps two stubs carrying the invariants that must not be missed (overlay on every render path, VideoFrame.Epoch, StripPool threads, Release-only render numbers; arrival-stamped rawvideo, non-blocking submit, stop order, burned _outputIndex judge) and links the new file. - docs/ai/index.md + docs/index.md route the split; schema.md inventory lists the new tier. - 4 index-row references and 2 TASKS.md anchors repointed; ai.md's Services row too. Measured: ai.md 63.6k -> 47.1k tokens, 2,702 -> 1,975 lines. Progressive disclosure per schema.md convention 5. No source touched; no push.
186 KiB
ytLlive — AI Guide
Memory map entry point. Conventions live in
schema.md; task status and YouTube API research inTASKS.md; brand/marketing palette and launch strategy inMARCOM.md(gitignored — read it before any branding or marketing work); directory maps in each folder'sindex.md. Reading order: this file →TASKS.md→MARCOM.md→<dir>/index.md→ source.Subsystem detail is split out: large blocks of this file now live under
docs/ai/and this file keeps a short invariant stub + link where each was removed. Opendocs/ai/index.mdto find the right one, or ignore it entirely until your task touches a moved subsystem.
Response style
No default "Plans & Pitfalls" / planning boilerplate. Respond directly and concisely: do the queued work, then report what changed and what's next. Skip feature pitch, step-by-step implementation plans, pros/cons tables, and "potential pitfalls" sections unless the user explicitly asks for a plan first. A short diff-style summary beats a proposal document every time.
Derivative work (spin guard mandated in AGENTS.md)
Nothing we build is novel — streaming/overlay/WebView2 problems were solved by OBS, CEV, and WPF ecosystems long before us. Two consequences:
- Proactive: new features start with a quick external scan, not head-first design (citation goes in the commit message).
- Objectively triggered spin guard: a second failed fix for the same symptom means STOP theorizing and research the established answer externally; there is no unique bug in this repo that the wider ecosystem hasn't hit. This project's hard lesson: the web-source bounding saga (2026-08) burned 9 commits rediscovering that a browser source is a fixed canvas — OBS keeps it at a stable page size, crops/hugs content via the box, and clips at the edge. The apparent "gap" that ended the saga was a widget's own CSS glow effect, not a bug.
Derived-solution rule (2026-08-29, driven by the image-shrink incident): when
you work out any one-off, reusable solution (recipe / workaround / how-to), write
it into MyMistakes.md → Recipes registry that same session, and grep it
before ever re-deriving. A solution recorded once ends the loop; an un-recorded
one guarantees the user hears it derived again (see AGENTS.md → Working rules → 🔬).
LAN infrastructure (192.168.50.x)
All LAN credentials live in CREDENTIALS.md (gitignored). Server IPs and roles:
| Host | IP | Role |
|---|---|---|
| corsair | .132 | Gaming/streaming primary — RTX 5070 Ti (this machine) |
| llamavault | .87 | ODROID-HC4, 12TB RAID5, media storage (NFS). SSH: ssh mshallop@192.168.50.87. DHCP-bound — it moved off .86 after the 2026-10 power outage, so confirm with a ping before trusting any path. SMB is reached by the llamavault name, pinned in the Windows hosts file, never by literal IP: \\llamavault\llamaVault\... |
| pivault | .158 | RPi5 8GB, 10.83TB RAID5 NAS |
| jarvis | .210 | AMD Ryzen 5 5600X, RX 6600 XT — AI inference, SearXNG, Docker registry, Immich |
| ultron | .108 | AMD Ryzen 7 7840HS — AI orchestrator, llama-server, Qdrant |
| gordito | .144 | Home Assistant, Alarmo, Zigbee |
| officerfriendly | .26 | Pi-hole DNS |
| octopi | .197 | OctoPrint (Prusa MK4) |
| BigBlinkyRouter | .1 | ASUS ROG GT-BE98 Pro, WiFi 7 |
Web infra (DO droplet): llamacasty.com + llamachile.tube at 143.244.176.131.
Pushing to the Gitea instance (llgit.llamachile.tube). The repo is gramps/LlamaCasty
(renamed from gramps/ytLlive; the old path 301-redirects). Push over HTTPS, not SSH:
origin = https://llgit.llamachile.tube/gramps/LlamaCasty.git, with the credential helper
/home/gramps/bin/gitea-credential (registered as
credential.https://llgit.llamachile.tube.helper) reading the account password from the
gitignored CREDENTIALS.md on demand — nothing stored in plaintext, nothing in a command line.
The Gitea account password IS the portal password in CREDENTIALS.md, and the same password
authenticates the REST API (curl --config …/api/v1/user) — that API is the fastest way to ask
the server questions (the repo's real name and clone URL, the account's SSH keys, a token).
⛔ The SSH endpoint on llgit:2214 is not a usable push path: the account's SSH keys ARE
registered (key id 6 "Corsair VSC" = this machine's ~/.ssh/id_ed25519) yet the server refuses
the publickey, and the port later refused connections outright. Do not spend a session on key
archaeology — ask the API, then push over HTTPS.
No-Fluff Mode (on demand)
Invoke with "no-fluff mode" (or similar) when you want ruthless review instead of reassurance. In that mode:
- Strip all polite pleasantries, emojis, transitions, and conversational padding.
- Treat the user's input as a draft to be methodically deconstructed or strengthened — argue, correct, and sharpen rather than agree.
- Give unvarnished truth, not reassurance.
This is an occasional, explicitly-invoked mode — never the default. The default response style above stays in effect unless invoked.
Brand
Brand palette and official assets live in MARCOM.md (gitignored — read it for hex values,
asset file names, and marketing copy). The brand red #e94560 is the primary accent used
throughout the UI.
Product name vs repo/assembly branding (2026-09-14)
The product is llamacasty (llamacasty.com, llamachile.tube, llgit.llamachile.tube). The
repo path, csproj AssemblyName/RootNamespace, DB/log paths (%APPDATA%\ytLlive\...),
and most internal namings are the legacy ytLive/ytLlive — a re-brand that never renamed
the internals. Treat the two as separate: external/user-facing language says "llamacasty",
code/assembly/repo names stay ytLive. If a full internal re-brand is ever done, this note
and the UI locator (Premium/wordmark) are the checklists; it is NOT a goal pre-1.0.
Say "LlamaCasty" when reporting to the creator — never "ytLive" in prose. ytLive is
the internal repo/assembly/namespace name only. Exact current split:
| surface | name | note |
|---|---|---|
| product / site / marketing | LlamaCasty | llamacasty.com, llamachile.tube |
repo dir, csproj, RootNamespace |
ytLive |
230 files: namespace ytLive.* |
AssemblyName → shipped exe |
ytLive |
ytLive.exe |
| app data dir | %APPDATA%\ytLlive |
capital L, drifts from ytLive |
Distribution.md tells customers |
LlamaCasty.exe |
⚠ mismatch vs the real ytLive.exe |
Consequences, so nobody re-hunts them:
- Renaming
<AssemblyName>toLlamaCastyis the cheap win (the customer-visible exe name matches whatDistribution.mdalready promises; namespaces can stayytLive.*). 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%\ytLlivefolder is breaking — it would orphan every existing user's layout DB,Assetsand settings. Never do it casually; needs a migration. - No code compares its own assembly name (no
GetExecutingAssembly().GetName().Namechecks), so the assembly name is otherwise free to change.
AI transparency
This project is built through AI-assisted pair-programming. All code is generated under the supervision of an experienced developer with decades of coding experience. Architecture decisions, product direction, and quality gates are human-owned; AI accelerates implementation. This is a testimonial to successful human-AI collaboration, not autonomous code generation.
Run
First time on a fresh clone (or if ffmpeg/ was deleted) — this is REQUIRED, not optional:
./scripts/fetch-ffmpeg.sh # ~65 MB once; verifies SHA-256, extracts 9 files into ./ffmpeg
FFmpeg is bundled, never downloaded at runtime (Windows Store policy 10.2.2 — the old runtime
download 404'd on 2026-09-01 and would fail certification). The binaries are gitignored because
135 MB of DLLs in git would tax every commit on this 9p tree; the pinned URL + SHA-256 in the script
are the audit trail. ytLive.csproj's GuardBundledFfmpeg target fails the build with that
exact instruction if the folder is missing. FFMPEG_ZIP=<path> reuses a pre-downloaded archive.
# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project:
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
"/mnt/c/Program Files/dotnet/dotnet.exe" run -c Release # the launch command (creator, 2026-10-16): Release, not Debug — perf takes must read Release numbers
Run-mode convention (creator directive 2026-10-16): the creator launches with -c Release
by DEFAULT. Debug runs happen ONLY when the AI explicitly names the Debug command for a
specific reason — never implicitly, and never for reading performance numbers (Debug pixel
paths are several × slower; every take in the 141ms saga read Debug numbers where Release is
the real reading).
Pre-commit gate (write it down — I forgot once): ALWAYS run
./scripts/verify.sh "<file1>" "<file2>" ... (declared scope) before committing. It forces a
clean build (0 warnings), the full test suite (passes only the 2 known pre-existing [FAIL]s), and
the scope check in one shot. An incremental build can report 0 Warning(s) while skipping
recompiles — that's how CS8601 slipped past on 2026-08-29. Never claim 0 warnings from anything but
verify.sh's clean build.
EnableWindowsTargeting=true in ytLive.csproj lets a cold restore work from WSL, but a Linux
dotnet run/build re-downloads 100M+ of windowsdesktop.app.* packs into the Linux NuGet cache
(which lacks them) over the slow 9p /mnt/c bridge — twice, because the WPF _wpftmp generated
project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the
packs resolve from C:\Program Files\dotnet\packs, so the Windows host never re-downloads.
Never use --no-restore right after an interrupted restore — the stale project.assets.json
produces misleading NETSDK1064 "package not found" errors. Running requires Windows anyway.
Tests
xUnit in ytLive.Tests (net8.0-windows10.0.19041.0, matches the app TFM). Run on Windows —
WSL can't run net8.0-windows tests:
dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
Good Dog Rule: ONE integration test per change (no feature branches pre-1.0 — work lands on
main, see AGENTS.md). Current: TokenStore DPAPI
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
frame pump + failure handling (fakes for the WinRT seams), real-MainWindow round-clip
interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip,
LayoutStore background roundtrip, LayoutStore HasBackground roundtrip + v5→v6 non-Live backfill,
ScreenCaptureManager refcount + shared-bitmap + coalescing
(fake IScreenCaptureSource + a real background-STA Dispatcher), BackgroundTests, SceneCatalogTests,
the TASK 25 dirty-layout heal (BackgroundHealIntegrationTests), WebcamSafeguardTests,
SceneCompositorTests (full-scene composite + vertical tier), StretchMathTests, FfmpegLocatorTests,
FfmpegEncoderTests, FramePumpTests, the TASK 8/11/12 audio chain (AudioPipelineTests + MasterLimiter),
the Socials fediverse-heal roundtrip, AboutHubTests, NotificationAreaIntegrationTests (TASK 24),
GlobalHotkeyTests + HotkeyConfigTests (TASK 20), WebcamMenuGateTests (TASK 26), ChatLayerGateTests
(TASK 27), BroadcastPullOutTests (TASK 29), DefaultRecordFolder fallback (TASK 30), WebView2ManagerTests
(TASK 17), RecordingFileTests + OnAirSignTests (TASK 18), SessionTeardownTests +
WebcamOutputKeyTests + GameMeterHonestyTests (2026-09-01 recording-verification fixes) — ZERO
known failures as of 2026-09-01. AudioPipelineTests 25/25 green in 26ms —
the "known failing" Mix_HonorsProviderGains… and the class's notorious STANDALONE HANG shared
one root cause: TASK 22's _delayedMix (nullable, never initialized) was dereferenced
(delayed.Length on null) every live-mix tick — a swallowed NRE starved the pipe the tests
read and flooded startup.log. Test was right, code drifted from the map's own contract
(loopbackGain = GameAudioVolume — now honored; the "unity because loopback scales with the
endpoint" assumption was disproven by the creator's 20%-volume meter observation). The former
RoundClip "known failure" (stale-test layers: namescoped FindName + VisualTreeHelper.HitTest
where UIElement.InputHitTest models input — see MyMistakes) was fixed the same day.
Per-class runs through the Windows dotnet.exe host execute the RealApp/MainWindow suites fine;
only the FULL-suite run still hangs (WASAPI teardown, pre-existing) — and the whole-suite
"247 total" era count is stale; trust per-class results.
Reward-event capture (monetization awareness, see the Monetization section) will add its integration
tests here when it ships: one real chat-poll payload containing all seven reward event types →
assert the persisted canonical RewardEvent rows (type + amount/currency/tier/memberLevel/participant
ids) round-trip into SQLite.
Launch POSITION is derived, never restored (2026-10-04)
Creator ruling: the window must open in the upper-left of its screen with ~75px padding, because
the restore path was putting it below the fold. MainWindow.RestoreWindowGeometry now ignores the
saved position entirely and calls the pure TopLeftPlacement(workArea, w, h, padding), which takes
the work-area origin plus LaunchPadding = 75 and clamps so the window is never pushed off-screen:
a window that fits stays wholly visible, one too big to fit falls back to the work-area origin.
LaunchPadding is in device-independent units, so it scales with DPI instead of being a fixed
physical pixel count.
Size and maximized state are still persisted and restored — only position was dropped. The
clamp still applies to size: saved values are Math.Clamped to [MinWidth, work.Width], which is why
a saved 800×500 comes back as 1366×768 (the XAML MinWidth/MinHeight). Reverting this is
reverting the one block in MainWindow.xaml.cs; the saved values are still written, so nothing is lost.
Why the old clamp was wrong (do not "fix" it back): it tolerated Left as low as
work.Left - width + 120 and Top up to work.Bottom - 80 — ranges wide enough to leave the window
almost entirely off-screen while still looking like a successful clamp. A clamp that permits the
window to be ~unreachable is worse than no clamp, because it reads as a guarantee it does not keep.
BroadcastPullOutTests.Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote (renamed
from ..._WindowRestores_Clamped_...) is the integration guard — it asserts the constructor applies
the derived placement for both an on-screen and a wildly off-screen (-99999) saved position.
Placement math itself is covered by WindowPlacementTests (pure function, no HWND needed).
Real-MainWindow tests MUST be hermetic (DB pollution bug)
The integration test boots a real MainWindow → MainViewModel → real LayoutStore
(%APPDATA%\ytLlive\ytLlive.db). Shutdown() on close saves the layout (full rewrite:
DELETE all scenes/sources, re-insert), so any source a test adds would be persisted over the
user's real ones — this happened and wiped the real webcam source (DeviceId replaced by the
test's fake test-camera). Rule: a test that constructs MainWindow MUST first set
MainViewModel.LayoutPathOverride to a temp DB path and reset it (plus
SqliteConnection.ClearAllPools() + delete) in finally. The seam is
internal static string? LayoutPathOverride (line ~529 in MainViewModel.cs),
ytLive.csproj has InternalsVisibleTo("ytLive.Tests").
The layout DB is a full rewrite per save (delete all, re-insert from memory), so
save/load round trips are exact: an element removed in the UI (RemoveElement →
scene.Elements.Remove → OnElementsChanged → debounced ScheduleSave, plus Shutdown on
close) does not come back after reload (LayoutStorePersistenceTests guards this).
Architecture
C# / WPF (.NET 8) following MVVM:
| Path | Role |
|---|---|
Models/ |
Plain data types — Scene, Source (incl. ClipShape, IsMirrored, VideoImageSource), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, Socials (SocialService enum + SocialEntry/SocialsConfig + SocialServiceIcons) — the social bar |
ViewModels/ |
MainViewModel — public partial class, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see ViewModels/index.md); Chat.cs is a thin delegating facade over Services/ChatOverlayLayer.cs (Commit G, first true decomposition), and now also over Services/AlertOverlayLayer.cs (TASK 43 — the native alert box, same true-component pattern); GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, SocialsDialogViewModel |
Services/ |
YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), SocialValidator (ISocialValidator seam + HttpSocialValidator default), webcam: VideoFrame seam + CameraDeviceInfo/ICameraEnumerator/ICameraFrameSource interfaces + MediaCaptureCameraEnumerator/MediaCaptureFrameSource (WinRT) + CameraManager, screen capture: IFullScreenDetector/Win32FullScreenDetector + IScreenCaptureSource/ScreenCaptureFrameSource (WinRT GraphicsCapture) + ScreenCaptureManager + ScreenCaptureSourceFactory + Direct3D11Helper/CaptureInterop (COM bridges), media source: IMediaFrameSource + MediaVideoSource (spawns ffmpeg rawvideo BGRA decode) + MediaVideoSourceManager (refcount-by-path session owner) + pure RawVideoFrameReader + IDecodeProcess/FfmpegDecodeProcess process seam (binary-stdout mirror of IEncoderProcess; see "Media source"), compositor: SceneCompositor + CompositorOptions + pure StretchMath + StaticPixelCache (see docs/ai/frame-pipeline.md → "Scene compositor"), alerts: ChatOverlayLayer + AlertOverlayLayer/AlertRenderer (the native celebration-zone overlay, TASK 43 — see "Native events & alerts"), audio: IAudioSource seam + WasapiLoopbackAudioSource/WasapiMicAudioSource (NAudio WASAPI) + AudioMixer + pure AudioLevelMeter/WaveToFloat/VoiceFilterChain/LowShelfFilter/HighShelfFilter/NoiseGate/Compressor/AutoDucker/AudioRingBuffer/TinyResampler/AudioSyncDelay + MusicPlayer + ITraxPlayer (TRAX toggle seam — FakePlayer in tests; the dot is green/red/gray and the error scrolls terse in the desktop-audio bar; MusicVolume fixed 0.5) + IAudioPipeWriter/NamedPipeAudioWriter (see "Live audio capture"), encoder: IFfmpegEncoder/FfmpegEncoder + IEncoderProcess/FfmpegEncoderProcess + IFfmpegLocator/FfmpegLocator + pure FfmpegArgs/FfmpegProgressParser/FfmpegEncoderPicker + the FramePump frame producer (see "Live encoder" + docs/ai/frame-pipeline.md → "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 notificationsRelayCommandfor all button actions; commands gate on state (e.g. Start only when Offline). TypedCommandParameters — 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;AddSourcestill falls back toEnum.TryParse<SourceType>(..., true)for safety. The webcam item is its ownAddWebcamCommand(it greys out viaCanAddWebcamToActiveSceneand isn't aSourceType— webcams areWebcamSceneConfig, notSourcerows)- 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 viaMeterFillWidth/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 isMath.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)—ToDisplaymaps 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 (AudioLevelis 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, fromPreviewMouseLeftButtonDown/Up+LostMouseCapturehandlers) 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_MouseLeftButtonUpcode-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:MicMutedis read-only, derived fromMicVolume == 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/EndVolumeFlashon 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 inThemes/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 =IAudioSourceseam + NAudioWasapiCapture/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 sameMicSpeaker_MouseLeftButtonUp→ToggleMicMuteCommandhandler. 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 untilIsConnected. 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=#555offline,#22c55elive) is green while actually streaming (IsLive); thePRIVATEbadge (IsLivePrivate) still shows when the broadcast is private. Both sign labels share theStatusSignTextstyle (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 whileIsLive || IsRecording. The connected account's avatar/name shows in the top bar next to the primary button (AccountAvatarUrl/AccountDisplayNameviaSyncConnectedAccount), 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):
MainViewModelis a ~4100-line god-object split into 17 partials — but partials are a myth of decomposition: every partial shares the same class, sameSetPropertystate, same collaborators viaScenes/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.csis the model (extracted fromMainViewModel.Chat.cs194→44): the layer owns the message buffer,ChatBoxRenderer, fade/mock timers, preview renders, and the liveRenderFrame; the VM keeps only the binding surface (ChatMessagesdelegates to_chatLayer.Messagesso XAML + LeftPanelCollectionChangedhold; the two computed gate props stay on the VM because theirOnPropertyChangedis 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 liveRenderFrame, withAdvance(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, countOnPropertyChanged/SetPropertytouches (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/ChatBoxRendereralready 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.csowns the scene collection (Scenes— the ViewModel'sScenesproperty 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 exposeElementKind Kind(Static= images/background art,Dynamic= webcam/live capture/chat/web). The split point is the index of the first dynamic element;SceneGraph.GetBakedBasebakes/caches all static layers below it (keyed by scene id + static element identities), andSceneCompositor.BakeStaticBase/CompositeLayers/theRender(.., staticBase, split)overload composite the dynamic/above-split layers per frame.FramePump.RenderSceneuses the optimized path when aSceneGraphis wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (viaInvalidateBakefrom the VM's element-property and background-heal paths). Defensive deviation from the TASK 31 spec:ChatOverlayLayerkeeps takingIEnumerable<Scene>instead of depending onGetChatBoxes()— 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 becauseBackgroundTests.csunit-testsMainViewModel.EnsureBackgrounddirectly. 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 deriveIsBackgroundfromType(OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows withIsBackground=0heal on load - Element z-order is a unified space, not per-table (2026-09-20):
SourceandWebcamSceneConfiglive in different tables, and a webcam can be interleaved between sources in a scene. Both tables'SortOrderis stamped from the SAME counter — the element's index withinscene.Elements— andLoadmerges 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/MissingScenesonMainViewModel) 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;AddScenerejects non-canonical names. - Theming: all custom styles live in
Themes/Controls.xaml, merged inApp.xaml— never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). Every control in the app carries a theme style (TASK 58, 2026-10-21): an unstyledComboBox/CheckBoxfalls back to the light WPF system template (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with aBackground/Foregroundinstead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now:ComboBox→YtComboBox,CheckBox→YtCheckBox, the dense properties fields →YtTextBoxCompact(BasedOn YtTextBox: 11px, padding 4,2) — no hand-paintedBackground/BorderBrushon a themed control.ytLive.Tests/PropertiesStyleTests.csis the guard: it fails if an unstyled drop-down, check box or properties field comes back — but that guard sees only those three control types, so a picker built as something else (aButton+ a code-builtContextMenu) slipped through it and the creator had to ask twice (TASK 63) - The properties panel is a HIERARCHY (TASK 59, 2026-10-21): a title owns its content, and the content is indented one level (10px) under it — the creator's ruling: "the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur." So the webcam's block reads
Shape(the mirror switch + the shape choice) →Geometry→Border Color→Border Thickness→Background(Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px#a0a0b0like the existing ones. The shape is a CHOICE, not a toggle button:IsRectShape/IsRoundShapeonSceneElementare two settable bools and the panel shows them as twoSegmentToggleoptions (Rect | Circle) with Rect selected by default (an element that never picked a shape is a rect) — the old single button showed the next action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips throughToggleClipShape()and both props are re-raised), not in a WPFRadioButtongroup — a RadioButton group unchecks its siblings by settingIsCheckedin code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard:ytLive.Tests/PropertiesHierarchyTests.cs - One list for the offered colours (TASK 60): the border-colour drop-down builds its menu from
Models/BorderColorPalette.cs(NamedColor(Hex, Name)+NameOf(hex)— empty for a colour outside the palette, "no name" being the honest answer), NOT from a list inside the click handler — the creator asked for a colour NAME next to each hex (#e94560 Crimson), and a hard-codedstring[]cannot carry one. A palette the creator may want to extend belongs in a testable catalogue, with the UI as a consumer. TASK 62: the field that shows the current colour shows the name beside it too (SceneElement.BorderColorName), and the hex field keeps a fixed width so the name stays next to the hex instead of drifting to the row's far edge - A picker IS the app's drop-down (TASK 63, 2026-10-21): the border colour is now a
YtComboBox— the same widget the Display and Font pickers wear — not a...button that built aContextMenuin code. The creator's complaint ("one of the first things we did was define branding. We established look-and-feel standards. And yet you keep ignoring these") had a precise cause: the TASK 58 sweep looked forComboBox/CheckBox/TextBoxtypes, and the border picker was neither — it was a hand-rolledButton+ code-built menu, so it was never in scope, and the guard could not see it. Lesson: a style sweep must ask "is this the app's widget?", not "is this control type themed?" — anything hand-rolled that looks like a picker (a button that opens a menu) is invisible to a type-based guard. The hex field stays (a custom colour can still be typed); the drop-down carries the swatch and the colour's NAME, the rows carry swatch + hex + name. Guard:ytLive.Tests/BorderColorPickerTests.cs - A ComboBox's CLOSED box and its ROWS can differ — only through a template selector (TASK 63):
WPF copies
ItemTemplateintoSelectionBoxItemTemplate, and that property is read-only in C# ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned"), so a plainItemTemplaterenders the SAME content in the closed box and in every row. The one mechanism that tells the two apart is anItemTemplateSelector: WPF calls it with theContentPresenter's templated parent — theComboBoxwhen the closed box renders, theComboBoxItemwhen a row renders.Helpers/ClosedBoxTemplateSelector.csdoes exactly that (ClosedTemplatefor the ComboBox,RowTemplatefor the ComboBoxItem). AComboBoxItem.ContentTemplateset through anItemContainerStyledoes not work: WPF overwrites the container'sContentTemplatewith the ComboBox'sItemTemplate(null) — so use the selector. - The theme's drop-down popup sizes to its CONTENT, not to the ComboBox (measured 2026-10-21):
the
YtComboBoxpopup carries no width binding, so the pop-up is as wide as its widest row (measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped, and the popup may be wider than the closed box. Measure (RenderTargetBitmap/ActualWidthin a throwaway test), don't guess. And aPopup's visual root (PopupRoot) has anullvisual parent (VisualTreeHelper.GetParent(popupRoot) == null) even thoughPopupRootIS aVisual: its only link back to thePopupand the owningComboBoxisLogicalTreeHelper.GetParent. Any ancestor walk used by a window-level click-outside guard (LeftPanel.IsDescendantOf) MUST fall back toLogicalTreeHelper.GetParent(child)whenVisualTreeHelper.GetParent(child)returnsnull, or clicking aComboBoxIteminsidePART_Popuplooks like an outside click and deselectsSelectedElementbefore the item click registers (fixed 2026-10-22). - A field in a dense panel carries a FIXED width (TASK 61): a WPF element sizes itself to its
content, and in a
DockPanelthe last child fills the row — so a row of same-kind fields looks broken unless every field has an explicitWidthAND the row setsLastChildFill="False". The geometry fields are 44px: the measured width of-1920in the app's font at 11px (via WPF'sFormattedText— measure, don't guess) plus the field's padding and border. Guard:ytLive.Tests/GeometryFieldWidthTests.cs - Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → Vertical 1080p60@8 (9:16, 1080×1920). The composition master frame is always 1920×1080 — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered 607×1080 window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. Bottom bar: gear icon far left, stream stats (bitrate/FPS/dropped/duration) centered under preview, resolution dropdown far right. A tooltip explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
- Crash diagnosis:
AppLogwrites startup checkpoints to%APPDATA%\ytLlive\startup.log;App.xaml.cslogsDispatcherUnhandledException/AppDomain.UnhandledException. When WPF won't run from WSL, this log is how you find the failure (it caught theMenuItemRole.SeparatorXAML crash and the ComboBox SelectionBoxItem bug). The log is SIZE-CAPPED (TASK 55, 2026-10-22): at 5 MBstartup.logrolls tostartup.1.log(one rolled copy kept) — the policy lives inHelpers/LogRotation.csas a pure function over a path, soAppLog's 156-call-site surface never changes and the rule is testable on its own. Guard:ytLive.Tests/AppLogRotationTests.cs
⚠️ Known issue: screens/layers settings audit — RETIRED INTO THE GOLD PASS (2026-08-22, dispositioned 2026-09-01)
The AI hallucinated through multiple commits that night on background/scene property placement, repeatedly misreading the spec and introducing bugs. Specifically: moved properties to the wrong superclass level, used new instead of override breaking WPF bindings, removed working code, and added UI elements that weren't spec'd. The audit was never run since. The creator ruled (2026-09-01): it is a going-gold requirement, not session debt — the fine-tooth-comb pass (every screen's Background layer properties, pill toggle persistence, context menu visibility, "Add Layer"/"(+)" menu items) is a named checklist item under TASK 36 (gold pass) and this landmine is closed here.
Current limitations / TODOs
Helpers/OAuthCredentials.cscontains the real ClientId/ClientSecret. Auth is complete and the session persists via Windows DPAPI (Helpers/TokenStore.cs→%APPDATA%\ytLlive\ytLlive.auth, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives inside the Start Stream dialog (two-state flow — no separate Connect button). Sign-out is explicit only, and that means Logout — NOT Change Account:StopStream()does NOT clear the session — TASK 18's 2026-08-29 reversal ("stopping a recording leaves the creator signed in") — the older "graceful End signs out" line here was stale and is corrected against the code (2026-09-01).YouTubeAuthServicetakes an optionalHttpClient+sessionChanged+openBrowsercallback (test seams + save hook; services are still constructed inMainViewModel). Change Account must never sign out first (fixed 2026-09-27): it used toawait SignOutYouTubeAsync(); await SignInAsync();, and sinceSignOutYouTubeAsync→TokenStore.Clear()deletesytLlive.auth, cancelling in the Google chooser left the creator logged out of a working account. It is now justawait SignInAsync()— success overwrites the token via thesessionChangedhook, cancel/failure leaves the session untouched. The OAuth flow also releases its loopback callback port on retry: abandoning the browser leaves the attempt holdinglocalhost:8765for its 5-minute timeout, so a newAuthenticateAsynccancels and awaits the abandoned attempt before binding, serialised by aSemaphoreSlim(_authGate) — without that gate, two overlapping calls both see a free port and race to bind it (the exact failure the takeover prevents)- Google Cloud project ownership (recorded 2026-09-27, do not re-hunt): OAuth ClientId is
198374416003-s8tm3rb5csh91quvlg6mnec6srmrr6md.apps.googleusercontent.com, so the Cloud project number is198374416003. That project is owned by thellamachile.shop@gmail.com("gramps") Google account. ⛔ This is NOT the same account as the YouTube channel a creator signs into the app with — console access and channel identity are separate logins, and assuming they are the same was the trap that stalled the search. Direct link that resolves it in one hop:https://console.cloud.google.com/apis/credentials?project=198374416003. If the picker shows no match, it defaults to the "Recent" tab — switch to "All" before concluding the project is missing. - Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state persists (LayoutStore Settings table —
LicenseKey/LicenseValidatedAt/IsPremium, 14-day offline grace) YouTubeStreamServicemanages the variable reusable stream (shipped 2026-08-16):GetOrCreateReusableStreamAsynclistsliveStreams?mine=trueand reuses the existingcdn.isReusablestream, creating it only on first use (resolution=variable,frameRate=variable); the stream is cached viaLayoutStore(SaveReusableStream/LoadReusableStream, Settings table) and bound at broadcast insert (boundStreamId). Health (shipped 2026-08-16):GetStreamHealthAsync(streamId)pollsliveStreams?part=statusforhealthStatus+configurationIssues[]→StreamHealth; the real API nests them understatus.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 withrequires an element of type 'String'); banner decision in pureStreamHealthReporter- 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 StreamConfigdefaults (TargetBitrate=6000,Resolution="1920x1080") are stale — the live dropdown drivesStreamHealth.CurrentBitrate/FPSinstead- v1 task queue (2026-08-19): tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) shipped — steps 1-7 (Velopack bootstrap, PolarLicenseService, license entry UI, LayoutStore entitlement, BrandFlash toggle, startup re-validation, PremiumUrl wired); Velopack update URL pending:
- TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates
- TASK 19/23 merged: Control Surface UX — director's control room, transitions (Cut/Fade/Move), thumbnails above central monitor, edit mode offline only, right panel eliminated
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
- Broadcast metadata pull-out + launch geometry — shipped 2026-08-24 ("Text" tab on the Live screen, see its section below)
- TASK 21: Media source (video file playback) — starting soon videos, BRB loops
- TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards
- These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in
TASKS.md.
Control Surface UX (TASK 19/23 — shipped 2026-08-24, verified by code survey)
The app is a director's control surface, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor.
Layout: Two-column. Left panel = one panel, two states. Center = thumbnails above central monitor.
- Left panel: When offline (edit mode) → LAYERS + PROPERTIES (same as current, minus SCREENS). When live/recording → YouTube Live Chat (entire panel replaced, never coexists with layers/properties). Mode is implicit (derived from
StreamStatus), no toggle button. - Thumbnails: Five scene previews above the central monitor, 16:9, drag-reorderable. Click = stage (offline) or transition (live). Active scene gets brand-red glow border.
- Central monitor: Shows staged scene (offline, editable in edit mode) or live output (live, read-only). During transitions: compositor-blended animation.
- Right panel: Eliminated. Chat moved to left panel (live mode).
- Edit gating:
IsEditMode=StreamStatus == Offline && !IsRecording. When live/recording: all drag/resize, context menus, right-click property adjustments disabled. Gun safety — no accidental edits mid-stream.
Scene reference split: ActiveScene → StagedScene (central monitor target, editable offline) + LiveScene (encoder output). Go Live sets LiveScene = StagedScene. Thumbnail click sets StagedScene (offline) or fires transition (live).
Transitions: TransitionService (new file) — Cut/Fade/Move with configurable duration (default 300ms). SceneCompositor.RenderBlend() blends two scenes' frames. FramePump feeds LiveScene to encoder; during transitions, feeds blended frames. Go Live always = Cut (clean start).
Thumbnails: Scene.Snapshot property. SceneCompositor.Render() at 320×180 → WriteableBitmap. Only staged scene has live backdrop render; other 4 = static snapshots.
Thumbnails: Scene.Snapshot property. Fill = per-scene tile artwork first — each canonical scene's ThumbnailAssetId (an Asset-table image, the creator's CRT-styled renders seeded at heal into the internal DB), else background art; minis never render live captures. The scene currently staged shows the shared on-air image (OnAirImageAssetId setting → Asset row, seed path MainViewModel.OnAirSeedPath) in its tile and it follows the scene swap — "the on-air screen jumps around the top bar" (user ruling 2026-10-10). The Live screen's tile only shows the green placeholder when it is staged and no on-air art is stamped. The strip labels are FontSize 20 (user ruling 2026-10-10); border = #e94560 when staged, #22c55e when live. The preview's own live signal is the outer PreviewGlow red border — NOT the 6px green #22c55e rectangle that previously sat in the BackdropLayer (removed 2026-10-10).
Global hotkeys (TASK 20 — step 1 shipped 2026-08-24)
Services/GlobalHotkeys.cs:HotkeyIdenum (9 actions),IHotkeyRegistrarseam,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.OnSourceInitializedcreates the manager and forwardsActivated→MainViewModel.HandleHotkey; windowCloseddetaches (unregister all).MainViewModel.HandleHotkeymaps ids toTransitionToScene/start-end commands (honoringCanExecute)/mute toggles/TraxLeftClick.- Tests:
RegistrarOverridestatic seam mirrorsLayoutPathOverride. Landmine: forcing a WPF window's handle withWindowInteropHelper.Handledoes NOT raiseSourceInitializedand returns Zero pre-show — useEnsureHandle()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>, orpicker:<displayname>. The Live row is a realSourceofType DisplayCapture, inserted first (MainViewModel.EnsureBackground(scene), internal static — also used by theStagedScenesetter 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 inRemoveElement; the element template setsIsHitTestVisible=falsefor backgrounds; the row's remove button and "Remove Source" menu item are hidden).Scene.HasBackground(persisted) is true for all five canonical scenes (seeSceneCatalog.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 ownIsBackgroundviaHelpers/AllTrueToVisibilityConverter). Non-Live screens keep the properties panel pill ("Use default background") + Browse for custom art. - Settings hygiene:
LayoutStore.Savepurges orphanedBackgroundUseDefault_{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):
ActiveBackgroundImagerenders aboveBackgroundImage, 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 showsDisplaySource(capture, falling back to its AssetId art). Flavor-blindIsBackgroundlookups are for persistence/heal/minis — not this overlay. - Detection (event-driven):
Win32FullScreenDetectorhooksSetWinEventHook(EVENT_SYSTEM_FOREGROUND)— fires on the UI thread whenever the foreground window changes. The callback runs the sameGetForegroundWindow+DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)+MonitorFromWindow+GetMonitorInfocheck; a window is full-screen when its frame covers all four monitor edges; own process is excluded; monitor index =EnumDisplayMonitorsenumeration order (the same orderScreenCaptureSourceFactoryuses to map index→HMONITOR viaWin32FullScreenDetector.GetMonitorHandle).IFullScreenDetectoralso exposesGetDisplays()(DisplayInfo: index/name/resolution/bounds/IsPrimary, friendly name viaEnumDisplayDevices) +PrimaryMonitorIndex()for the in-app "Capture Display" submenu +FullscreenMonitorChangedevent +StartWatching()/StopWatching(). At launchReacquireScreenCapturesheals backgrounds (HealBackgrounds()), then keys the Live background to the full-screen game's monitor; when no game is detected,ResolveAutoCaptureKey()returnsnull— staleCaptureKeyvalues are cleared, no capture session is started, andSource.DisplaySourcefalls back to_imageSource(the static asset on the backdrop element).StartWatching()is called at the end ofReacquireScreenCaptures.OnFullscreenMonitorChangedauto-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 +Activatedhandler) reads the current foreground directly as a manual fallback. Deduplication:_lastReportedMonitorprevents redundant redesignates from rapid foreground changes. - Capture (WinRT GraphicsCapture):
ScreenCaptureFrameSourcecreates a free-threadedDirect3D11CaptureFramePool(2 buffers,B8G8R8A8UIntNormalized) +GraphicsCaptureSession; frames →SoftwareBitmap.CreateCopyFromSurfaceAsync(alpha ignored) →VideoFrame(BGRA8), bytes read viaWindowsRuntimeMarshal.TryGetDataUnsafe(the same CsWinRT-safe read the webcam path uses) — theIMemoryBufferByteAccessComImport cast threwInvalid caston every frame under CsWinRT, which floodedstartup.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 asSceneCompositor.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 cadencetelemetry). 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.LatestFramesurvives 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 tostartup.logevery 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:
ScreenCaptureManagermirrorsCameraManager— refcounted by target key, one sharedWriteableBitmapper key, dispatcher-coalesced latest-frame copies,PreviewBitmapChanged/CaptureFailedevents,ReleaseAllAsyncon re-designation.ScreenCaptureSourceFactory.Resolve(key)parses the key into a source;PickAsync()shows the OSGraphicsCapturePicker("Change Capture…", owner window set via theIInitializeWithWindowComImport) and returns a transientpicker: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, anEnumWindowspass filtered likeWin32FullScreenDetector: visible, non-cloaked viaDWMWA_CLOAKED, non-empty rect, other-process, titled). Picking one runsRedesignateBackgroundAsync("window:0x{hwnd:X}")— the same full-bleed layer-0 capture path as the game/desktop, keyed straight intoScreenCaptureSourceFactory(window:already resolved there). Session-scoped, never persisted:window:(andpicker:) keys areIsTransientCaptureKey—ReacquireScreenCapturesstrips 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:OnFullscreenMonitorChangedskips re-designation whileIsAliveWindowPin(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-runsReacquireScreenCaptures→ auto-fallback (fullscreen game → desktop → static art). Seam:IWindowEnumerator+WindowInfo(Label = "Title — Process"),WindowEnumeratorOverridestatic test seam mirrorsCameraEnumeratorOverride. - CsWinRT projection gaps hand-rolled:
Windows.Graphics.Direct3D11.Direct3D11Helperis not projected, soDirect3D11HelperP/Invokesd3d11.dll!D3D11CreateDevice(hardware, BGRA_SUPPORT, explicit 11.1-first feature array) → QIIDXGIDevice→ the WinRT interop exportCreateDirect3D11DeviceFromDXGIDevice→MarshalInterface<IDirect3DDevice>.FromAbi(one shared device per process). Do not switch back to the QI-for-IDirect3DDxgiInterfaceAccesstrick: the raw D3D11 device no longer exposes that interface on newer Windows (verified E_NOINTERFACE on build 26200, hardware and WARP alike) whileCreateDirect3D11DeviceFromDXGIDevicekeeps working.IInitializeWithWindoware ComImports inCaptureInterop.cs. All WinRT projections were verified by reflection against the builtMicrosoft.Windows.SDK.NET.dllbefore 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 (
FullscreenMonitorChangedevent +RefreshBackdropAutoCapture) re-target the backdrop, never pace it. The lag is inherited:Windows.Graphics.Captureis 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_framePendinggate drops frames during stalls, and the manager'sDispatcherPriority.Rendercopies 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 —
ShowPreviewPlaceholderalso checksBackgroundImage(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/ICameraFrameSourceinterfaces. Tests inject fakes; screen capture and background removal later feed the same seam. - CPU-first:
MediaCaptureInitializationSettings { MemoryPreference = Cpu, StreamingCaptureMode = Video, SharingMode = SharedReadOnly }, frames pulled viaCreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8)— the pipeline does any format conversion, so everyFrameArrivedyields a ready BGRA8SoftwareBitmap(bytes read viaWindowsRuntimeMarshal.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 aMediaFrameReader(StartAsync→OutputFormatNotSupported; the MJPG pipeline exposes NV12, not BGRA) — exactly what a browser grabbing the webcam produces. When the app can't re-negotiate (ourSetMediaStreamPropertiesAsyncrefuses 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 (seeMyMistakes.md→ WINRT RESOURCE-ALLOCATION RECIPE). Anything not Bgra8 is converted inOnFrameArrived, 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) orVideoRecord, not blindly the first preview source. If a camera exposes neither, the failure names the device and the stream types it does expose.SharedReadOnlylets the capture coexist with other apps that share the camera. - Known failure: NVIDIA Broadcast — it opens the physical webcam exclusively, so
InitializeAsyncfails 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 (LogitechVID_046D&PID_082D). Correction (2026-10-05): Windows Studio Effects is NOT a second option of this kind — it runs inside the physical camera's own driver, so it never appears as a selectable device. Only tools that install a virtual camera (NVIDIA Broadcast, XSplit VCam) do. "Pick the vcam" is a user action in the camera picker, never an automatic substitution: creator ruling 2026-10-05 — the app must not bypass the user's explicit camera choice, so there is deliberately no fallback-camera setting. - TFM:
net8.0-windows10.0.19041.0(app + tests) pulls the WinRT projection from the SDK reference packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy camera toggle still applies).EnableWindowsTargetingkeeps WSL builds working. - One webcam, many scenes (schema v3) — app-level default (2026-09-17): a singleton
Webcamrow holds the identity (Id/DeviceId/Name); each scene gets its ownWebcamSceneConfig(position/size/clip/mirror/border/IsVisible).Scene.Elementsholds images (Source) and, at most once, the webcam (WebcamSceneConfig);Scene.WebcamConfigis the accessor.CameraManagerrefcounts capture sessions byDeviceId(a session starts atRefCount = 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 viaSwapWebcamIdentityAsync(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 withWebcamAddToolTip= "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;_webcamis 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 afterLoadLayout, stored asWebcamStartupValidationTaskso tests await it) enumerates the OS once and tri-states on the count: 0 → app runs on, layer inactive, no alarm; 1 → attemptCameraManager.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 leavesRefCount = 1and 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 (CanAddWebcamtrue); an already-loaded identity is never overwritten. Test seams mirrorLayoutPathOverride:CameraEnumeratorOverride/CameraFrameSourceFactoryOverrideletWebcamStartupResourceTestsdrive 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 —CameraConflictProbereads only running process names, no device handles; contention symptoms fit shared-mode/bandwidth just as well (seeMyMistakes.md). The YouTube Chat layer follows the same one-per-layout rule (TASK 27):CanAddYouTubeChatgreys out the (+) item while any scene carries a ChatBox source (raised on staging + elements change;AddSourcerefuses a second as defense in depth). Chat layers created before the "YouTube Chat" default still carried the legacy name "Chat" (commit65641d8era) — 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 publicRectWidth/RectHeightbefore 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-timeHealLegacySquareRect(load only) widens a pre-v4Traditionalconfig that ended up square to 16:9 (keeps height; Round and explicit rect dims are untouched). - Device swap / layout reload:
ChangeWebcamAsync(picker) andReacquireWebcam(after load) release the old device withReleaseAllAsync— 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) — thenAcquireAsyncthe new device once per config. - Shared bitmap, coalesced updates: one
WriteableBitmapper active camera, created on the UI thread at the device's frame size (first frame), forwarded to everyWebcamSceneConfig.VideoImageSourceviaPreviewBitmapChanged. Frames arrive on a worker thread;CameraManagercoalesces onto the dispatcher (at most one pending copy per session, atRenderpriority, always copying the latest frame) so a 60fps device never drowns the render thread. - Webcam added mid-session must get the live frames:
PreviewBitmapChangedfires 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;AddWebcamToActiveSceneAsyncassigns it to the new config right beforeAcquireAsync, andReacquireWebcamre-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#RRGGBBor""=none,BorderOpacity0–1,BorderWidth0–20,BorderAnimationNone|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 anEllipseGeometryclip (Center=0.5,0.5RadiusX/Y=0.5), inside aViewbox Stretch="Uniform"holding a1x1Grid, so it renders as a true circle (diameter = the shorter element dimension) instead of an oval stretched to the element rect — the traditionalImagekeepsUniformToFillover the full rect. The clip is geometry, not anImageBrush: a brush re-rasterizes the frequently-updatedWriteableBitmapper frame on the render thread, which is what made the live webcam crawl while round. The Round border is a centeredEllipseatWidth/Height = RoundBorderSize. - Resizing locks to a square (
_resizeAspect = 1) whileClipShape == 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).RoundBorderSizefollows the clamped height.WebcamSafeguardTestsguards the clamp. - Hit-testing: a
GridwithoutBackgroundonly hit-tests where its children draw, so clicks in the empty corners of a round clip fell through toWindow_PreviewMouseLeftButtonDownand deselected the element — making the corner handle ungrabbable. The element Grid carriesBackground="Transparent"(whole rect draggable; the empty canvas Grid uses the same trick for right-click Show Webcam) and theSelectionOverlay(dashed border + corner dot) isIsHitTestVisible="False"so it never intercepts the click. Two things make the webcam menu work: (1) theContextMenupins its ownDataContexttoPlacementTarget.DataContext— aContextMenuisn't in the visual tree, so without it the Click-handlerDataContext:patterns (and the IsChecked/slider bindings) silently fail; (2)Themes/Controls.xamlships a full darkMenuItemtemplate —PART_Popup(submenu popups), a popupItemsPresenter(the Border Opacity/Thickness sliders live in Items, so they render in a hover flyout), a✓checkmark column, and a›arrow driven byHasItems. An earlier bareBorder + Headertemplate dropped all three: submenus never opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the Click handlers were fine.
- The Round webcam is an
- GPU posture: webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred to the encoder task.
- Background removal — REVERSED, now IN for v1 (creator ruling 2026-10-08, TASK 51). The
2026-10-05 "permanently out" ruling stood on "an in-app model means a runtime, a GPU/driver matrix,
and per-scene tuning". The delivery shape changed: a ~430 KB Apache-2.0 model embedded in the exe,
run on the CPU by onnxruntime — no download, no GPU requirement, no driver matrix. NVIDIA's Video
Effects SDK stays ruled out (RTX-only + ~1 GB user-side download). See "Webcam background
effects" below and
TASKS/task-51-webcam-bg-effects.md.
Webcam background effects (TASK 51 — built 2026-10-09; jitter fix 2026-10-09)
- Model: Google's MediaPipe Selfie Segmenter, landscape (Apache-2.0, model card 2021-05-06),
official file
storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/ float16/latest/selfie_segmenter_landscape.tflite. Converted to ONNX by our owntools/selfie-seg/convert_tflite_to_onnx.py(the graph ends in MediaPipe's custom opConvolution2DTransposeBias, which tf2onnx/tflite2onnx can't convert — 11 op types, so a direct translation was smaller than any workaround). Verified against Google's own runtime:verify_against_mediapipe.pyfeeds the same frame to MediaPipe'sImageSegmenter(original .tflite) and onnxruntime (our .onnx): max |diff| 5e-5, 100% binary agreement on two images. ⛔ Do NOT swap in PINTO model-zoo #109 (it's ML Kit's model, extracted — not Apache provenance). obs-backgroundremoval is GPL-3.0 — license-compatible since LlamaCasty went GPL-3.0 (2026-10-09), but none of its code is used; if any is ever borrowed, it needs its copyright notice kept. - Contract: input
[1,144,256,3]RGB /255 NHWC → output[1,144,256,1]person probability. The whole frame is stretched (not cropped) to 256×144, so the mask maps back by normalized coordinates. - Shipping:
Assets/Models/selfie_segmenter_landscape.onnxis an EmbeddedResource (in git, inside the exe — nothing to lose next to it, nothing fetched at runtime). Runtime =Microsoft.ML.OnnxRuntime1.31.0 (MIT), CPU execution provider, 2 intra-op threads. Note the nativeonnxruntime.dllis ~17 MB (win-x64); an RID-less build copies every RID's native folder — the MSIX publish (TASK 48) must pin the RID so only win-x64 ships. - Code (
Services/BackgroundEffects/):IPersonSegmenterseam (Segment(VideoFrame) → SegmentationMask?, tests inject fakes);OnnxPersonSegmenter(lazy session from the embedded resource; never throws — a load/run failure logs once and returns null forever after);BackgroundEffectProcessor.Apply(frame, BackgroundEffect)—None= same instance (true passthrough),Remove= copy with alpha = bilinear-upscaled mask × source alpha, RGB untouched,IsOpaque = false(the compositor's straight-alpha blend shows the layers below), strength = edge feather (mask band 0.5±(0.05+0.45·s) → alpha);Blur(slice 2) = background downscaled 4× by box average, 3 separable box-blur passes and bilinear-upsampled, blended by the mask — person pixels (mask = 1) are copied untouched, opacity preserved. Blur radius is quadratic (BlurRadius(strength): 1→6 small px, default 0.5 = 2 — the "blur heavy" fix 2026-10-09; the old LINEAR 1→12 small px ≈ 48 full px was far beyond what the established tools use: StreamFX defaults its Box blur Size to 5 and its author stops downscale-based blurs around 8 px). The mask is computed once per frame (TrySegment) and rendered per effect (Render), so several variants share one inference. No mask / segmenter exception → passthrough + one log line, never a dropped layer. - Config (slice 3): per scene, on
WebcamSceneConfig—BackgroundEffect+BackgroundStrength(0..1, default 0.5), persisted as two additive guarded columns onWebcamSceneConfig(BackgroundEffect TEXT DEFAULT 'None',BackgroundStrength REAL DEFAULT 0.5, no version bump — the TASK 47 convention).RemoveBackground/BlurBackgroundare the radio-pill bools the UI binds. - Hookup (slice 4) — the ONE processing point is
CameraManager. Effects are per scene but the camera session is shared, so the manager renders variants keyed byWebcamEffectKey(effect, strength rounded to 0.01).MainViewModel.SyncWebcamEffects()declares the union of every config's key (SetRequestedEffects; unused variants are dropped so their CPU stops) and points each config'sVideoImageSourceat its variant's bitmap; it re-runs on load, add, remove, device swap, and anyBackgroundEffect/BackgroundStrengthchange (configs are watched viaPropertyChanged, which alsoScheduleSaves). Per raw frame a background worker (latest-frame-drop, one at a time) segments ONCE and renders every variant; each variant keeps its own latest frame (the resolver:GetLatestFrame(device, key)) and WriteableBitmap (EffectPreviewBitmapChangedon first creation). The raw path is untouched — a slow/failed model never stalls the plain webcam — and an effect variant shows nothing until its first processed frame (Remove never flashes the real room). The compositor needed no change: its sampler already honours source alpha. - Temporal smoothing (2026-10-09, creator check: "removal is jittery"): the segmenter classifies
every frame independently, so a verdict that wavers at an edge makes the cut-out flutter. Fix:
MaskSmootherblends each raw mask with the previous smoothed one — 0.75 × new + 0.25 × previous (CurrentWeight, internal const). State is perCameraSession(one per-camera worker), NOT in the segmenter — theOnnxPersonSegmenteris shared app-wide and its bundled-model contract test pins the raw per-frame output. A null mask (model hiccup) resets history; the first frame and any size change pass through un-blended; the returned mask never aliases the caller's array and two buffers ping-pong (no per-frame allocation — a 256×144 mask is 147 KB, past the 85 KB LOH). Weights come from the established tools, not a guess: OBS's obs-backgroundremoval smoke-teststemporal_smooth_factorat its 0.85 default, Jitsi's webcam background filter (jitsi/jitsi-meet#17080) found 0.7/0.3 transformed best — a midpoint that still reacts to real movement; 0.5/0.5 leaves a smearing edge ghost. The blur "heavy" follow-up was fixed the same day: quadratic radius, 1→6 small px (see theBlurbullet above). - Perf (synthetic 1280×720, Debug build): segment 1.6 ms, Remove 3.3 ms, Blur 17.5 ms per frame
(Release is several × faster). Pixel loops are row-parallel, capped at min(4, cores/2); the
bilinear upsampling is separable (horizontal rows precomputed per mask/small row, one vertical lerp
per pixel) — the first naive per-pixel 2-D bilinear cost 46/80 ms.
CameraManagerlogs the real average once per session after 120 processed frames (startup.log: "background effects avg …").MainViewModel.PersonSegmenterOverrideis the hermetic-test seam. - UI (slice 5, creator spec 2026-10-09): the webcam's properties in the Layers panel
(
Controls/LeftPanel.xaml) gain a Background section — twoPillToggleswitches, Remove and Blur, bound toRemoveBackground/BlurBackground(radio-exclusive: one on turns the other off, turning the active one off = None) and a Strength slider (0..1, 5% snaps, visible only while an effect is on; Blur = amount, Remove = edge softness). Dragging the slider never blanks the webcam: a new strength variant inherits the dropped same-effect variant's last frame + bitmap (CameraManager.SyncVariants), and the VM keeps the shown bitmap until the new one renders. - Tests:
BackgroundEffectEngineTests— the Remove integration test (fake segmenter), and a model contract test that runs the BUNDLED model through the C# preprocessing on a fixture and matches the MediaPipe-produced reference mask (≥99.5% agreement; fixture = MediaPipe's public test imagemozart_square.jpg, public-domain painting, inytLive.Tests/Fixtures/).CameraManagerTests— the ONE integration test for the jitter fix (aSteppingMaskSegmenterwhose two consecutive masks disagree at an edge; asserts the second frame keeps a partial alpha where the raw mask would have cut to 0: x=6 → 58 upstairs of the raw 0, person core 255, background 0).Blur_default_strength_is_gentle_and_full_strength_is_strongpins the quadratic radius mapping (BlurRadius(0.5)= 2,BlurRadius(1)= 6) and the behaviour at a step edge: default smears light ~9px into the dark half vs ~26px at full strength.
Vertical (9:16) tier camera placement (TASK 57 — "vv" Units 1+2, built 2026-10-21)
The problem. The vertical tier (1080×1920) is a centred band crop of the SAME 1920×1080
master: OutputRect* = x 656.25, y 0, w 607.5, h 1080. A camera placed for 16:9 (the default
webcam sits at x≈1408) lands outside the cropped window entirely — in the vertical stream the
creator is simply not in the picture.
The rule (creator's locked table, 2026-10-17). Each scene keeps its own vertical placement — position, size and shape — and switching back to 16:9 restores the desktop layout exactly:
| mode | shape | size | position |
|---|---|---|---|
| 16:9 | as today | as today | as today |
| vv · Live | circle or rect (the creator's choice, per scene) | small tile | one of six spots: top/bottom × left/centre/right |
| vv · Chat | rect only (the circle option is not offered) | the small tile (normal), or enlarged to fill one 607×540 area | the same six spots; enlarged fills the top or the bottom area |
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
Design — geometry only, no render-path change. The preview and the compositor both read the
element's OWN X/Y/Width/Height + ClipShape, so the tier switch swaps the live
geometry instead of teaching the render path about tiers:
WebcamSceneConfigcarries the vertical placement (VerticalX/Y/Width/Height/VerticalClipShapeVerticalRectWidth/Height— seven additive DB columns, NULL = "this scene has no vertical placement yet") and the parked 16:9 layout (LandscapeX/…/LandscapeRectHeight, transient).
BeginVerticalLayout(...)parks the live 16:9 layout and brings the vertical one in;EndVerticalLayout()commits the vertical placement back into the stored slot and restores the parked 16:9 layout. The live geometry IS the render input → the compositor andPreviewPane.xamlare untouched.- The save keeps the X/Y/W/H/ClipShape columns as the 16:9 layout (while the vertical tier is
live, the parked values are written there and the live ones into the
Vertical*columns). SceneElement.X/Y/Width/Heightbecame virtual so the webcam can CONSTRAIN every write (drag, resize handle, properties fields) into the band — one choke point instead of three writers re-implementing the rule. Outside the band = invisible in the vertical stream.
The band and the six spots (Models/VerticalCameraLayout.cs, pure math): the band is
656.25…1263.75 × 0…1080; it splits into two 607×540 areas (top/bottom), each with three columns
(left/centre/right) → six spots. A tile snaps to the spot its centre falls in, inset 32 master
px from the band's outer edge (the app's own placement margin) and never outside the band. The
default tile is 270×152 master px (480×270 in the 1080×1920 output) in the bottom-right
spot — the corner the 16:9 default webcam uses. The Live scene gets that tile automatically the
first time the vertical tier is picked (otherwise the vertical stream has no camera at all); the
other scenes keep their 16:9 placement until the creator drags them into the band.
The Chat scene's own rules (Unit 2). In the vertical tier the Chat camera is rect only:
the circle is not offered (MainViewModel.CanToggleClipShape is false → the Shape button
disappears) and the model refuses it too — WebcamSceneConfig.ClipShape is overridden, so a
locked camera cannot become Round by ANY writer (the toggle, the button, the placement swap). The
lock is WebcamSceneConfig.VerticalShapeLocked, set by the VM per scene and cleared before the
16:9 layout comes back, so the desktop shape is restored. Its size is discrete: the small tile
(normal) or one whole 607×540 area (enlarged) — the resize handle snaps to the nearer of the two
(VerticalCameraLayout.SizeFor), and an enlarged camera fills the area its centre is in (top
or bottom). The Chat scene also gets its tile automatically the first time the vertical tier is
picked (like Live) — otherwise the vertical chat stream has no camera at all.
Manual entry (creator rule): the webcam's Layers properties gained a Geometry section (X/Y/W/H) — it edits the LIVE placement, i.e. the one the current tier shows. The webcam's geometry setters clamp it into the band, so a typed value can never push the camera out of the cropped window.
Derivative work. OBS has no built-in way to store a scene's layout per aspect ratio — the maintainers' answer is "create a new empty scene and add the scene you want to send as source" (obsproject.com/forum/threads/…161307), and Streamyard's dual-aspect guide says the same ("no built-in way to store separate layouts per aspect ratio, so you rely on manual workarounds and plugins" — streamyard.com/blog/dual-aspect-ratio-streaming-tool). Our per-scene, per-tier stored placement, resolved on the tier change, is the step past that.
Tests. VerticalWebcamLayoutTests — the ONE integration test for Unit 1: the vertical tier
is picked → the Live camera comes INTO the band as a small tile → dragged to the top-right it
lands top-right inside the band → back to 16:9 the desktop layout is restored exactly → and both
placements survive a save + reload. (The store writes the webcam rows only when the app has a
webcam identity — the config rows hang off it — so the test supplies one.)
VerticalChatCameraLayoutTests — the ONE integration test for Unit 2: the vertical tier is picked
→ the Chat camera comes into the band as a small RECT tile → the circle option is not offered and
cannot be set even on the model → dragged up and enlarged it fills the top area exactly → back
to 16:9 the desktop layout is restored exactly and the circle option is offered again.
Known trap (see MyMistakes.md): a second properties panel bound to SelectedElement.IsWebcam
broke WebcamBackgroundPillTests — that test finds the webcam's panel by binding text, so a
new panel must use a different binding (IsWebcamSelected) or the selector matches twice.
Rendering budget, measurement & the compositor — moved
Frame cadence, per-element composite timing, the scene dump, the render budget and the
SceneCompositor design live in docs/ai/frame-pipeline.md —
read it before changing the render loop, the compositor, or anything that moves per-frame cost.
Invariants that hold even if you never open that file:
- Every global overlay must be composited on EVERY render path (pump, preview/thumbnails, scene
dump —
SceneCompositor.Overlayis shared for all three). A feature wired into one path is silently absent from the output; that bug (flash + social bar "preview-only forever", 2026-09-26) is why the rule exists. VideoFrame.Epochis not optional. A producer that recycles buffers MUST bumpEpochper frame, orFramePump's buffer-identity paste cache and the full-render signature serve stale pixels.BrandFlashPresenterreuses one 8MB master buffer and stampsEpochper read for exactly this reason.StripPoolowns four dedicatedBelowNormalbackground threads for strip-parallel compositing; strips below 64 rows run inline. Never demote threads insideParallel.For— it runs on thread-pool threads and starves the scheduler (that mistake once broke audio-mix timing).- Read render numbers in Release only. Debug pixel paths are several× slower; a Release 1080p composite measures ~1.0–1.1ms against the 16.67ms budget, and only Release takes are evidence.
FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10; rewritten to BUNDLED 2026-09-28, plan in TASKS.md)
The encoder's one external dependency is ffmpeg.exe. It is BUNDLED — never downloaded, never
fetched at runtime. IFfmpegLocator resolves an absolute path on demand: the bundle first
(<AppContext.BaseDirectory>\ffmpeg\ffmpeg.exe), then PATH as the escape hatch for someone who
deliberately installed their own. The order is the opposite of the original ("the user's own install
wins") on purpose: a PATH ffmpeg may be a different major version or a GPL build, and the
bundle is the one that was pinned, license-vetted and tested. If neither hits, it throws a loud
IOException naming the missing folder and the fetch script — the old "found something on disk"
cache path is gone deliberately, because silently satisfying a missing bundle with a stale copy is
exactly the silent drift a certification-gated dependency must not have.
The bundle is not in git (135 MB of incompressible DLLs would tax every commit on this
9p-bridged tree). scripts/fetch-ffmpeg.sh populates ./ffmpeg from a pinned month-end BtbN
LGPL-shared win64 artifact, verifies SHA-256 before extracting, and is idempotent; set
FFMPEG_ZIP=<path> to reuse a pre-downloaded archive. ytLive.csproj copies ffmpeg\** to the
output (CopyToPublishDirectory="Always" for the MSIX package) and the GuardBundledFfmpeg target
fails the build if the folder is absent, so a fresh clone finds out before the creator presses
Record rather than during a take. ffmpeg.exe + ffprobe.exe + the libav*.dll family must stay in
one folder — Windows resolves a shared build's DLLs from the executable's own directory.
ffplay.exe (18 MB) is deliberately not shipped; nothing calls it.
BtbN LGPL-shared (not gyan.dev, not static): it drops GPL-only libx264/x265 while keeping
NVENC/QSV/AMF + libopenh264 + native AAC, and dynamic linking means LGPL compliance is "license text +
source offer" with no static-relink (§6) material — see the Licensing guardrails below. libopenh264
is compiled into avcodec-63.dll (verified 2026-09-28: the encoder is registered there, and there
is no separate OpenH264 binary to ship), so Cisco's BSD terms + patent grant ride along inside item 1
of THIRD-PARTY-NOTICES.txt. Pin: autobuild-2026-08-31-13-27 (month-end on purpose — BtbN keeps
~14 dailies but month-end builds ~2 years; a daily pin is what 404'd, see below).
⛔ Why this was rewritten (2026-09-27 → shipped 2026-09-28). The pinned-download design was a defect, not a cold-start edge case. It failed in production on 2026-09-01 — the previous daily pin aged out of BtbN's 14-day retention and the download 404'd on the creator's first real recording attempt. Two facts made "the pin is a const, just bump it" wrong:
- Microsoft Store policy 10.2.2 forbids dynamic code inclusion. Downloading an executable from the internet and running it is the exact pattern the policy names. An MSIX submission is rejected over it.
- Bumping the pin is whack-a-mole against a retention window we do not control.
Bundling deletes the startup network dependency entirely. The LGPL-shared build choice is
unaffected — dynamic linking was chosen for LGPL §6 compliance ("license text + source offer"),
not to enable downloading. Analysis: TASKS/research-store-certification.md §4.
Guardrail: FfmpegLocatorTests.TheLocatorCarriesNoNetworkCapability asserts the locator's
source contains no HttpClient/URL/downloader at all, so the policy cannot rot back.
Every other third-party binary already ships via NuGet — ffmpeg was the only download-and-execute.
Verified 2026-09-28: e_sqlite3.dll and WebView2Loader.dll land in runtimes\win-x64\native\
(SQLitePCLRaw + WebView2 packages) and NAudio/Notification.Wpf/Microsoft.Data.Sqlite/ProtectedData
are managed. Two gaps remain and are not fixed by bundling: the WebView2 Evergreen runtime is
a machine prerequisite (a manifest framework-dependency + graceful-failure path, TASK 48 item 2), and
Velopack's updater downloads and executes — its teardown is a compliance action, not just cleanup.
Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
The encoder is a thin orchestrator over ffmpeg.exe — no H.264/AAC code in the app. It spawns the
subprocess (path from IFfmpegLocator), feeds raw BGRA master frames into stdin, and parses -stats
stderr lines into StreamHealth (bitrate/FPS/duration, dropped-from-frame-count). FfmpegEncoder
(IFfmpegEncoder seam) holds: StartAsync (locate → probe -encoders → spawn → stderr loop →
drain loop), SubmitFrameAsync (slice 10: bounded-queue ENQUEUE — never a pipe write; the drain task
owns stdin writes), StopAsync (flush queue → stdin EOF → ffmpeg
finalizes + exits by itself; a 10s watchdog kills it), Dispose (force-kill + wait), and the
HealthUpdated/ProcessFailed events. Pattern: the encoder never touches Process — it drives the
IEncoderProcess seam (FfmpegEncoderProcess wraps the real Process, redirected stdin/stdout/stderr
- exit control); a
Func<IEncoderProcess>factory + the locator are constructor-injected, so the integration test fakes the whole subprocess (probe + encoder) with a Channel-backedTextReaderwhoseComplete()is EOF (null), never aChannelClosedException.
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 viaEncoderOptions.AudioPipeName; replaced the old-f lavfi -i anullsrcsilence 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.Buildemits one self-contained block per output, each its own-map 0:v -map 1:a+ codec tags (AddVideoTagshelper). Stream block =-f flv <rtmpUrl>; record block =-f mp4 <RecordPath>.EncoderOptions.StreamEnabled/RecordEnabled/RecordPathgate 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.StartAsyncthrows unless at least one output is enabled. Encoder choice is probed from the binary's-encoderslisting (FfmpegEncoderPicker, pure): NVENC → QSV → AMF → OpenH264 fallback, never libx264 (GPL; see Licensing).EncoderOptions.VideoEncoderforces one and skips the probe.EncoderOptionsalso carries W×H/FPS/bitrate from the quality tier andGopSize= FPS×4. Constructed byMainViewModelsince ship step 5 (new FfmpegEncoder(new FfmpegLocator())), driven by theFramePumpbelow.
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): pillsRecordPillOn/OnAirPillOnstay 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 byRaiseTopBarModes()). The old sign-lights + two pill toggles + status dot + PRIVATE/TEST chips are gone; the mode is one segmented REC|ON-AIR switch (SegmentToggle/SegmentLabelstyles), and "going back" is one tap on the other segment. Idle worlds: Record = switch + "Start Recording" (PrimaryStartButtonLabelnames 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 andShowAvatarButton= 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.ShowPrimaryStartButtonremains the always-idle face.StopStreamclears both pills (see "Stop ends everything; failures roll back" below).
- "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):
- Filenames (pure
Services/RecordingFile): auto-namety-<yyyyMMdd>-<HHmm start>-<fff>-0000.mp4at start; rename-on-stop toty-…-<hh2mm2 actual length>.mp4(FinalizeRecordingAsyncafter the pump stops & the file closes), numeric-2/-3suffix on collision (UniquePath). Length comes from_liveElapsed, which the session timer walks whileIsLive || IsRecording. The<fff>millisecond segment is load-bearing, not decoration (2026-09-27): both names derive from_recordStartTime, so it survives the rename, and without it two instances started in the same minute compute the same start path and both ffmpeg processes write one file — which is exactly the dev multi-instance case (Helpers/InstanceProfile, one instance recording the other). - Private broadcasts end the same way (creator ruling 2026-10-09). After
StopStream's transition(complete),OfferToKeepPrivateBroadcastAsyncreads the broadcast's privacy from YouTube (GetBroadcastPrivacyAsync— visibility can change mid-stream, local state isn't trusted) and, only if it isprivate, showsKeepBroadcastDialog(repo root): Enter = Keep (IsDefault), Escape / Delete / the X = delete viaDeleteFinishedBroadcastAsync—videos.delete?id=<broadcastId>(a live broadcast's id IS its video id; 204), falling back toliveBroadcasts.deleteon 404. An unreadable status asks nothing and deletes nothing; a failed delete toasts that the video is still on YouTube. Decision logic =MainViewModel.ClosePrivateBroadcastAsync(internal static, tested inPrivateBroadcastCloseOutTests);KeepBroadcastPromptOverrideis the dialog seam. - Save dialog: Cancel DISCARDS (creator ruling 2026-09-26). The
RenameRecordingDialogresult is a two-outcome decision, and it lives in the extractedMainViewModel.CompleteRecordingSave(startPath, dir, autoStem, chosenStem, creatorSaved)(internal, soytLive.Testscan drive it against real files without a frame pump — seeytLive.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 returnsDiscardFailedand 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.Moveto 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.MoveUNCONDITIONALLY and only overwritestemwhen the dialog returned true, soShowDialog() == falsefell 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 viaChooseRecordFolderCommand(OpenFolderDialog), persisted throughLayoutStore.Load/SaveRecordFolder(RecordFolderkey). - Explicit sign-out only:
StopStreamno longer clears the session/token. Sign out via Logout only — Change Account re-authenticates in place and deliberately does not sign out first (2026-09-27; signing out first deletedytLlive.authbefore the browser even opened, so a cancelled switch lost a working session). Stopping a recording leaves the creator signed in. - Stop ends everything; failures roll back (2026-09-01):
StopStreamalso 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 fullStopStreamteardown instead of leaving a limbo (StreamStatus.ErrorwithIsRecording=trueover a dead encoder — the first-launch zombie recording). Toast copy distinguishes "Recording stopped" from "stream pipeline stopped";AudioMixer.StopLiveis idempotent-safe for rollbacks that never reached StartLive. Seams:OnFramePumpFailed+IsRecordingsetter made internal (test-only, InternalsVisibleTo). Test:SessionTeardownTests. EncoderOptions.StreamEnabled/RecordEnabled/RecordPathgate 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 —BuildEncoderOptionsalways 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(forWaveOutEvent), MIT — item 9 inTHIRD-PARTY-NOTICES.txt):WasapiLoopbackAudioSource=WasapiLoopbackCaptureon the default render device;WasapiMicAudioSource=WasapiCapturewith the device resolved byFriendlyNamematchingMicSourceName(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 overridableGetDefaultMixFormatonWasapiCapture, so there is no forced 48 kHz path; the mixer'sTinyResamplernormalizes 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 providerFunc<string?>at eachStart, so a mic picked mid-session takes effect immediately (the mixer restarts the mic on pick). Both sources raiseStartedonce their capture loop actually begins — the mixer turns that intoMicConnected. AudioMixerowns both sources;StartMicCaptureAsyncstarts the mixer once at startup andShutdowndisposes it — NOT go-live — so the meters preview live. Mic samples: downmix to mono →TinyResampler→VoiceFilterChainper sample (bassLowShelfFilter120 Hz +4 dB → trebleHighShelfFilter8 kHz +3 dB →NoiseGate(threshold 0.005, hysteresis 0.5, attack 0.5, release 0.0005) →SpeechLeveler(2026-10-09: automatic gain steering speech RMS to −20 dBFS over a 0.3 s window, gain −6..+20 dB, rises ≤10 dB/s / falls ≤30 dB/s, learns only while the gate is open and above −54 dBFS so pauses/hum are never pumped) →Compressor(threshold 0.5, ratio 4:1); all pure DSP) → ring buffer → the post-filterAudioLevelMeter(RMS, 0.2 exponential smoothing) →MicLevelChanged→ marshalled to the UI thread →AudioLevel. Loopback samples: resample → ring buffer → second meter →LoopbackLevelChanged→ the game bar'sGameAudioLevel. The raw linear level maps to the meter's display scale byAudioLevelMeter.ToDisplay(−60..0 dBFS → 0..1, honest — the +10 dB display boost was removed 2026-10-09; the mic meter isToDisplay(AudioLevel × MicVolume), true dB math, no ×1.2). Why (2026-10-09): a creator's YouTube upload measured −28.5 LUFS / −8.9 dBFS peak against YouTube's −14 LUFS reference (YouTube only turns loud videos down, never quiet ones up) while the boosted meter looked "dialed in". Nothing in the chain added gain — the compressor folds only above −6 dBFS and the limiter only caps. Re-running that same audio through the new chain (mic volume 0.8): −17.9 LUFS, −0.7 dBFS peak. Recording and streaming share this one mix, so both were (and now are) the same. Measure with the bundled ffmpeg:ffmpeg -i file -af ebur128=peak=true:framelog=quiet -f null -. Mic connection state surfaces as events:MicConnected(sourceStarted),MicFailed(sourceFailed), andRestartMic()re-resolves + restarts just the mic (loopback keeps running). Failures log viaAppLog; a mic failure zeroes the meter, a loopback failure never kills the mic. MeterPushis 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 theIAudioPipeWriterseam) and a live loop task; everymixInterval(default 10 ms)FillAndMixreads 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 viaFunc<double>seams (micGain = MicMuted ? 0 : MicVolume;loopbackGain = GameMuted ? 0 : GameAudioVolume, × duck), mixes mono mic + stereo loopback into interleaved stereo, runs theMasterLimiter(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 throughAudioGainProvider(Services/Audio/AudioGainProvider.cs) — the VM hands itsMicGain/LoopbackGainFuncs 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.FfmpegEncoderitself is untouched; the VM owns the pipe lifecycle. - Audio sync offset (TASK 22): after the limiter,
FillAndMixroutes the whole interleaved stereo mix through aAudioSyncDelay(Services/Audio/AudioSyncDelay.cs) — a pure delay line whose offset comes from aFunc<int> syncOffsetMsseam (the VM's globalAudioSyncOffsetMs, −500..+500 ms, persisted asAudio.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 (seeAudioMixer.StartLive; the delay line stays clamped 0..500 internally — negative bypasses it entirely). The advance is armed once atStartLive(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 viaIsEditMode) 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= NAudioMediaFoundationReader(mp3/wav/m4a) →VolumeWaveProvider16at the hardcoded 0.20 bed (no slider) →WaveOutEventon the default device, looping on any clean natural end (PlaybackStoppedwithe.Exception == null→ rewind + replay; the earlierPosition >= Lengthcheck was unreliable forMediaFoundationReaderand let tracks play once then stop). The desktop/game bar's mute togglesLocalGain(0 or 1;UpdateTraxLocalGainruns on everyGameMutedchange 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 viaAudioGainProvider.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 viaOpenFileDialogwhen 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-rowMusic(TrackPath/IsEnabled) inLayoutStore. Known wrinkle (out of scope, future feature): YouTube mutes VODs carrying copyrighted music — "music on live, off VOD" is tabled.- ⛔ The load is behind a bounded probe, because a share that is gone must never
freeze startup (2026-10-04).
LoadLayout→LoadTraxused to callMusicPlayer.Loadsynchronously on the UI thread, andnew MediaFoundationReader(path)does not fail fast on a dead SMB session — it burns the whole ARP/TCP/SMB timeout (~40s, measured) and then throws0x80070035. Measured:OnStartup08:42:59 →MainWindow loaded08:43:43, with 42 of those 44s insideLoadLayouton that one track. The rule now: a probe may time out; only a bind is allowed to be on the creator's path.IsTrackReachableAsync(path, budget, File.Exists)runs the probe on a thread-pool thread under aTask.WhenAnyceiling (TraxProbeBudget= 2s) and reports offline/missing/slow as a normal state, not an error. Only a probe that passed gets the real bind, and that bind stays on the UI thread with every otherMusicPlayermutation, so no new cross-thread race is introduced. The orphan probe thread stays parked until SMB gives up — deliberate: it is not the thread anyone waits on. Same principle covers the webcam: a device held by another app fails fast (sharing violation) but a virtual-camera driver can stall enumeration, so device probes belong off the startup path too, not behind a long timeout.
- ⛔ The load is behind a bounded probe, because a share that is gone must never
freeze startup (2026-10-04).
- 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'sSettingskey/value table storesMicSourceName(saved on pick viaSaveMicSourceName), and the VM restores it at construction BEFORE the mixer's firstStart— 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
IGameAudioDetectorshow/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;GameMeterHonestyTestsguards 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,WaveFormatExtensiblewith 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 testMix_HonorsProviderGains_AndGameMute_KillsTheLoopbackproving the provider gains reach the pipe and mute silences the loopback).
The live frame pipeline — moved
FramePump and the whole path from frame producer to encoder (pacing, pool/paste caches, slice
history, teardown, health stats) live in
docs/ai/frame-pipeline.md.
Invariants that hold even if you never open that file:
- rawvideo is stamped by ARRIVAL, never by a timestamp. ffmpeg assigns pts from frame order at
the declared fps, so supply rate = output speed. The pump duplicates the previous frame on a missed
deadline (correct duration) and never resets
nextTickto wall-now — rebasing turns a hitch into a silent time-lapse (take-2 lesson, 2026-09-01). - Submit never blocks. The encoder queue is bounded and a drain task owns the stdin write +
flush; on overflow the newest frame is dropped and counted (
DroppedFrames) — overload costs content, never a frozen or smeared session. - Stop order:
StopAsyncstops the encoder first — flushing the pending queue to EOF so the file finalizes with no accepted frame lost — before awaiting the pump loop; the reverse order deadlocked on pipe backpressure. - Judge output in Release, by the burned
_outputIndex— the 6-digit counter burned into each frame is what lets a recorded file be audited for drops after the fact; stats alone are not proof.
Social bar (TASK 6 — shipped 2026-08-12, plan in TASKS.md; bar bug-fix branch 2026-08-13)
A global bar layer (never a Source, no sources-list row) that sits over the bottom or top of the
output and carries the creator's social links — content-sized, centered, GREEN glow when ON, position is a
click-toggle top ⇄ bottom (default BOTTOM, persisted SocialBarPosition). Models/Socials.cs: SocialService enum
(YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/
Snapchat/Reddit/WhatsApp/Telegram/Link/Website/Fediverse), SocialEntry (Service/Handle/ProfileUrl/
FediverseSoftware), SocialsConfig (Entries + BarPosition + BarEnabled; BarJustify dropped,
column back-compat). Configured in the "Social Media Site Promotion" dialog (SocialsDialog.xaml +
ViewModels/SocialsDialogViewModel, WPF-free, injected ISocialValidator + sign-in/sign-out fakes):
ON/OFF bar switch (schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle;
signed-out → sign-in gate → OAuth; delete → confirm sign-out), row 2 free, rows 3–6 lock icons on
freemium. Service detection (SocialServiceIcons.DetectService): URL domain / fediverse @user@domain →
Fediverse / bare→Website. Validation (Services/SocialValidator.cs, ISocialValidator seam +
HttpSocialValidator default): async GET of the canonical profile URL; 200/redirect = valid, 404/failure =
rejected. Fediverse additionally does a best-effort nodeinfo lookup (/.well-known/nodeinfo →
software.name, stored in SocialEntry.FediverseSoftware / the SocialEntry.Software column,
column-presence migration, no version bump) so the entry shows the instance's real logo
(LogoDataForFediverse: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse
honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodeinfo failure still validates
(glyph falls back). If the identity domain's nodeinfo is blocked (YunoHost SSO gates
/.well-known/nodeinfo behind the login page) but the bare root 302s to the real instance, the lookup
follows the root redirect and asks the resolved host. Cancel is a hard stop: LookupAsync takes a
CancellationToken (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations
never touch slot state).
Bar bug-fix branch (2026-08-13) — three changes:
- Positioning is a click-toggle (KISS) — the original drag set a local
Canvas.SetTopvalue 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+ClearValueon 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, andSocialBarSnapis gone. - Fediverse software self-heal — the DB row
@gramps@llamachile.tubehadSoftware = NULLbecause nodeinfo was only ever asked of the identity domain (a landing page; the real instance ismastodon.llamachile.tube).HttpSocialValidator.ResolveFediverseSoftwareAsyncnow probes well-known subdomains (mastodon.→social.→ …FediverseSubdomainCandidates) when the identity domain and its redirect both come up empty, under a ~15s linked-CTS budget.MainViewModelruns the staticHealFediverseSoftwareAsyncoff the UI thread on layout load, applies matches via the dispatcher, and saves;SocialEntry.FediverseSoftwareis settable and raisesLogoData, so the icon updates in place. - The bar renders on the live output —
Compositor/SocialBarRenderer.csrasterizes 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
NotificationAreaXAML control registers itself with the manager onLoaded; routing matches the area'sNameagainst the request'sAreaName— our host isx:Name="ToastArea"in MainWindow's root grid (last child = topmost z-order,Grid.RowSpan=4, bottom-right above the footer viaMargin="0,0,12,96", outside the preview Viewbox). An unknown AreaName silently drops the toast. NeverExpires()setsExpirationTime = 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+ theCardstint table: slate info#3a3b52, green success#1d5c38, amber warning#b8860b, dark-red error#8f1f1f— matching the health-banner language). - Calls marshal to the captured Dispatcher (
_dispatcher.Invoke) — encoder/pump events fire off-thread.
Migrated MessageBoxes: both webcam-acquire warnings, image-read warning, sign-in unsuccessful/failed. Promoted from log-only (each keeps its AppLog line): go-live prep failures ×3 (reusable stream, broadcast insert, prep exception), frame-pump death while live, mic-missing at startup, premium lapse, saved-session refresh failure (sign-out notice). Deliberately left log-only: offline license re-validation skip (would spam every launch). Still modal by design: SocialsDialog's slot-delete YesNo confirm.
Licensing — do not violate (GPL-3.0 open source since 2026-10-09; see LICENSE, ADDITIONAL-TERMS.md, THIRD-PARTY-NOTICES.txt)
LlamaCasty is free software under GPL-3.0-or-later (creator rulings 2026-10-09 — free forever, public
repo, "GPL-3 with attribution"). Copyright (C) 2026 Gramps. ADDITIONAL-TERMS.md carries the three
§7 terms: 7(b) every copy keeps "Based on LlamaCasty by Gramps — https://llamacasty.com" in its
About screen; 7(c) modified versions are marked and renamed; 7(e) no trademark rights. The
icon + logo files (Assets/llamacasty-icon.png/.ico, Assets/llamacasty-logo.jpg) are all
rights reserved, outside the GPL — verbatim copies may carry them, forks must replace them (this is
what keeps the merch brand protected). The scene backgrounds + default alert video are AI-generated
placeholders (creator: made "with the intent of individual creators to immediately replace with
their own images") — they go out under the GPL with the code, no reservation. LICENSE + ADDITIONAL-TERMS.md ship beside the exe
(csproj None items); the About panel shows THIRD-PARTY-NOTICES.txt, whose header carries the
LlamaCasty license notice (GPL §5(d) "Appropriate Legal Notices"). Source offer (§6):
https://llgit.llamachile.tube/gramps/LlamaCasty (the Gitea repo; creator toggles it public — the
old gramps/ytLlive path 301-redirects there), recorded in the notices header + README.
Every bundled component must stay GPL-3.0-compatible (MIT/BSD/Apache-2.0/LGPL/public domain all are; GPL-2.0-only and proprietary are not; Apache-2.0 is fine with GPL-3 but NOT with GPL-2). The rules below predate the GPL switch; where their reason was "proprietary product" it is noted, but the rule itself still stands for its other reasons. NEVER:
- Swap the FFmpeg pin casually — the BtbN
lgpl-sharedpin is the verified build. A GPL FFmpeg build would now be license-compatible (the old "GPL contaminates the paid product" reason is gone), but changing builds means re-verifying encoders, and libx264 carries no H.264 patent grant (Cisco's covers OpenH264). Keeplgpl-sharedunless a task says otherwise. - Distribute the static lgpl build — LGPLv2.1 §6 wants relinkable object files for static linking.
The shared (dynamic-DLL) build sidesteps that: compliance is "license text + source offer +
unmodified binaries". The pin is
lgpl-shared; when the pin is refreshed, keep the shared variant. - Use BtbN's
nonfreevariant — 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.txtfrom 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
latestBtbN release tag floats. Only immutable autobuild tags give a reproducible source offer. Record the tag + variant beside the URL (TASKS.md) every time the pin moves. - Use non-CC0 icon art — the social bar's bundled SVG logo path data comes from Simple Icons
(CC0 1.0, public domain — see
THIRD-PARTY-NOTICES.txt). Replacing or adding logos must stay CC0 or another public-domain source; a logo asset under an incompatible or attribution-heavy license would complicate every redistribution. The app's icon + logo (Assets/llamacasty-icon.*,Assets/llamacasty-logo.jpg) are the creator's own art — all rights reserved, outside the GPL (ADDITIONAL-TERMS.md). - Ship an ML model without clean provenance — the background-effects model (TASK 51) is Google's
official MediaPipe Selfie Segmenter download (Apache-2.0, model card), converted by our own script.
Model-zoo re-hosts (e.g. PINTO #109 = ML Kit's model, extracted) are off-limits. Apache-2.0 §4
obligations (license notice + "we converted it") live in
THIRD-PARTY-NOTICES.txt. - Forget the v1 license-texts gate —
THIRD-PARTY-NOTICES.txtlinks 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.txtfrom 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 inMainViewModel(PremiumUrl,CoffeeUrl,ChannelUrl,MastodonUrl); filling in the billing URL lights up the premium button. Do not reintroduce an "open the notices in Notepad" button.
Design Principle
This software is so intuitive that even the most right-brained person can easily intuit and use it.
Apply this to every UI decision:
- One-click go-live with working defaults
- Prefilled YouTube defaults (RTMP URL, bitrate, resolution, latency)
- Visual/drag-and-drop scene building over property panels
- Every action produces a visible outcome — no dead ends
- "We're not them" (2026-09-01): assume the creator's hardware is mediocre, because it is. Every feature spends the machine's budget once — never twice for the same result (record OR stream, not both; one reusable stream; one webcam; two mixer inputs). If a feature only sings on a high-end chassis, it doesn't ship — the OBS escape hatch is open by design.
Monetization (design decision — the branding flash is the sword)
⛔ Superseded 2026-10-08/09 — free forever, no branding on the stream. The credit is now OFF by default:
MainViewModel.ShowBrandFlash(internal static, defaultfalse) is the master switch, andBrandFlashEnabled => ShowBrandFlash && !IsPremiumfeeds the presenter at construction and on every licence change. The code (BrandFlashPresenter, the FramePumpbrandFlash:seam, the preview layer) is kept, not deleted — creator ruling: "don't delete it, just make it conditionally displayed".BrandFlashOutputTestsopt in by setting the switch before constructing aMainWindow;Credit_IsOffByDefault_EvenOnTheFreeTierguards the default. Everything below is the historical paid-tier design.
Full pricing and the Year 1 revenue forecast in MONETIZATION.md (gitignored). ⚠️ No Polar
product, discount code, or license key exists any more — nothing bills today; Polar is dormant.
Brand palette and assets in MARCOM.md (gitignored). This section covers the in-app model.
Free forever: all features unlocked for everyone — no feature lock, no paid tier, and since 2026-10-08 no watermark either (the credit is OFF by default; see the banner above). No creator will tolerate a watermark, and as a good-guy developer we give everyone complete access so no one can call us crooked. Money comes from gratuity/merch, not gating (TASK 54).
- Free: a periodic branding credit — "made with LlamaCasty!" in full-frame neon (white core,
feathered red/blue halo with the channels split in opposite directions), at a random position
inside the frame on every presentation (single pre-measured line, so it never wraps; travel range
is
0 … 1920−textWidth), held 2s (500ms fade in, 1000ms full, 500ms fade out), first credit ~5s after go-live then everyrand(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 asServices/Compositor/BrandFlashPresenter.cs(raster + envelope + placement + licence gate), published to both the frame pump (FramePump(brandFlash:)→ the compositor's per-frameflashFrameslot) and the preview (BrandFlashElement, bound toBrandFlashImageSource) from the sameVideoFrame, so the preview cannot drift from the broadcast. Deliberately NOT theflashFrameparameter ofSceneGraph.GetBakedBase—SceneRegion.Matchescompares 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 isStart()ed once in theMainViewModelctor, not fromUpdateLiveVisuals(). It used to be gated onIsLive, 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 sameFramePump(Streaming.Operations.cs:68and:199both callStartAsyncwith the samebrandFlash:delegate) — verified, not assumed; there is no second encoder path needing a "redirect". The licence gate needs no live branch:IsPremium's setter pushesEnabledfrom anywhere. Consequence of app-lifetime — the premium edge restarts the timer.Enabled = falsestops theDispatcherTimer(cutting the advertisement mid-credit). BecauseStart()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(IsCadenceTimerEnabledis the test seam). Test-arithmetic trap, hit again 2026-09-26:Advance(5.1)in ONE call cannot observe a credit —Advanceboth 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 (theStephelpers inBrandFlashOutputTests). - Paid: branding flash removed.
BrandFlashEnabledis now a derived, non-assignable!IsPremiumand the presenter's ownEnabledgate is re-checked on every frame, so the entitlement changing mid-credit cuts the advertisement on the next tick rather than letting it finish. That's it. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording — everything is free. HowIsPremiumis obtained is a billing concern and has changed twice; what it gates has not. - Pricing: ⛔ SUPERSEDED 2026-10-08 by the free-forever ruling (TASK 54). Retained as
reasoning only:
$10/mosubscription or$400lifetime, no annual, ruled 2026-09-27 and overtaken ten days later. If a paid tier ever returns, these are the numbers to argue from. HowIsPremiumwould be obtained is a billing concern; what it gates is not. - Billing: ⏸ dormant — no paid tier exists (free-forever ruling 2026-10-08, TASK 54). The
Store IAP plan of 2026-09-27 was never implemented (
Windows.Services.Storeappears nowhere in the repo) and was withdrawn with the Store route on 2026-10-22. If a paid tier ever returns, the provider is Polar — merchant of record for VAT/sales tax, 5% + $0.50 Starter, $50 already paid;Distribution.md§4.2/§4.5 holds the design. ⚠️ No Polar product, org ID, key prefix, or discount code exists (d105dfa1-…,c05fb364-…,LCYT-,LLAMAFOUNDER/LLAMA50, the customer-portal validate endpoint, and the 14-day offline-grace machine are all gone). - Support (creator's model, corrected 2026-09-01): support = email + GitHub issues — the once-planned in-app bug-reporter is out of product (TASKS.md → closed list). Most queries are how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with emergency patches; not a 24/7 service promise. There is no licence chatter at all — the app is free, there are no keys, and no user sees licence UI. TASK 36 item 5 (expiry reminder) is struck.
Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1 ruling — build order: capture → report → journey → Alerts): the app is monetization-aware by design, for every creator, free and ungated (the only subscriber swap remains the branding-flash removal above). It has three layers, all free — this is the product's differentiator, not a paid tier:
- Reward-event capture (the data foundation). The live-chat poll (
YouTubeChatService.Poll) receives every reward event overliveChat/messagesand — since TASK 43 (2026-09-24) — decodes all six types, not two:superChatEvent,superStickerEvent,newSponsorEvent,membershipGiftingEvent,giftMembershipReceivedEvent,memberMilestoneChatEventland asChatMessage.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 SQLiteRewardEventstable (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-memoryChatMessagefeed. - 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.listfor 90-day public uploads. Every refresh appends aYppSnapshotto SQLite (schema v10) — the history a later slice needs for velocity/ETA. TheauditDetailspart is OUT (fixed 2026-09-23): readding it 403s the WHOLE request — that part alone requires theyoutubepartner-channel-auditscope no app for normal creators should request (the standing flags sat unreadable since slice 1 shipped; the app'syoutube+youtube.force-sslscopes 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 fromstatistics,contentDetailsonly. YPP/API failures log the response body'serror.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, additiveyt-analytics-monetary.readonly) behind theIChannelStatsProviderseam (additive capability, requires an OAuth re-consent). Compliance items no API exposes (2FA, AdSense linkage) are in-app self-reported checkboxes with deep links; channel standing (exposed only via the partner-audit scope) deep-links to the Earn page. Slice 2's once-daily refresh gate + snapshot history were baked into slice 1.
What was rejected: always-on watermark (obscurable — replaced by the flash), hard stream-time cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams routinely run 2-4 hours), soft-limit nagging, freemium feature tiers, and donation-only (relies on the kindness of strangers). Resolution/quality ceilings stay rejected (fixed 2026-09-01, no longer "deferred"): the free tier never loses quality — auto step-down (TASK 33) is a protect the stream feature, never a monetization penalty.
Auth gates Go Live, but not exploration
The app is fully usable without authentication: users can build scenes, add sources, compose previews, and audition the software with zero commitment. But going live requires authentication — it's the one capability gated behind YouTube sign-in. The sign-in should never pressure the user ("sign in (optional)", not a modal wall): the two-state top bar shows Start Stream (offline) / End Stream (live), and the Start Stream dialog hosts the account — a saved session appears as the default with "Change Account"; with none saved, a "Sign in to YouTube" button starts OAuth and the Start button stays disabled until signed in.
Account assumption (do not build an account setup flow)
Connecting uses Google OAuth ("Sign in with Google") to link an existing YouTube creator account. ytLlive never creates or sets up accounts — that is YouTube's job. If the creator has no YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to YouTube" button; going live is unreachable until an account is connected.
YouTube Live API — design constraints (do not violate)
✅ SOLVED 2026-10-04 — the broadcasts never went live because they were never BOUND.
The bug, in one line:
contentDetails.boundStreamIdonliveBroadcasts.insertdoes not bind the broadcast. Binding requires an explicitPOST liveBroadcasts/bind?id=…&streamId=…&part=id.How it was finally proven, after a week of misdiagnosis — the diagnostic line, once its own JSON-nesting bug was fixed (
FirstItem), read:lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING | streamStatus=active activeChat=none cdn=rtmp/variable@variableEvery field is real, and together they are conclusive:
bound=NONEwithautoStart=true. Both live in the samecontentDetailsobject on the insert, so the request was well-formed and accepted — the API took one field and silently ignored the other. That asymmetry is the proof; a malformed request would have failed outright.lifeCycle=created, notready. Google:created= "incomplete settings, so it is not ready to transition to a live or testing status". An unbound broadcast is precisely incomplete.streamStatus=active— the ingest half was never broken. YouTube had an active stream and a broadcast that was not attached to it.The causal chain, complete: no binding →
lifeCycleStatusstuck atcreated→enableAutoStarthas no bound stream to fire from → never live → close-out 403sinvalidTransitionbecausecreated → completeis not a legal edge (onlyready/liveare).Why it survived a week, and the lesson worth keeping: every observable pointed away from the binding. ffmpeg pushed ~99 MB cleanly, the reusable stream went
active, close-out returned a transition error (which reads like a lifecycle-timing problem), and the insert returned a valid broadcast ID (which reads like success). A reusable stream accepts bytes for a broadcast that is not watching it, so a perfectly healthy RTMP session is exactly what an unbound broadcast looks like. Google says it outright in the migration guide: "a liveBroadcast must be bound to exactly one stream before the live broadcast can actually start. The broadcast is not bound to any streams at the time that it's created." That sentence was in the docs I read during the original design and was not applied. Read the failure modes in the docs, not just the happy path.Shipped:
YouTubeStreamService.BindBroadcastAsync, called fromPrepareAndStartLiveAsyncimmediately afterCreateBroadcastand before the pump starts. Failure is fatal to the session (an unbound broadcast can never go live, so a live-looking UI over it is a lie).boundStreamIdwas removed from the insert body and thestreamIdparameter removed fromCreateBroadcast— a parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible. The old testCreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which asserted the disproven behaviour and therefore laundered a guess into a guarantee, is replaced byCreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamIdplusBindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body.Bind failures are not all "sign in again." Google documents six reasons (
insufficientLivePermissions,liveStreamingNotEnabled,liveBroadcastBindingNotAllowed,liveStreamNotFound,liveBroadcastNotFound,internalError, plus rate limiting) and only the first is an auth problem.BindFailureMessagemaps each to its own text;liveBroadcastBindingNotAllowedmatters most, because it means our ordering is wrong and a generic "check the sign-in" would hide a bug in our own code behind a plausible excuse.
These are the hard facts behind every decision. Full list in TASKS.md.
- One-click go-live — never call
transition(live). Insert the broadcast withenableAutoStart=true,enableAutoStop=true,enableMonitorStream=false,selfDeclaredMadeForKids=false,latencyPreference=low. The encoder starting brings YouTube live.enableMonitorStream=falseis what lets us skip the testing stage. - ⛔ CORRECTION (2026-10-04): the push is provably healthy — stop investigating it. The long
test run settles it:
liveBroadcasts.insertat 14:17:04.577, ffmpegOutput #0, flvtortmp://a.rtmp.youtube.com/live2/5f6z-…at 14:17:05.686, and at 14:18:56.473 ffmpeg's own muxing summary readsvideo:101229KiB audio:1419KiB,muxing overhead: 0.202949%— ~99 MB of real video plus audio over 112s, which is exactly what 8000+128 kb/s should produce. No socket error, no 4xx from ingest, no reconnect. The broadcast still never leftready, and close-out earned 403invalidTransitionagain. An earlier 8s run behaved identically, so "YouTube needed longer" is disproven — 112s is longer. The break is between ingest accepted and broadcast told to start, nowhere in the RTMP path. - The binding is the prime suspect, and the app was blind to it.
contentDetails.boundStreamId("the live stream bound to the broadcast") andenableAutoStart("start automatically when you start streaming video on the bound live stream") are both valid on insert, with no documented restriction against a reusable stream. But YouTube's migration guide carries the danger: "a liveBroadcast must be bound to exactly one stream before the live broadcast can actually start. The broadcast is not bound to any streams at the time that it's created." And this failure mode is invisible from the RTMP side, because a reusable stream accepts bytes for an unbound broadcast — ingest succeeding proves nothing. ⇒LogBroadcastDiagnosticsAsyncrides the existing stream-health poll (no second timer) and writes one greppable line:lifeCycleStatus,privacyStatus,recordingStatus,boundStreamIdcompared against the stream id we are actually pushing (BoundMatches→yes/MISMATCH/NO-BINDING/unknown),boundStreamLastUpdateTimeMs,enableAutoStart/Stop,liveChatIdpresence, the stream's ownstatus.streamStatus+activeLiveChatId, and the CDN settings we declared. It is self-limiting —_broadcastSeenLivelatches onlive/liveStarting, so the line shouts only while the broadcast is stuck and goes quiet once healthy. The next run reads this line; it is the whole experiment. Guarded byBroadcastBindingTests.BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush, which pinsunknownfor a missing expectation so an absent stream id can never be misreported as a match.- ⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).
liveBroadcasts.listreturns a list wrapper, so the broadcast lives atitems[0]. The original code descended intoitemsfor the stream but read the broadcast's response root, so every broadcast field came back?— andBoundMatchesthen dutifully reported a confidentboundMatches=NO-BINDINGfrom a parse that had never happened. Three lines of fabricated alarm. Two fixes, both load-bearing:FirstItem()descends intoitems[0]for both halves (and returnsdefaultwhen there is genuinely no item), and the line now printsboundMatches=unreadwhen the broadcast could not be read, so an absent parse can never be reported as a verdict. Guarded byBroadcastBindingTests.FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly. Generalisation: a diagnostic must degrade to "I don't know", never to a confident wrong answer — otherwise one level of JSON nesting manufactures a bug that isn't there. - What the one run that did work proved: the stream half was correct, and it shows
streamStatus=ready→streamStatus=activeabout 30s into the push. So the ingest does register as active server-side, which is a genuine step forward — combined with the 99MB muxed in the earlier run, the stream half of the system is now verified healthy. The broadcast half is still unobserved pending a re-run.
- ⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).
- Visibility picker (TASK 9 item 6) — DELIBERATE TEST-PHASE LOCK, not drift (2026-09-01).
The "always Private" enforcement in
YouTubeStreamService.CreateBroadcaststays while the creator runs multi-month real-world testing: non-private test streams would clutter the channel with VODs highlighting where the product breaks.recordFromStart/DVR stay ON — Private VODs are invisible to subscribers and serve as post-mortem review tapes; bulk-delete pre-GA. The unlock is a deliberate final-pass item in TASK 36 (gold pass), wired with the dialog selection — never opportunistic. The PRIVATE badge keeps showing when the stream is actually private. - ⛔⛔ STOP. The always-Private lock is a DECISION, not a bug, not drift, not an open question.
(Creator ruling, restated 2026-10-04 after being asked about it AGAIN.)
privacyStatus = "private"hardcoded inYouTubeStreamService.CreateBroadcastis exactly right as written. Do not change it, do not "unlock" it, and — this is the part I keep getting wrong — do not raise it as a question, an observation, a suggestion, or a "heads-up". The creator has now answered this more than once.- What it becomes: the hardcoded lock is replaced by a real, user-selectable visibility option in the Go-Live dialog — at v1, not before. Not at 1.0-candidate, not at GA prep, not opportunistically during any other unit.
- Why it exists: pre-v1 test streams would otherwise publish VODs of a product still breaking, in public, to subscribers. Private keeps those tapes usable for post-mortem review while staying invisible to the channel's audience.
- Do not let these look like symptoms of the one they resemble. All are working as designed:
privacy=privatein the broadcast diag line; the PRIVATE badge;recordFromStart/DVR ON; and the absence of any delete call in the codebase (there is no delete feature, by design at this stage — "can we delete the test VODs" is a retired question, answered 2026-10-04:videos.deleteacceptsyoutube.force-sslwhich we already hold, private is a visibility setting not a deletion lock, and anyway a broadcast that never reacheslivenever becomes a VOD at all). UpdateBroadcastis NOT the unlock — and it is also currently DEAD CODE. (Fact corrected 2026-10-04: an earlier version of this note saidCreateBroadcast:112/UpdateBroadcastMetadataAsync, both wrong.) The method that can change a live broadcast's privacy isYouTubeStreamService.UpdateBroadcast(broadcastId, meta), which sendsprivacyStatus = meta.Visibility ?? "private". It has no production caller — the only caller in the repo isBroadcastPullOutTests.cs:120. SoCreateBroadcasthardcoding private is not merely backed up by convention, it is the only thing enforcing it.- ⇒ Is a Go Live video private? YES — verified 2026-10-04, and this answers a standing
creator doubt. Three independent hardcodes (
CreateBroadcast→"private";GoLiveViewModel.Visibility => "Private";BeginTestStream→StreamVisibility = "Private") plus one unreachable method. There is no path by which a broadcast this app creates can become non-private today. The creator's actual concern — "do not populate my channel with videos showing the app at its worst" — is fully met. - ⛔ THE LANDMINE, and the reason the above matters:
LiveBroadcastFormViewModelstill exposesVisibilityOptions = {private, unlisted, public}andUpdateBroadcastwould happily honour it. The v1 visibility option is the first thing that will ever wireUpdateBroadcastup — so v1 is exactly when this guarantee ends, by the creator's own choice. When that happens, private stops being guaranteed and becomes a default the user can change. That is intended at v1; it is a disaster before v1. Do not wireUpdateBroadcastinto any production path before v1, and do not "fix" the dead-code smell by calling it.
- ⛔ The TEST button is HIDDEN at v1 — it exists only because of the lock above (creator
ruling 2026-10-04). This was absent from the map entirely until that date, which is how the
dry-run framing below came to be the only recorded one. Both are true, and the second is the
load-bearing one:
- Its real job is being the guaranteed-private path.
BeginTestStreamhard-setsStreamVisibility = "Private"(MainViewModel.Streaming.Operations.cs:105), so Test can never publish. That is why it exists for the whole pre-v1 phase — a dry run you cannot leak. - At v1 it is redundant and gets hidden, because once visibility is a real dialog option the user picks Private themselves and a separate "safe" button no longer earns its place. Hiding it is part of the v1 visibility work, not a separate cleanup.
- The pre-existing "dry run" framing is now known to be incomplete (
ShowTestButton's own comment calls it "a dry-run of the livestream"). Do not read that comment as the reason the button must persist, and do not re-derive the button's purpose from it. If a dry run is still wanted at v1 it must be re-justified on its own merits — that question is open, and is for v1, not now. - It is a computed predicate, not a setting —
ShowTestButton => ShowStreamActions && IsConnected(MainViewModel.Streaming.cs:182), feeding bothCanStartTestand the XAML visibility. So the v1 hide is a one-line predicate change, not a settings migration. - Not a v1 blocker: the Test route is the same pipeline as Go Live —
BeginTestStream→StartStreamingSession(alsoRecord: false)→PrepareAndStartLiveAsync— so Test and Go Live create identical broadcasts (boundStreamId,enableAutoStart). Test differs only in never recording (alsoRecord: false) and forcing Private. This is why Test is the correct way to run any diagnostic stream, and why a Test result is evidence about Go Live.
- Its real job is being the guaranteed-private path.
- 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.categoryIdis removed fromCreateBroadcast(not aliveBroadcastfield, silently ignored). Monetization enablement (if ever needed) rides the reward-events chain vialiveBroadcasts.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 beforeFramePump.StartAsync, which reads it once). Failure →StreamStatus.Error, never a crash;StopStreamclears_currentBroadcastId. - Variable reusable stream (shipped 2026-08-16) —
GetOrCreateReusableStreamAsynclistsliveStreams?mine=trueand reuses the existingcdn.isReusablestream, inserting once per channel (cdn.resolution=variable,cdn.frameRate=variable,isReusable=true) only on first use; the ingestion URL is cached viaLayoutStoreSettings (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)pollsliveStreams.list?part=status; render nothing ongood/ok/noData, surface a banner only onconfigurationIssues[]withwarning/errorseverity. The decision is the pureServices/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 viaResetHealth; poll failures log only. Parsing (fix 2026-09-22): the real API nestsstatus.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/configurationIssuesand tolerates the old flat-string shape. UI = a full-width banner strip under the top bar,HealthIssueBannertext +HealthIssueBackground(amber#b8860bwarning / dark red#8f1f1ferror), hidden byNotNullToVis. 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.EndBroadcastAsyncPOSTsliveBroadcasts/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 403invalidTransition— 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-flightsliveBroadcasts.list→status.lifeCycleStatusand only transitions fromlive/testing, skipping silently otherwise (enableAutoStopfinishes 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+enableDvrdefault 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, noBroadcastForm.CaptureGoLive"last live" stamp, no recording).IsTestStreamflips the face: gold top bar + glow (#9c6f1c), a TEST badge, and the End button reads "End Test";StopStreamclears 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 inMainViewModel.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 inMainWindowhad skipped TestSession, so only Stream Settings + YPP dismissed on an outside click). liveChatId resolution (fixed 2026-09-25, TASK 44; ⚠️ CORRECTED 2026-10-04): the id issnippet.liveChatId—contentDetailshas NO liveChatId property — andGetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)reads part=snippet and polls with a bounded retry, andPrepareAndStartLiveAsyncstarts the frame pump FIRST before resolving it. The old claim that "YouTube only populates it once the broadcast is LIVE" is WRONG, and it was inferred from a sample rather than from the API. The 2026-10-04 diagnostic caughtliveChatId=presentwhilelifeCycleStatus=created— not live, not evenready. The officialGetLiveChatId.javasample testsbroadcastStatus == activebefore returning the id, and that read as a statement about when YouTube populates the field; it is only a guard in the sample. Generalisation: a sample's defensive check is not a spec. Also note the old note said the missing id meant "the ready state" — the broadcasts were neverreadyat all, they werecreatedand unbound (see the SOLVED box above), so the terminology was wrong too. The bounded retry is harmless and stays, but the id is available far earlier than assumed — worth revisiting only if chat startup latency is ever reported as slow. Drawer tooling: Mock Chat Input = a REALliveChat/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'sMessageReceivedseam, markedChatMessage.IsSimulated. API fact (researched + hard constraint):liveChat/messages.insertonly createstextMessageEvents — 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 includesnippet.type="textMessageEvent"alongsideliveChatIdandtextMessageDetails.messageText— omitting type returns400 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). Scopesyoutube+youtube.force-sslalready 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/messagescarries all six monetization events (superChat / superSticker / newSponsor / membershipGifting / giftMembershipReceived / memberMilestoneChat).streamListis 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;AddSourcerefuses 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
×Nfan (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.
- 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
Services/AlertOverlayLayer.cs— the component: enqueues events one-at-a-time, advances on a 33ms dispatch ticker, serves the live frame throughRenderFrame(same cache-first pattern as chat) and the preview throughUpdatePreview;Advance(double)is the deterministic test clock (tests drive playback with no timer). Queue cap 10 (floods drop the tail, never stall).ChatEventKind.Nonerows (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→BeginClipspawns a per-playIAlertClipDecoder(ffmpegrawvideo bgra -vf scale=W:Hframe pipe +f32leaudio pipe, both real-time paced viaRawVideoFrameReader; not the refcountedMediaVideoSourceManager— alerts are one-shot plays that must be disposed at drain). Custom clip wins per-source over the built-in ONLY whenAlertUseDefaultVideo==falseAND the file exists; the shippedAssets/alert-default.mp4is stamped into theAssettable at startup (AlertDefaultVideoKeysetting; the prune keeps it via UNION) and read-at-play; any decode failure falls back to the sixAlertRendereranimations (deliberate, not an error). Fades in/out viaFadeDurationSeconds = 0.30(AlertOverlayLayerconst): fade-in byelapsedon straight-alpha frame copies; EOF → freeze-frame → fade-out → drain (the clip branch ofAdvanceclears_currentbeforeAdvanceToNext— the miss that the TASK 47 test caught). Audio forwards to the mixer sink scaled byvolume × fade. - Alert ticker (announcement strip):
AlertTickerFrame(in the layer) composes "Author — Kind · amount" and hands it toServices/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 intoBakeStaticBase):SceneCompositor.Render+CompositeLayerstake a trailingtickerFramethreaded fromFramePump._alertTicker(Func<VideoFrame?>ctor seam) throughRenderScene/RenderFulland mixed intoBuildFullRenderSignatureso a scroll changes the cache key. Toggled byAlertShowTicker.- 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 onVideoFrame.Placement((int X, int Y)?, withOriginX/OriginY= 0 when unset). Every blit site readstickerFrame.OriginX/OriginYinstead of a literal0, 0—SceneCompositor×2 andFramePump's static-bakeOverlaypath. The preview binds the same numbers (AlertTickerLeft/Top/Width/Height→AlertTickerElement'sCanvas.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. CopyStripclips 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 = 3passes inside the alert's own length, speed derived from that — never px/s; the old fixed140px/stook ~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-elementImage;AlertOverlayLayertakes atickerPreviewSinkand publishes fromRefreshAlertPreviews()(UI thread only — the pump thread must never touch aWriteableBitmap); the VM writes it into one reusedWriteableBitmap(AlertTickerImageSource/AlertTickerVisible) bound toAlertTickerElementinPreviewPane.xaml, mirroringSocialBarElement. 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).
- 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 (
- Alert audio under the master limiter:
AudioMixer.EnqueueAlertAudiowrites a dedicated 8s stereo-48k ring (AlertBufferSeconds), drained inFillAndMixbefore the limiter and added at unity — never ducked (creator ruling: "don't lower my game audio for a tip");StartLiveclears it. Per-alert loudness = the Volume slider.
Windows packaging & distribution (⛔ REVERSED 2026-10-22 — direct + Steam; Store dropped)
The Microsoft Store route is dead. A brand name on the Store's publisher line requires a
Company account, which requires a registered legal entity (an LLC — ~$85 New Mexico with a
~2-week SOS backlog, ~$143 Wyoming). Partner Center does not convert an Individual account to
a Company account later, and an Individual account publishes under the verified legal name. The
creator will not fund an entity for a nameplate, so the 2026-09-27 Store ruling was withdrawn on
2026-10-22. Store IAP was never implemented either — Windows.Services.Store appears nowhere in
the repo.
The route now:
- Direct download from
llamacasty.com— the app is free forever (TASK 54), so there is nothing to bill in v1. - Steam when $100 exists — Steam Direct is $100 per product (recoupable after $1,000 AGR), accepted categories include Audio/Video Production / Player Tools (OBS Studio is the precedent), the developer/publisher name is self-chosen so the brand needs no entity, 5+ screenshots at 1920×1080 + capsule art, a build Valve runs, and a 2-week live page before release. Valve takes 30%.
- Polar is dormant, not deleted-in-principle — chosen checkout/licence provider if a paid
tier ever returns; $50 already paid.
Distribution.md§4.2/§4.5 holds the design. - The Microsoft Store: not pursued. The MSIX package stays as a working artifact and is
submitted nowhere. History:
TASKS/research-store-certification.md,TASKS/task-48-distribution-msix.md.
Consequences that are architecture, not packaging detail:
- ⛔ The updater is now MISSING, not deleted-by-design. Velopack and its bootstrap were
removed on 2026-10-22 (TASK 48 slice 3) because the Store was going to deliver updates — that
premise is gone. An update path must come back, and
ytLive.Tests/StorePackagingTests.cs(which asserts no updater returns) must be rewritten with the chosen updater rather than left to veto one. - The MSIX package still builds via
scripts/package-msix.sh(TASK 48 slice 2) — publishRelease -r win-x64 --self-containedintoartifacts/msix-stage, copyPackaging/Package.appxmanifestin asAppxManifest.xml+Packaging/Assets/, thenmakeappx pack→artifacts/LlamaCasty.msix(159 MB, gitignored;MSIX_PFXsigns it for a local install). The manifest is a full-trustpackagedClassicAppatmediumILwithrunFullTrust+ webcam + microphone.Square310x310Logo/Wide150x150Logotraps are inMyMistakes.md. Logos come from the app's own artwork viascripts/make-store-logos.py. - ⛔ Bundle ffmpeg — no runtime downloads (was Store policy 10.2.2; still right for a direct download, and it fixes the 2026-09-01 404). See the FFmpeg locator section above.
- ⛔ 64-bit Windows only (creator ruling 2026-10-09). Both csproj files set
<PlatformTarget>x64</PlatformTarget>(the test project too — an AnyCPU test project referencing an x64 app raises MSB3270 and breaks the 0-warning gate). Why: the bundled ffmpeg is the win64 build, ONNX Runtime 1.31 ships no win-x86 native (only win-x64/win-arm64), and a 32-bit address space can't hold the 1080p frame buffers. Windows 11 is 64-bit-only anyway. Public copy says "64-bit Windows 10 or 11".
What does NOT change: the monetization posture. The app is free forever with no watermark
(TASK 54, 2026-10-08), so IsPremium gates nothing in v1 and the branding flash is OFF by
default. Monetization above governs.
⛔ Durable invariant: full trust, or recording silently breaks
MSIX packages run at one of two trust levels. A packaged WPF app declaring
rescap:Capability Name="runFullTrust" runs at mediumIL — the same permissions as a
standard desktop app, NOT AppContainer — and may spawn child processes, which is why
bundling ffmpeg is legal. Writes to user-profile locations (AppData, Downloads, MyVideos)
pass through unvirtualized at mediumIL; the package install folder is read-only.
ViewModels/MainViewModel.Recording.cs writes recordings to
%USERPROFILE%\Downloads / SpecialFolder.MyVideos (DefaultRecordFolder(),
ChooseRecordFolder() via Microsoft.Win32.OpenFolderDialog), and the whole
temp-write-then-move lifecycle stays in one directory. That works only because we are full
trust. No broadFileSystemAccess capability is needed, and none should be added.
⚠️ If anyone flips the manifest to appContainer, Directory.CreateDirectory and
File.Move start resolving to a per-package virtualized location and output vanishes with no
error. A comment at DefaultRecordFolder() is owed when the manifest lands (TASK 48
item 6) — not before; a comment describing a manifest that doesn't exist is worse than none.
Signing — do not buy EV
Microsoft removed the SmartScreen instant-bypass for EV certificates in March 2024. An
EV-signed file now accrues reputation exactly like an OV-signed one, so EV ($400+/yr) buys
what OV (~$150–300/yr) buys. Signing is not instant regardless — a valid cert stops the
malware warning immediately, but "unrecognized publisher" clears only as SmartScreen accrues
reputation from real download volume. Cert validity is capped at 460 days (recurring
line item) and private keys must live on an HSM or hardware token. The 2026-10-22 ruling
is to ship unsigned and accept the "unrecognized publisher" warning until download reputation
accrues — buying a certificate is deferred, not rejected. Distribution.md §4.3 was corrected
2026-09-27 (it previously recommended EV).
Distribution and licensing are independent — and the channel decision does not decide licensing
A Store-distributed app may still verify licences with Polar (Store policy 10.8.1 explicitly permits a secure third-party purchase API for non-game PC products), so "which channel?" never implied "which billing?" — route B was a real option. The 2026-10-22 reversal proves the point in the other direction: dropping the Store did not by itself restart Polar, because the 2026-10-08 free-forever ruling (TASK 54) had already removed the paid tier. Polar is therefore dormant, ready if a paid tier ever returns, and nothing in v1 depends on it. The lesson worth keeping: the licensing choice rides on a monetization decision, not on a distribution decision.
⛔ "Only when the user says so" is enforced by Windows, not by us
Camera/mic consent is layered: Store policy 10.6 forbids circumventing OS checks; the Windows desktop-app camera/microphone toggle (Settings → Privacy & security, since Win10 1903) is the gate; hardware lights and tray indicators are the OS's disclosure mechanism. So route everything through the standard MF/DShow/WASAPI paths and we never write that certification ourselves — the OS does it and gets neither our credit nor our blame.
⚠️ Untested: whether that toggle reliably gates a DShow webcam and a capture card.
Microsoft's own support page admits desktop apps "might still be able to access your camera
or microphone even when these settings are turned off." This is not a certification
failure (we declare webcam honestly and use supported APIs) but it is a real user-trust
issue — so no privacy-forward marketing copy until it is measured. Guard note added to
MARCOM.md.
⛔ Durable invariant: chat is rendered, never stored
TASK 49's profanity filter is local, on-device, opt-in, non-persistent, with a
user-supplied word list — never a hardcoded slur list baked into the binary (unpleasant
artifact, false positives across dialects, extractable). It must never match the SuperChat
amount or any reward field — those are financial data under policy 10.5.5.
The non-persistence half is not just hygiene: it is the load-bearing half of the 11.12 UGC certification answer (mirrored YouTube chat is moderated at industrial scale upstream; ~1.6B comments removed in Q1 2026 alone, child safety the #2 reason at 124.6M). No future feature — chat history, moderation logs, analytics, crash payloads — may quietly break that assumption. If one is ever proposed, the 11.12 position must be re-argued first.
Minors
The operator is 13+ by construction, not by promise: LlamaCasty requires a YouTube account to stream, and YouTube's ToS sets the account floor at 13 (regional variants reach 14; 13–17 need parent/guardian permission; under-13s are on supervised/YouTube Kids accounts). So a COPPA theory premised on collecting PI from under-13s has no purchase against the operator. The residual is viewers — still possible on supervised family accounts — and it is weak: live chat is restricted there, YouTube moderates before we see it, and we persist nothing. COPPA is very unlikely to bind (not child-directed), but note the enforcement trend (FTC Sept 2025 Apitor: operators are responsible for what third-party components collect) — so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no persisted chat or reward payloads."