# ytLlive β€” Task List > Task queue and authoritative research. Memory-map conventions: [`schema.md`](schema.md); > architecture/decisions: [`ai.md`](ai.md). Update statuses here whenever a task moves. > **Checklist markers** β€” every task's Status list uses the same states: > 1. βœ… β€” completed (green check) > 2. πŸ”Ά β€” in progress (amber diamond) > 3. ☐ β€” not completed / pending (empty box) > 4. ❌ β€” exception (blocked, known-issue, or deliberately excluded from this build) > **2026-09-01 β€” v1 = feature-complete ruling:** there is no v1.x. Everything queues to v1 or to > **"Out of product β€” permanently"** (file end). Read those two sections before adding or reviving > anything here. ## YouTube Live API β€” research facts (authoritative, v3 build) Lifecycle: `created β†’ ready β†’ [testing] β†’ live β†’ complete` (transitional `liveStarting` / `testStarting`). 1. **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA). 2. **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** β€” changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out. 3. **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`). 4. **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`. 5. **Transition to live** only allowed when the bound stream's `status.streamStatus == active`. ### Two features that reshape the design 1. **enableAutoStart / enableAutoStop** β€” instant one-click go-live, no transition call. With `enableAutoStart=true` we never call `transition(live)`: the broadcast auto-goes-live the moment the encoder starts. Combined with `enableMonitorStream=false` (our preview pane replaces YouTube's monitor stream β€” the thing that forces a testing stage), the flow is **create β†’ bind β†’ Start Stream β†’ encoder starts β†’ YouTube brings it live**. No testing, no transition polling, no liveStarting stuck-state handling. 2. **`cdn.resolution=variable` / `cdn.frameRate=variable`** β€” free auto step-down. YouTube auto-detects what we send; since we ARE the encoder we can drop bitrate/resolution on the fly with zero API calls. Declaring an explicit resolution instead (e.g. 1080p) requires a new stream, which can't happen mid-broadcast. Variable is the enabler for the whole auto step-down feature. **(Claim-check 2026-09-01: the enabler is real, the governor is not built β€” the drop-side policy ships as TASK 33, v1 per the complete-v1 ruling.)** ### Compliance gotchas (maps perfectly to report-by-exception) 1. `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception β€” poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic. 2. Encoder must comply or YouTube flags it: keyframes ≀ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only. 3. Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`. ### Tips we should take advantage of 1. **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline. 2. **Backup ingestion address**: YouTube provides a simultaneous-push backup β€” future hardening, not v1. 3. **`recordFromStart` + `enableDvr` default true** β†’ every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal. 4. **`latencyPreference`: `normal | low | ultraLow`** β€” for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature. 5. **Broadcast ID == Video ID** β€” one ID to track everything. --- ## TASK 1 β€” Initial Scaffold **Goal:** Working C# / WPF project with MVVM architecture, dark-theme main window, and YouTube service stubs. ### Status: βœ… Done 1. βœ… Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage 2. βœ… Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling) 3. βœ… MainViewModel: scene management, stream controls, chat 4. βœ… MainWindow: scene/source panel, preview area, chat panel, status bar 5. βœ… Clean build, 0 warnings (WSL + Windows) --- --- ## TASK 2 β€” YouTube OAuth2 Authentication **Goal:** Fully working Google OAuth2 flow β€” user clicks "YouTube", browser opens, authorization callback lands, channel info is stored. ### Status: βœ… Done 1. βœ… Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot 2. βœ… Real OAuth2 wiring β€” baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch 3. βœ… Token persistence via Windows DPAPI (`Helpers/TokenStore.cs` β†’ `%APPDATA%\ytLlive\ytLlive.auth`), best-effort reload + proactive refresh at startup, saved after every exchange/refresh 4. βœ… Account sign-in/change surfaced in the GoLive dialog (saved account shown with "Change Account"; "Sign in to YouTube" when none; Start disabled until signed in) 5. βœ… ~~**End Livestream signs out**~~ **SUPERSEDED by TASK 18 (2026-08-29): explicit sign-out only** β€” `StopStream()` no longer clears the session (sign out via Logout / Change Account, `SignOutYouTubeAsync`); stopping a stream or recording leaves the creator signed in. Originally shipped as: a graceful end completes the session: `StopStream()` calls `YouTubeAuthService.ClearSession()` + `TokenStore.Clear()` + `IsConnected = false`, so the next Start Stream dialog requires a fresh sign-in. A crash never runs End, so the DPAPI token survives and the creator stays signed in. Resume/reconnect after a midstream crash is deliberately deferred to TASK 3: the socket can't be resumed (it dies with the process), so "resume" = fast reconnect with a saved broadcast ID/stream key within YouTube's disconnect-grace window; too slow and `enableAutoStop` ends the broadcast. **Orphan closed 2026-09-01:** this mechanism is owned by **TASK 32 (Stream resilience)** β€” in-session blip retry inside the grace window; cross-process "resume" of a dead broadcast is officially out-of-product (the API makes it impossible β€” creator ruling). 6. βœ… Tests in `ytLive.Tests` (xUnit, net8.0-windows): TokenStore DPAPI roundtrip/corrupt/missing/clear + mocked exchange channel-parse + refresh expiry bump + `ClearSession` β€” 7 passing **Design constraint:** Sign-in must NEVER block core exploration. Users can build scenes, add sources, and audition the software without authenticating. But **going live requires authentication** β€” the "Start Stream" dialog is where the account sign-in lives, alongside all stream metadata. **Two-state flow:** There is no separate "Connect" button. The top bar shows a single button β€” **Start Stream** when idle, **End Stream** when live. Clicking Start Stream opens one dialog that supplies everything: account (previously-saved account shown as default, with a Change Account action) + title/description/visibility. **Live indicators (unmissable):** Top bar + window title bar flip red, a pulsing **● LIVE** badge with elapsed timer appears in the top bar, the preview area gets a red glow, and the taskbar icon shows a red overlay dot. Window title bar shows the stream title once validated. ### Requirements: 1. **Google Cloud OAuth credentials** β€” client ID + secret, **baked into `Helpers/OAuthCredentials.cs`** (desktop "Desktop app" OAuth client; loopback callback β€” no console redirect URI registration needed; creators never configure) 2. **Local HTTP listener** β€” `HttpListener` on `http://localhost:PORT/oauth2/callback` to catch the redirect 3. **Browser launch** β€” open the authorization URL in the default browser 4. **Token persistence** β€” store access/refresh tokens securely (Windows DPAPI), reload on startup 5. **UI state** β€” account shown in the Start Stream dialog; "Change Account" action triggers re-auth 6. **Go Live gated on auth** β€” Start Stream dialog requires sign-in to enable the Start button; scene building works without it ### Tests: 1. Mock token exchange response, verify channel info parsed 2. Verify token refresh triggers when near expiry 3. Verify credential load/save roundtrip --- --- ## TASK 3 β€” Capture Pipeline (Scenes/Sources) **Goal:** Real video preview in the center panel β€” the minimal source set below, composited per scene. ### Status: πŸ”Ά In progress β€” 27 of 30 items done (1 excluded: background removal); items 16, 17, 20 open 1. βœ… **Milestone 1 β€” webcam** β€” MediaCapture (WinRT SDK projection) with device enumeration, CPU-first frame source, refcounted `CameraManager`, picker dialog, clip shapes (Traditional + Round) + mirror, 480Γ—270 default placement β€” schema v2 2. βœ… **Schema v3 (Ship Branch A)** β€” multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OBS-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`) β€” 25 tests passing 3. βœ… **Schema v4** β€” roundβ†’rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load 4. βœ… **Screen backdrop (ship task #1, schema v5)** β€” live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`), auto-detecting the full-screen game at launch/focus (else the **primary display** β€” never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` β€” 45 tests passing 5. βœ… **Schema v6 β€” backdrop Live-only by policy** β€” `Scene.HasBackdrop`, enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5β†’v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920Γ—1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) β€” the live-mode stutter fix 6. βœ… **The five-scene catalog (`SceneCatalog`)** β€” Starting/Live/BRB/Chat/Ending is the product β€” work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones) β€” 62 tests passing 7. βœ… **Webcam-after-session-start fix** β€” a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container β€” `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` β€” 65 tests passing 8. βœ… **Chat scene webcam size cap** β€” raised from 50%-per-dimension (960Γ—540) to half the screen AREA (~1358Γ—764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better 9. βœ… **"Add Webcam" always opens the camera picker** β€” deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it β€” `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…" 10. βœ… **Webcam resource validation + first-frame proof** β€” `MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties β‰₯1, `reader.StartAsync()` status read + throws on non-Success); subscribes `capture.Failed` + `CameraStreamStateChanged` β†’ `SourceFailed` event on the seam; fallback ladder (VideoPreview β†’ VideoRecord). `CameraManager.AcquireAsync` requires first-frame proof (4s timeout): returns true only after a real frame arrives β€” silent empty box impossible. `MainViewModel` subscribes `CameraFailed` β†’ red `WebcamError` chip in preview + MessageBox names suspect apps (`CameraConflictProbe`). 19041 SDK projection gaps: `Exclusive`/`DeviceLost` not projected; `CameraStreamState.Failed` compared by `(int)2`. 81 tests passing 11. βœ… **Scenes/sources UI** β€” add/reorder/rename, image + background overlays with move/resize/opacity/reuse 12. βœ… **Audio UX shipped (UI)** β€” the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, greenβ†’yellowβ†’red fill; mute = speaker icon β†’ red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted β€” sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level Γ— volume β€” volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input β€” clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider β€” gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) β€” the creator's only audio control, desktop/game audio is automatic (KISS rule). **Post-test follow-up (2026-08-13, game audio bar branch):** the **game audio bar** (desktop/game, a mirror of the mic bar: meter + mute + volume) is **overlaid at the bottom of the preview window** (bottom-center dark chip β€” monitoring UI only, never on the live output) and appears only while a **full-screen game is producing sound** in the preview (`IGameAudioDetector` seam + default `GameAudioDetector` polling `IFullScreenDetector` + the live loopback level, floor 0.5%, into a pure `GameAudioHysteresis`: SHOW after ~500ms of fullscreen+sound, HIDE ~1s after leaving fullscreen, **silence never hides an active bar**; the VM polls it on a 250ms `DispatcherTimer`); **capture now runs for the app's lifetime** (mixer started once at startup via `StartMicCaptureAsync`, disposed in `Shutdown` β€” not go-live) so the meters preview live; the **MIC label became a button with a status dot** (`Models/MicStatus`: green = connected via the source `Started` event, yellow = mic problem, red = no device); picking a mic takes effect immediately (`AudioMixer.RestartMic`, loopback keeps running); fixed a latent `?.Invoke(meter.Push(...))` short-circuit that skipped the meter update when nothing was subscribed. **Round 2 (2026-08-13):** the meters were dead on a flat scale (real speech/game RMS is ~0.01..0.1 linear) β€” the raw level is now mapped via `AudioLevelMeter.ToDisplay` (βˆ’60..0 dBFS spread across 0..1) so typical input reads ~1/3..2/3 of the bar at default volume. 169 tests passing, 0 warnings. **Round 3 (2026-08-13):** the mic bar gained a **mic mute icon** (a microphone glyph in the speaker's 16px style, red + slash when muted) between the meter and the speaker β€” both mutes adjacent with spacing between them, same `ToggleMicMuteCommand`; and the top-center LIVE badge became an **always-visible REC sign** β€” dark gray dot + dim "REC" offline, bright red (#e94560) + "REC" + elapsed while live, **darker red (#8f1f1f)** when live with a **private** stream (driven by the dialog's chosen visibility) β€” `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`, pulsing while live. **Queued:** task 22 (voice filters). Voice-filter note: the meter's `ToDisplay` input is the pre-filter mic level; when filters land they must apply BEFORE the meter/encoder mix 13. βœ… The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES) 14. βœ… **Social bar v2 (six-slot dialog, sign-in gate, real logos)** β€” global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tieβ†’bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots β€” row 1 always YouTube (signed-in β†’ channel handle; signed-out β†’ sign-in gate β†’ OAuth; delete β†’ confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bareβ†’Website) β†’ async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 β€” initials badges gone); invalid β†’ red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates β€” the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop** β€” `ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain β€” it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo` β†’ `software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark β€” `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains β€” `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there. 15. βœ… **Window capture** β€” absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending 16. ❌ **Scene compositing (D3DImage/MediaElement)** β€” SUPERSEDED, permanently out (2026-09-01): the software output compositor (TASK 4 step 1) + the XAML preview ARE the design; a D3D11 swap stays a possibility behind the `VideoFrame` seam, not a feature 17. ☐ **Text source** β€” live text ("Starting soon", "Back in 5", handle, callout) 18. βœ… **Chat box** β€” YouTube live chat rendered *on* the stream so viewers read along in-video β€” `ChatBoxRenderer` (WPF FormattedText β†’ VideoFrame), configurable font size/color/badges/timestamps/max-messages via Elements panel, schema v10, persisted across save/load 19. ❌ **Background removal (milestone 2)** β€” ONNX Runtime + DirectML, MediaPipe Selfie Segmentation β€” deliberately NOT in this build 20. ☐ **Alerts** β€” Super Chat / membership / subscribe pop-ins; build after the six; **free and ungated** (the stale "one paid feature" label predates the 2026-08-31 monetization reversal and is corrected 2026-09-01 β€” the paid swap is the branding flash ONLY; see Monetization in `ai.md`). **v1 IN** per the complete-v1 ruling. **Precursor β€” reward-event capture (2026-09-01):** Alerts render from the app's canonical `RewardEvent` feed (all 7 `liveChatMessage` reward types, persisted to SQLite + the session report β€” see TASK 10 β†’ "Related work β€” monetization awareness"), so it consumes that already-built data rather than re-integrating `liveChatMessages`. That capture is the required prior milestone. 21. βœ… **Show Desktop toggle** β€” `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem). 22. βœ… **Top bar β€” status light + avatar + Log In button** β€” red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` β†’ GoLiveWindow. `ShowStartStream` now requires `IsConnected`. 23. βœ… **Chat preview β€” one-at-a-time mock messages** β€” `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear. 24. βœ… **All backdrops seeded + EnsureDefaultBackdrop** β€” Live, Chat, Ending backdrops added (ending-backdrop.jpg replaced). `EnsureDefaultBackdrop()` called on every scene switch. `SeedEndingBackdrop` replaces existing (not skip-if-exists). Chat scene no longer skipped. 25. βœ… **SceneCatalog fix** β€” Chat scene display name corrected to "Chat" (was "YouTube Chat" β€” that name belongs to the layer/SourceType.ChatBox, not the scene). 26. βœ… **Left panel spacing + context menu reorder** β€” HR margins matched, Row 4 changed from `*` to `Auto`. Context menu reordered: Border Thickness first, then Opacity, Color, Effect. 21. βœ… **Logo + richer in-app About (2026-08-13, queued β†’ 2026-08-14 SHIPPED)** β€” the ytLlive wordmark in the top bar opens the About overlay (already wired); the About overlay is now the **creator hub**: the real logo (the "llama fortnite superman logo" from the creator's vault, copied to `Assets/llama-logo.png` β€” the creator's own art, no third-party license), plus **creator-hub links** β€” llama chile shop on YouTube (`MainViewModel.ChannelUrl`), Mastodon (`https://mastodon.llamachile.tube/@gramps`), **Buy me a coffee** (`https://buymeacoffee.com/llamachiley` β€” live), and **Unlock Premium** (greyed "coming soon" β€” the **billing** product URL is a tabled seam, `PremiumUrl`, until TASK 10 picks the subscription provider). A **Licenses & legal** button flips the About overlay to an in-app scrolling panel that loads the full `THIRD-PARTY-NOTICES.txt` text (`MainViewModel.ShowLicenses` reads the shipped file from the executable directory on first open; graceful "not found" fallback β€” **never the OS viewer, everything stays in-app**); "← Back to About" returns. The About overlay is also the in-app home of the notices β€” the top-bar About button that opened the file in the OS viewer was removed on 2026-08-13 for exactly this reason. Integration test (Good Dog Rule β€” ONE): `AboutHubTests.About_Opens_InApp_Licensing_Loads_Shipped_Notices` drives the real window + VM, asserting the hub opens, the link URLs are real, the licensing panel loads the shipped notices text (contains "Third-Party Notices" + "LGPL"), and back returns to the hub. 174 tests passing, 0 warnings 22. βœ… **Voice filters on the mic channel (2026-08-13 queued β†’ SHIPPED 2026-08-14 inside TASK 9, the audio milestone)** β€” the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Noise suppression = a **pure-C# noise gate** (creator chose over RNNoise / a second ffmpeg `afftdn` pipe, 2026-08-14 β€” KISS). Always-on β€” no UI knobs; the mic stays the creator's single audio control 27. βœ… **Webcam row gates on the app-wide identity (2026-08-24, TASK 26)** β€” the (+) menu's Webcam item greys out whenever a webcam exists **anywhere** (`CanAddWebcam` = `StagedScene != null && _webcam == null`; renamed from `CanAddWebcamToStagedScene` whose per-scene rule let a second picker run from a scene lacking the config), raised at both `_webcam` mutation sites (create / last-config removal) + scene staging + elements change. Creator's visual pass found it: minis/no-capture-rows/capture-controls all good. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity` (real window + temp DB seeded with a webcam in Starting; asserts greyed while Live staged, re-enabled after `RemoveSourceCommand` clears the last config + the `Webcam` DB row). Stale map fixed in the same commit: ai.md's "empty-canvas right-click Show Webcam" claim dropped β€” that XAML never shipped (`ShowWebcamCommand`/`CanShowWebcamInStagedScene` are wired but unbound dead code, audit item). 224 tests (223 pass; the pre-existing AudioPipelineTests failure is unrelated) 28. βœ… **YouTube Chat layer: one-per-layout gate + legacy label heal (2026-08-24, TASK 27)** β€” same rule as TASK 26 applied to the chat layer: the (+) menu's YouTube Chat item greys out while any scene carries a ChatBox source (`CanAddYouTubeChat`, raised on staging + elements change; `AddSource` refuses a second), tooltip "One chat layer at a time β€” it's already in your stream". Plus the creator's label fix: layers added by commit `65641d8` were named "Chat"; LoadLayout now heals exactly that un-renamed default to "YouTube Chat" so the Layers row matches the (+) picklist (creator renames untouched, idempotent). ONE integration test `ChatLayerGateTests.LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer` (real window + temp DB with a legacy "Chat" row: healed on load + persisted on save, greyed cross-scene and in-scene, re-enabled after remove). 225 tests (224 pass; pre-existing AudioPipelineTests failure unrelated) 29. βœ… **Broadcast metadata pull-out + launch geometry (2026-08-24)** β€” creator ask: "a tab-pullout on the right side of the preview pane β€” white tab, YouTube-red label reading 'Text' β€” opening a form with all settable liveBroadcast fields; fields that can't be filled before launch greyed out; saving/updating remote content; field data saved and pre-loaded on app run". Shipped as specified with one correction: the API is the inverse of "more fields once live" for contentDetails (those lock in created/ready) β€” the pull-out carries the always-editable snippet/status set: Title, Description, Tags (csv), Visibility (private/unlisted/public), Made-for-Kids, plus read-only Scheduled Start. **Always visible** (creator revised same day from Live-only gating). White 30px tab, rotated red "Text", right edge vertically centered; click slides a dark 320px drawer left over the preview (200ms CubicEase). Every edit persists to Settings keys (`Broadcast.*`) immediately; **Update Broadcast** button calls the new `YouTubeStreamService.UpdateBroadcast` (PUT `liveBroadcasts?part=snippet,status`, echoes scheduledStartTime because update replaces the whole snippet part). Go Live prefills from the form and captures what was inserted (`CaptureGoLive`). The old un-persisted Default Stream Title/Description fields came OFF the App Settings overlay (replaced by the form); `DefaultStreamTitle/Description` VM properties deleted. **Launch geometry** (same unit): default 1920Γ—1040, MinWidth 1366, MinHeight 768; `WindowStartupLocation=Manual` + window size/position persisted on close via `RestoreBounds` (maximized-safe), restored in ctor clamped to minimums and the primary work area (disconnected-secondary fallback). ONE integration test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`. 228 tests (227 pass; pre-existing AudioPipelineTests failure unrelated) 30. βœ… **Gear menu cleanup + Default Location for Recordings + single Start button (2026-08-29)** β€” **KISS: removed** Gear-menu `Save Layout`/`Save Layout As…`/`Open Layout…` (redundant with auto-save; multi-layout = OBS) + dead `SaveLayoutCommand`/`SaveLayoutAsCommand`/`OpenLayoutCommand` + `SaveLayoutAs()`/`OpenLayoutFile()` methods. **App Settings panel:** first real setting β€” `Default Location for Recordings` (configured folder via `ChooseRecordFolderCommand` + "Use Downloads" reset via `ResetRecordFolderCommand`, resolved path shown). Fallback chain: configured β†’ `%USERPROFILE%\Downloads` β†’ `MyVideos`. `StartRecordFile()` + `ChooseRecordFolder()` `InitialDirectory` updated to use the new fallback. **Single Start button:** `PrimaryButtonText`/`IsStreamingStart` + their `OnPropertyChanged` calls removed; button `Content="Start"` in XAML β€” pills are the intent surface, button is always just Start. `Choose Record Folder` fast-path kept in the button context menu. Monetization (About links/premium) deferred to a later task. ONE fallback test `DefaultRecordFolder_Falls_Back_To_Downloads`. build 0 warnings, 247 tests (2 known failures, pre-existing). ### The Minimal Source Set (design decision β€” do not expand casually) ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship the hot few and nothing esoteric. If a user needs more, they've graduated to OBS. 1. **Webcam** β€” the face cam. Non-negotiable. 2. **Screen** β€” the main event (game, slides, browser). One source; a picker chooses a monitor *or* a window. (Window capture is absorbed here β€” no separate source type.) 3. **Background** β€” a full-canvas backdrop image. Fills the whole scene automatically, zero fiddling. Kept separate from Image on purpose: same pixels, but this one needs no positioning. 4. **Image** β€” a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned. 5. **Text** β€” live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this. 6. **Chat box** β€” YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native. 7. **Alerts** β€” Super Chat / membership / subscribe pop-ins. The dopamine source. **The one big lift** (Super Chat event streaming + on-stream rendering/animation); build after the six. **Free and ungated** β€” paid = flash removal only (stale "one paid feature" corrected 2026-09-01). Consumes the canonical `RewardEvent` feed (reward-event capture, TASK 10 β†’ related work). Deliberately NOT supported: game capture, browser source, media playlist, VLC, color-key voodoo, MIDI. ### Source memory model (design decision) 1. A scene has resources. Resources can be shared across scenes. 2. A resource exists exactly once in memory, no matter how many scenes use it (a logo in five scenes = one loaded bitmap). 3. Every resource carries a **catalog of scenes**: one usage entry per scene it appears in, each entry dictating that scene's use β€” placement (X/Y/Width/Height), opacity, z-order, enabled, scale mode, crop. 4. Usages are named `{resourceName}.{sceneName}` β€” whatever the user named the resource, dot, the scene name: `logo.starting`, `logo.live`, `myPic.brb`. Not a hardcoded "logo". 5. A webcam in two scenes = one capture session, two catalog entries. 6. Refcount by catalog size: the last usage removed β†’ the resource is disposed and evicted. 7. The resource (not a per-scene node) owns everything `IDisposable`. ### Scene transitions (design decision) Scene switching while live must never stutter. Supported types, most β†’ least economical: 1. **Cut** β€” instant switch. The default. Zero cost. 2. **Fade** β€” short crossfade (~300ms). 3. **Move** β€” a simple, economical move transition, done to perfection and memory-efficient. The smart streamer's bread and butter. 4. **Custom (media) transitions** β€” require media elements (video/stinger playback during the transition). Heavier, but creators pay for these, so we support them. Their media follows the same resource memory model: loaded once, catalogued by scene. Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four above. ### Requirements: 1. **Screen** β€” Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker 2. **Webcam** β€” MediaCapture (WinRT SDK projection) with device enumeration β€” βœ… **milestone 1 done**: 1. TFM bumped to `net8.0-windows10.0.19041.0` (app **and** tests) so the WinRT projection resolves from the SDK reference packs β€” no NuGet package, no capability manifest (unpackaged desktop app) 2. `MediaCaptureFrameSource` (CPU-first: `MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`), `MediaCaptureCameraEnumerator` (`DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)`) 3. `CameraManager`: refcounted by `DeviceId`, one shared `WriteableBitmap` app-wide, dispatcher-coalesced UI updates (~render rate, latest-frame drop), placeholder/`AppLog` + warning on failure 4. `CameraPickerDialog` (mirror of `ReuseImageDialog`) β€” "Searching for cameras…" / list / "No cameras found" states 5. One webcam app-wide: Add β†’ Webcam greyed out once one exists ("it's already in your stream" tooltip); persisted `DeviceId` re-acquires after layout load 6. Default placement 16:9 **480Γ—270**, bottom-right, 32px margin; drag/resize/selection shared with Image sources 7. **Clip shapes: Traditional + Round** (phone view dropped β€” the 9:16 phone output is the vertical output-crop tier); **mirror**; both persisted in the layout DB (schema v2) and toggled from the source chip 8. **Background removal = milestone 2** (ONNX Runtime + DirectML, MediaPipe Selfie Segmentation) β€” not in this build 3. **Background / Image / Text** β€” static sources positioned/scaled/opacity 4. **Chat box** β€” rendered from the live chat poll (right panel is the same feed, raw) 5. **Scene compositing** β€” per-scene source layering (z-order = sources list order, top-to-bottom back-to-front), preview rendered via D3DImage or MediaElement 6. **Branding flash** β€” the topmost full-frame "made with ytLlive!" layer at ~25% opacity, ~1s on / 300s off (see Monetization in `ai.md`), gated on `BrandFlashEnabled` + live/recording. Lives in the preview compositor now (`BrandFlashLayer` in `MainWindow.xaml` CanvasGrid, driven by `BrandFlashActive`/`BrandFlashTimer` in `MainViewModel`); the encoder output renders the same layer, and v0.2 local recordings carry it too 7. **Drag/drop placement & reorder** β€” intuitive, visual (per design principle): 1. **Preview:** click-drag a source in the center panel to reposition it; resize via handles 2. **Scenes list:** drag rows to reorder scenes 3. **Sources list:** drag rows to reorder sources (this *is* the z-order) β€” implemented --- --- ## TASK 4 β€” RTMP Ingest to YouTube **Goal:** Push encoded video to YouTube's RTMP ingest. ### Status: βœ… Done β€” all 7 items shipped 1. βœ… **Ship step 1 β€” the output compositor SHIPPED** (2026-08-10) 2. βœ… **Ship step 2 β€” the FFmpeg locator SHIPPED** (2026-08-10) 3. βœ… **Encoder + RTMP push SHIPPED** (2026-08-12) β€” the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below) 4. βœ… **WASAPI audio capture SHIPPED** (2026-08-12) β€” NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below) 5. βœ… **Frame-pipeline wiring SHIPPED** (2026-08-12) β€” `CameraManager`/`ScreenCaptureManager` β†’ compositor resolver β†’ encoder, driven by a paced `FramePump` (see the ship step 5 plan below) 6. βœ… **Health stats SHIPPED** (2026-08-13) β€” `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 9 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets) 7. βœ… **One-click go live + private-only enforcement SHIPPED** (2026-08-14) β€” the Go Live dialog is **locked to Private** (no dropdown, `GoLiveViewModel.Visibility` is a get-only "Private"); `YouTubeStreamService.CreateBroadcast` **always sends `privacyStatus = "private"`** (dialog + service enforcement, requirement 8 β€” nothing can go out non-private) and gained an injectable `HttpClient? http = null` seam for tests; `BeginGoLive` now calls `CreateBroadcastAsync` and remembers `_currentBroadcastId` for TASK 9's bind/transition (failure β†’ `StreamStatus.Error`, never a crash; `StopStream` clears the ID); the REC sign shows a **PRIVATE badge** (dark-red border, next to REC, `IsLivePrivate`) when the live stream is private; settings' dead "Default Visibility" dropdown + `MainViewModel.Visibilities`/`DefaultStreamVisibility` removed. The broadcast-insert integration test asserts the request body carries `"privacyStatus":"private"` (2 new tests β†’ 173 passing, 0 warnings) The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920Γ—1080 frame without the preview's editing chrome) β†’ **audio capture** (WASAPI, feeds the meter) β†’ **H.264+AAC encode** β†’ **vertical-tier crop/scale** β†’ **RTMP push** β†’ **health stats** into the bottom bar. Nothing can encode until a frame source exists, so the compositor is ship step 1. The pipeline is `CameraManager + ScreenCaptureManager β†’ compositor resolver β†’ compositor β†’ encoder β†’ RTMP`. ### Requirements: 1. **Encoding** β€” H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≀ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** β€” NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) β€” the format is NOT the differentiator, the license and per-GPU quality are. **License guardrails (never violate β€” see `ai.md` β†’ "Licensing β€” do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL Β§6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen. 2. **RTMP push** β€” **FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push. **No self-heal (claim-check 2026-09-01): ffmpeg's reconnect flags are input-side; an RTMP *output* push does not reconnect itself β€” retry is app-side, owned by TASK 32.** **Binary distribution (decided): check-then-pull** β€” probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (**BtbN LGPL-shared win64** zip, ~75 MB β€” gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract `ffmpeg.exe` **plus the `libav*.dll` family**) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL 3. **Quality ladder** β€” the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**: 1. 720p30 @ 6 Mbps 2. 720p60 @ 6 Mbps 3. 1080p30 @ 8 Mbps 4. **1080p60 @ 8 Mbps** (default β€” mainstream ceiling, GPU hardware-encoded so the gaming machine never notices; upload headroom stays comfortable) 5. Vertical 1080Γ—1920 @ 60fps @ 8 Mbps (9:16 phone tier) The composition master is always 1920Γ—1080; a tier is an output rect + target resolution (see `ai.md` "Resolution tiers"). Vertical output = the centered 607Γ—1080 crop of the master scaled to 1080Γ—1920 (semi-crop preview is already implemented; the encoder applies the same rect). 1080p60 is the ceiling by design β€” "if you want 1440 or 4K or 8K β†’ OBS is your solution"; the app targets the most mainstream creator, not power users. Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required). Quality is greyed out while live because the declared resolution can't change mid-stream β€” but with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls (no stream recreation); 60fps presumes a hardware encoder β€” no hardware encoder β†’ auto fallback to 720p60/1080p30 4. **Stream key management** β€” reuse the cached reusable stream (one per channel) instead of creating a new one per go-live; prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2` 5. **Health stats** β€” bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side) 6. **One-click go live** β€” defaults that work out of the box 7. **Audio capture (feeds the meter β€” this task ships the wiring)** β€” WASAPI loopback (desktop/game at unity, zero UI β€” "it just is") + the picked mic (`MicSourceName` from the `MicPickerDialog`). The mic capture feeds `AudioLevel` so the realtime meter comes alive (today it reads 0 β€” the mixer feed is pending, see `ai.md` audio notes). AAC mono/stereo @ 48 kHz per the compliance rules. 8. **Private-only go live until v1 (reputation guard, decided 2026-08-10)** β€” until the v1 release, go-live is **locked to private streams only** so a software error can never publish something public/unlisted that damages the creator's reputation. RTMP push itself has no privacy β€” privacy lives on the YouTube **live broadcast object**, which this app already controls via its OAuth API calls. So the lock is purely API-side: the Go Live flow always creates/updates the broadcast with `privacyStatus = "private"` and a guard **refuses** to set anything else (same spirit as the Live-only backdrop policy). The UI shows a clear "PRIVATE" badge next to the stream state so the creator always knows who can see them. Enforcement must be verifiable in the auth-service tests (fake the broadcast-insert/update call, assert `privacyStatus` is forced to private). 9. **v1 release gate: bundle the full license texts (decided 2026-08-10)** β€” `THIRD-PARTY-NOTICES.txt` currently links the canonical license texts rather than embedding them. At the **v1 (GA) release**, the full texts of every license it names (LGPL v2.1+, BSD-2-Clause, MIT, Apache-2.0) MUST be bundled alongside it (shipped in the app output, e.g. a `licenses/` folder next to the notices file, still reachable from the About screen). This is a **release blocker for v1, not a task to queue early** β€” do it in the release pass. The repo should treat this like the private-only go-live gate: a checkbox that cannot silently lapse. #### Ship step 1 β€” Scene compositor (the frame source) **Goal:** a pure-CPU software compositor producing the encoder's master frame (BGRA8, the `VideoFrame` seam) from the scene model. The preview stays XAML (the editing view); the compositor is the **output view** β€” WPF's `RenderTargetBitmap` can't be used (software-rendered + captures chrome). Two renderers must agree, so the XAML (`MainWindow.xaml` CanvasGrid + element DataTemplate) is the contract. **Decisions (locked 2026-08-10):** **Path A CPU blitter** β€” GPU effort belongs to NVENC (the encoder), not composition; with an FFmpeg subprocess the master crosses a CPU readback to the pipe every frame anyway, so GPU compositing buys ~nothing at this layer count (2-3 live layers; static layers pre-composite once). A D3D11 compositor can replace this one later **behind the same seam** (the CPU master buffer stays the contract). **Render the output rect directly**: compositor is constructed with `CompositorOptions {SourceRectX/Y/W/H, OutputWidth, OutputHeight}`; 16:9 tiers = full 1920Γ—1080 1:1; vertical (9:16) = composite the centered 607Γ—1080 crop then bilinear-upscale to 1080Γ—1920. Reuses `MainViewModel.OutputRectX/Y/W/H` (note `(1920βˆ’607)/2 = 656.5` β†’ align to integer pixels for output). **Render spec (back β†’ front, mirror the XAML exactly):** 1. Backdrop β€” the Live scene's `IsBackdrop` Source (`CaptureKey` β†’ live frame), `UniformToFill` full-frame (XAML's separate `BackdropImage` layer; the backdrop *element* renders nothing β€” its DataTemplate Image is Collapsed for DisplayCapture). 2. Background β€” the scene's `Background` Source, `UniformToFill` full-frame (the `ActiveBackgroundImage` layer, not per-element). 3. Elements in `Scene.Elements` order (backβ†’front), skip `IsVisible=false`. What actually renders: 1. `Source` Type `Image` β†’ static asset, `UniformToFill` cover-crop into (X, Y, W, H) 2. `WebcamSceneConfig` β†’ latest frame by `DeviceId`: Traditional = `UniformToFill` rect; Round = circle diameter `min(W,H)` (alpha 0 outside β€” true circle, not oval); mirror = horizontal flip around element center (`MirrorScale`); opacity = per-pixel multiply (content + border); border = stroked rect / centered circle at `RoundBorderSize`, width `BorderWidth`, alpha `BorderOpacity` 3. `Background` / `IsBackdrop` / `TextOverlay` are NOT per-element (layers above; Text not shipped) 4. Branding flash β€” pre-rendered full-frame "made with ytLlive!" at 25% alpha when live + `BrandFlashEnabled` + timer active. Passed in as a `VideoFrame?` (compositor core stays pure byte-math, no WPF; likely a bundled asset rather than runtime text rendering). 5. NOT in output (preview chrome only): SelectionOverlay, DimRects, output-rect outline, badge, placeholder. **New files (all in `Services/Compositor/`):** 1. `SceneCompositor.cs` β€” `Render(Scene, frameFor: Func, flashFrame: VideoFrame?, CompositorOptions) β†’ VideoFrame` (output-sized). The caller's `frameFor` resolver maps each element to its frame (webcam β†’ DeviceId, image β†’ AssetId via `StaticPixelCache`, backdrop β†’ CaptureKey) β€” the compositor stays pure/hermetic/no WPF. 2. `CompositorOptions.cs` β€” source-rect + output WΓ—H. 3. `StretchMath.cs` β€” `UniformToFill` cover-crop, ellipse mask, bilinear scale (pure, unit-tested). 4. `StaticPixelCache.cs` β€” asset `byte[]` β†’ cached BGRA `VideoFrame` (WPF `BitmapDecoder` + `CopyPixels`, decode once per content hash). **Test plan (Good Dog Rule β€” ONE integration test):** `SceneCompositorTests` β€” a scene with backdrop (solid red fake frame) + round webcam (solid green) + image (solid blue) β†’ render 16:9 master β†’ assert per-layer probe pixels (corner = backdrop color, element center = webcam color, outside the round clip = backdrop color, mirrored element swaps left/right); a vertical-tier variant asserts 1080Γ—1920 output + crop fidelity. Focused unit tests on `StretchMath`. Tests push frames directly β€” no capture managers involved (they wire in a later step). **Same-PR housekeeping:** fix the stale comment `MainViewModel.cs:324` ("shown under the meter on line 2" β†’ "shown left-justified INSIDE the meter bar" β€” `ai.md` is the authority); this task's requirements now include the explicit audio-capture/meter wiring (#7 above). **Out of scope (later ship steps):** FFmpeg locator + license posture (covered in requirements 1-2), encoder + RTMP push, WASAPI audio capture (loopback + mic) feeding `AudioLevel`, wiring `CameraManager`/`ScreenCaptureManager` into the frame pipeline, brand-flash timer wiring, health stats (bitrate/FPS/dropped). **Built (2026-08-10):** all four files shipped in `Services/Compositor/`, `SceneElement.TryGetBorderColor` made public (shared hex parse with the compositor β€” no duplicated color parsing), the stale `MainViewModel.cs:324` comment corrected, and the pre-existing CS1998 in `YouTubeAuthServiceTests` cleaned up β€” build **0 warnings**. Tests: the `SceneCompositorTests` integration test (full-scene master pixels, vertical tier, flash) + 4 `StretchMath` units β€” **72 passing**. #### Ship step 2 β€” FFmpeg locator (the encoder's binary) **Goal:** resolve a usable `ffmpeg.exe` on demand (the encoder's one external dependency), never shipping a binary in the repo. Returns an absolute path; downloads only when neither PATH nor the local cache provides one. **Decisions (locked 2026-08-10):** 1. **BtbN LGPL-shared win64 build** β€” not gyan.dev (gyan's "essentials" is GPLv3 and ships libx264, which violates requirement 1's license posture) and **not the static lgpl build**: LGPLv2.1 Β§6 wants relinkable object files for static linking, but the **shared** (dynamic-DLL) variant sidesteps that β€” compliance is "license text + source offer + unmodified binaries" (see `THIRD-PARTY-NOTICES.txt` and `ai.md` β†’ Licensing). Drops libx264/libx265 while keeping NVENC/QSV/AMF, libopenh264 (the LGPL-legal H.264 software fallback) and native AAC β€” exactly the requirement-1 encoder profile. 2. **Pinned URL** β€” `https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip` (~75 MB zip β€” earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention keeps the last 14 daily builds + each month-end build for 2 years, so a cold cache after retention expiry 404s β€” a logged, recoverable failure (the seam throws; the encoder step surfaces it). Once cached, the URL is never touched again. The pin is a single `const`, bumpable in one place β€” and must always stay on the **shared** variant (never `gpl`, `nonfree`, or static; see ai.md Licensing). 3. **Check-then-pull order** β€” (1) PATH probe (the user's own install wins), (2) cached `%APPDATA%\ytLlive\tools\ffmpeg.exe`, (3) download + extract. Extract `ffmpeg.exe` **plus the `libav*.dll` family** (the shared build's bin/ folder; Windows resolves the DLLs from the exe's own directory) into a staging dir then move into place β€” a crash never leaves a corrupt or partial cache. 4. **Seam** β€” `IFfmpegLocator.LocateAsync(CancellationToken)`: search dirs, tools dir, and the downloader (`Func>`) are constructor-injected with production defaults, so tests fake the network (feeding a real in-memory zip) and never touch disk outside a temp dir. **New files (all in `Services/Encoder/`):** 1. `IFfmpegLocator.cs` β€” the seam. 2. `FfmpegLocator.cs` β€” the impl (PATH probe β†’ cache β†’ pull+extract exe + DLLs), failures logged via `AppLog`. 3. `THIRD-PARTY-NOTICES.txt` (repo root) β€” the LGPL/BSD/MIT notices + source offer, copied to the build output. *(Surfacing changed on 2026-08-13: the top-bar About button that opened the file in the OS viewer is GONE β€” the notices are reachable in-app via the logo β†’ About overlay instead.)* **Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without downloading, cache hit skips the network, cold cache downloads β†’ extracts β†’ `ffmpeg.exe` lands in the tools dir, and a second call serves the cache (downloader invoked exactly once). Focused unit tests: **shared-build DLLs extract alongside the exe**, empty zip throws, missing entry throws, empty download throws, downloader failure propagates, zero-byte cache is refreshed. **Same-PR housekeeping:** requirement 2's stale binary facts corrected in this plan (~30 MB β†’ ~75 MB zip; "gyan.dev/BtB N" β†’ BtbN LGPL-shared only, with the why); the "never do" licensing guardrails recorded in `ai.md` so the reasoning survives. **Out of scope (later ship steps):** the FFmpeg subprocess encoder (frames in via stdin, stderr health parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health stats. **Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the **lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output; the "never do" licensing guardrails are recorded in `ai.md` β€” build **0 warnings**. Tests: the hermetic `FfmpegLocatorTests` integration test (PATH β†’ cache β†’ download decision ladder with a fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte cache refresh, empty payload, missing zip entry, downloader failure) β€” **78 passing**. #### Ship step 3 β€” Encoder + RTMP push (the FFmpeg subprocess) **Goal:** encode raw BGRA master frames into H.264+AAC FLV and push them to the reusable stream's RTMP ingestion URL β€” one battle-tested subprocess doing encode + mux + push (retry is app-side, TASK 32 β€” ffmpeg does not self-reconnect an RTMP output), the app feeding frames via stdin and parsing stderr for health (req 2). **Decisions (locked):** the encoder is a thin orchestrator over `ffmpeg.exe` β€” no H.264/AAC code in the app. Arguments (pure `FfmpegArgs.Build`): `-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` (frames in), a **silent placeholder audio track** via `-f lavfi -i anullsrc` (the WASAPI capture step replaces this input), `-c:v -b:v K -maxrate K -bufsize 2K` + **`-g fpsΓ—4` `-keyint_min fpsΓ—4` `-sc_threshold 0` `-bf 0` `-pix_fmt yuv420p`** (the keyframe ≀4s / closed-GOP / H.264 compliance), `-c:a aac -ar 48000 -ac 2`, `-f flv `. Encoder choice is **probed from the binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure): hardware NVENC β†’ QSV β†’ AMF, then OpenH264 software fallback β€” **never libx264** (GPL; see `ai.md` β†’ Licensing). The seam (`IFfmpegEncoder` + `IEncoderProcess`, constructor-injected locator + process factory) keeps it hermetic β€” tests fake the whole subprocess (probe + encoder), no real binary. **Behavior:** `StartAsync` (locate β†’ probe β†’ spawn β†’ stderr loop), `SubmitFrameAsync` (serialized BGRA stdin writes, ~2 Hz health via `HealthUpdated`/`StreamHealth` β€” bitrate/FPS/duration/dropped-from-frame- count), `StopAsync` (stdin EOF β†’ ffmpeg finalizes + exits by itself; 10s watchdog kill), `ProcessFailed` on a non-zero unexpected exit. **Built (2026-08-12):** `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/ `FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` in `Services/Encoder/`. Not yet constructed by the app (the frame-pipeline wiring, ship step 5, owns it). Tests: `FfmpegEncoderTests` integration (probe β†’ spawn with NVENC preferred β†’ frames into stdin β†’ progress parsed β†’ graceful stop, no kill) + units (args compliance/GOP, progress parser, picker preference + GPL guard, no-URL/not-running/noop stops, process-death `ProcessFailed`) β€” **122 passing**. #### Ship step 4 β€” WASAPI audio capture (the meter comes alive) **Goal:** capture desktop/game audio (loopback) and the picked mic, feed the mic level into `AudioLevel` so the realtime meter reads something other than 0, run capture **only while live** (req 7). **Decisions (locked):** 1. **NAudio `NAudio.Wasapi` 2.2.1** β€” the wasapi feature package (not the `NAudio` meta-package): it carries the capture types (`WasapiCapture`/`WasapiLoopbackCapture` + the MMDevice enumeration) with `NAudio.Core`/`NAudio.Asio` pulled in transitively. MIT β€” recorded in `THIRD-PARTY-NOTICES.txt` (item 9). 2. **`IAudioSource` seam** (`Start`/`Stop`/`SampleReady`/`Failed`, IDisposable) β€” the app consumes the seam; the two WASAPI implementations wrap NAudio; tests inject hermetic fakes (no real audio devices, no timers). Loopback = `WasapiLoopbackCapture` on the default render device; mic = `WasapiCapture` with the NAudio device resolved by `FriendlyName` matching `MicSourceName` (the app only persists the DisplayName), falling back to the default capture endpoint. Mic device resolution is re-read at each `Start` via a name provider so a mic picked mid-session takes effect next go-live. 3. **`AudioMixer` owns both sources** β€” starts/stops both with go-live (`BeginGoLive` success β†’ `Start`, `StopStream` β†’ `Stop`). Mic samples feed a pure `AudioLevelMeter` (RMS, exponential smoothing) and raise `MicLevelChanged`, marshalled to the UI thread into `AudioLevel`; desktop samples are currently dropped (consumed by the encoder's AAC mix in a later step). Capture failures are logged via `AppLog` (mic failure also zeroes the meter); loopback failure doesn't kill the mic. 4. **Byteβ†’float** β€” pure `WaveToFloat.Convert` handles the WASAPI mix formats: IEEE float 32-bit (direct) and PCM 16-bit (normalized to -1..1), including `WaveFormatExtensible` with the IEEE-float subformat GUID. Trailing partial samples are ignored. **Built (2026-08-12):** `Services/Audio/` ships `IAudioSource` + `AudioSample`, `WasapiLoopbackAudioSource`, `WasapiMicAudioSource`, `AudioMixer`, `AudioLevelMeter`, `WaveToFloat`; `MainViewModel` constructs the mixer (mic source fed `() => MicSourceName`), starts it on go-live and stops it on end-stream, and maps `MicLevelChanged` β†’ `AudioLevel`. A pre-existing CS8602 in `FfmpegEncoder.cs:139` surfaced during this step's rebuild and was fixed (`process!`) β€” build **0 warnings**. Tests: `AudioMixerTests` (mixer lifecycle/forwarding/failure against fakes, meter RMS/smoothing/reset, `WaveToFloat` float/PCM16/ extensible/truncation) β€” **139 passing**. **Deferred (later ship steps):** wiring the desktop-capture samples into the encoder's AAC mix (replaces the `-f lavfi -i anullsrc` placeholder; the encoder construction itself shipped in ship step 5). *(The "capture while not live" + "audio UI beyond the mic controls" deferrals were SHIPPED on the 2026-08-13 game audio bar branch β€” capture is now always-on for preview and the game bar is the second audio UI. The mixer's short-circuit meter fix + `Started`/`RestartMic` seams live in the same branch.)* #### Ship step 5 β€” Frame-pipeline wiring (the encoder gets a frame source) **Goal:** the chain `CameraManager`/`ScreenCaptureManager` β†’ compositor resolver β†’ encoder, driven while live by a paced frame pump: snapshot the active scene β†’ resolve each element to its latest frame β†’ composite into the tier's output frame β†’ pace into the encoder's stdin at the tier's FPS. **Decisions (locked via user Q&A, 2026-08-12):** 1. **Video pipeline first** β€” the `-f lavfi -i anullsrc` silent track stays; mixing the loopback/mic WASAPI samples into the encoder's AAC track is its own later step. 2. **RTMP URL via a provider seam** β€” `MainViewModel._rtmpUrlProvider` is a `Func` returning null today (the reusable stream's ingest URL lands with TASK 9); when it yields null the pump logs and skips the encoder entirely, so go-live runs the existing visual flow without pushing. **Design:** 1. `Services/Encoder/FramePump.cs` β€” the frame producer. All collaborators constructor-injected seams (`Func`, `Func` resolver, `Func`, `Func`, `Func`, `Action` log, injectable pacing delay) so it stays free of WPF and of the capture managers and is hermetic in tests. `StartAsync` never throws (failures log + surface via `Failed` β€” the VM fires-and-forgets from the sync command handler); loop = snapshot β†’ render β†’ `SubmitFrameAsync`, paced at `1/options.Fps` (default `Task.Delay`; tests inject `Task.Yield`). `StopAsync` stops the encoder (closes stdin) BEFORE awaiting the loop β€” closing stdin unblocks a write stuck on pipe backpressure, so stop can't deadlock on the pump. `ProcessFailed` self-stops the pump. `HealthUpdated` forwards the encoder's stats (ship step 6 binds the bottom bar). 2. `ScreenCaptureManager.GetLatestFrame(key)` β€” mirrors `CameraManager.GetLatestFrame(deviceId)`; the backdrop's live frame for the compositor. 3. `MainViewModel` β€” owns the resolver (`WebcamSceneConfig` β†’ `GetLatestFrame(WebcamId)`; `Source.IsLiveCapture` β†’ `GetLatestFrame(CaptureKey)`; image/background β†’ `StaticPixelCache.Get(AssetId)`), builds `CompositorOptions` from the tier + `OutputRect*` (doubles rounded to ints β€” the vertical 607.5 half-pixel crop rounds to a perfectly-centered 608), builds `EncoderOptions` from the tier when the URL provider returns one, constructs the real `FfmpegEncoder(new FfmpegLocator())`, starts the pump on go-live, stops it on end-stream, disposes in `Shutdown`, and flips `StreamStatus.Error` when the pump fails while live (minimal β€” detailed health surfacing is ship step 6). **Test plan (Good Dog Rule β€” ONE integration test):** `FramePumpTests.Start_CompositesScene_FeedsEncoder_StopsCleanly` drives the full lifecycle against fakes β€” real `SceneCompositor` + real `FramePump`, fake `IFfmpegEncoder` β€” asserting the composited red backdrop frame actually reaches the encoder at the tier size and that stop tears everything down. Units: no-URL start skips the encoder, re-entrant start/stop no-ops, encoder start-failure raises `Failed` + disposes, `ProcessFailed` self-stops the pump, `HealthUpdated` forwards. `ScreenCaptureManagerTests.GetLatestFrame_ReturnsLatestPump_UntilReleased` pins the new accessor. **Out of scope (later ship steps):** the loopback/mic β†’ AAC mix (replaces `anullsrc`), health stats in the bottom bar (ship step 6), scene-switching transitions, and any flash-frame wiring. **Built (2026-08-12):** `FramePump` shipped in `Services/Encoder/`, `ScreenCaptureManager.GetLatestFrame` added, `MainViewModel` wired end-to-end (resolver + both option builders + pump lifecycle), `FramePumpTests` (7) + `GetLatestFrame` test (1) added β€” build **0 warnings**, **147 tests passing**. Known consideration: the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation exception is contained (logged + `Failed` + pump stops) rather than crashing. #### Ship step 5.5 β€” Social bar bug fixes + the bar on the live output (2026-08-13) **Bug 1 β€” bar wouldn't reliably change position (root cause found, then simplified):** the original drag set `Canvas.SetTop(bar, …)` with a local value, which permanently overrides the `Canvas.Top="{Binding SocialBarTop}"` binding β€” the release-time `SetSocialBarPosition` β†’ `PropertyChanged(SocialBarTop)` could never beat it. First fix added direction-snapping during the drag (`SocialBarSnap.Decide`, Β±6px deadzone) + `bar.ClearValue(Canvas.TopProperty)` on release β€” but that still misbehaved for shaky hands (jitter around the deadzone: it snapped up reliably, then refused to come back down and snapped back to top). **Superseded by a click-toggle (KISS, user decision):** clicking the bar in the preview flips it top ⇄ bottom (`MainViewModel.ToggleSocialBarPosition` β†’ the existing `SetSocialBarPosition`), the bar rides `{Binding SocialBarTop}` alone (no local values, no deadzone, no jitter sensitivity), and `SocialBarSnap` was removed. The `ClearValue` lesson stands: never set a local value on a property the binding owns. **Bug 2 β€” Mastodon showed the generic 7-star honeycomb (root cause found):** the DB row `@gramps@llamachile.tube` had `Software = NULL` β€” nodeinfo was only ever resolved against the identity domain (`llamachile.tube`, a landing page), never probed for the real instance at `mastodon.llamachile.tube`. Fixed on three fronts: `HttpSocialValidator` now **probes well-known subdomains** (mastodon. β†’ social. β†’ … `FediverseSubdomainCandidates`) when the identity domain and its redirect both come up empty, under a ~15s linked-CTS budget, with an optional `Action` log; `MainViewModel` **heals** any fediverse entry missing a software name on layout load (`HealFediverseSoftwareAsync` β€” static, testable; instance wrapper runs it off the UI thread, applies via the dispatcher, saves); `SocialEntry.FediverseSoftware` is now **settable** and raises `PropertyChanged` for `LogoData`, so the heal updates the icon in place. If the user later re-saves the entry with the icon fixed, the name persists with it. **Compositor rendering (user-approved scope):** the social bar now appears **on the live output**, not just the preview β€” `Compositor/SocialBarRenderer.cs` rasterizes the entries into a transparent straight-alpha BGRA strip (1920-wide, 40px content + 24px glow pad, green `#2ecc71` glow baked in) via `RenderTargetBitmap` (WPF glue like `StaticPixelCache`; the compositor core stays pure). `SceneCompositor.Render` takes optional `socialBarFrame` + `socialBarTop` (master space) and blits it **last β€” above the branding flash** (the old `BlitFlash` generalized to offset `BlitOverlay`). `FramePump` gains a `socialBar:` seam (`Func<(VideoFrame?, SocialBarPosition)>`, re-read every frame) and places the bar at `0` or `SourceRectHeight βˆ’ bar height`. `MainViewModel` owns the frame (`RenderSocialBarFrame`, re-rendered on load/save/notify) and feeds the seam. **Tests (+6 β†’ 153 passing, 0 warnings):** settable `FediverseSoftware` updates `LogoData`, subdomain-probe unit + null-when-silent unit, the branch's **one integration test** `Socials_HealMissingFediverseSoftware_RoundTripsThroughDb` (temp-DB roundtrip: NULL software β†’ healed via the validator β†’ persisted), compositor bar overlay (top/bottom + above-flash), `FramePump` bar pass-through (Top then flipped to Bottom mid-run β€” the seam is re-read each frame), and both `ISocialValidator` fakes (`FakeValidator`/`BlockingValidator`) gained `ResolveFediverseSoftwareAsync`. The drag-snap units (`SocialBarSnap`) were removed with the click-toggle supersession. **Not included (say the word):** rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`. --- --- ## TASK 5 β€” Layout Persistence (SQLite) **Goal:** Scenes, sources, and asset bytes survive restarts; assets are always available. ### Status: βœ… Done 1. βœ… SQLite database (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned via `PRAGMA user_version` (currently **v8**) 2. βœ… Assets live in the DB (BLOB keyed by SHA-256 content hash), never file paths β€” deleting the original file never breaks a scene 3. βœ… File-model save/open β€” the active layout file is tracked (default is the AppData DB); **Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active 4. βœ… Auto-save (invisible) β€” ~1.5s debounce on scene/source add/remove/reorder/rename/hide + any source transform change; flush on window close 5. βœ… Startup β€” load the active file; seed the five canonical scenes only when the DB is empty; (+) re-adds a missing canonical scene and is hidden once all five are present; adding beyond the five is rejected 6. βœ… Schema v1 β†’ v9 β€” webcam columns (v2), singleton `Webcam` + per-scene `WebcamSceneConfig` (v3), `RectWidth`/`RectHeight` round-to-rect restore (v4), `Source.IsBackdrop` + `Source.CaptureKey` (v5), `Scene.HasBackdrop` β€” backdrop **Live-only by policy** (v6, one-time backfill + `EnforceBackdropPolicy` on every load), `Scene.HasSocialBar` (v7, dropped per-scene toggle β€” column back-compat, unread), `Socials.BarEnabled` (v8); the `SocialEntry.Software` fediverse-software column is a **column-presence migration** (commented v8β†’v9, no version bump β€” `user_version` stays 8); v9 = single-row `Music`. **Load-time rule:** sources load with `IsBackground` derived from `Type` (OR'd with the persisted column so legacy DisplayCapture backdrops keep their flag) β€” rows saved before the Type setter derived the flag heal on load 7. βœ… WindowHandle stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned. **v9 lands in the TASK 8 audio milestone** (single-row `Music` β€” `TrackPath`/`IsEnabled`) ### Design decisions 1. **SQLite database** (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned via `PRAGMA user_version`. 2. **Assets live in the DB, not on disk** β€” `Asset` table stores image bytes (BLOB) keyed by a SHA-256 content hash (unique). Identical image content collapses to one row regardless of file name β€” the 1:M resource memory model, enforced by the database. No file paths; deleting the original file never breaks a scene. 3. **File-model save/open** β€” the active layout file is tracked (default is the AppData DB). **Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active. 4. **Auto-save (invisible)** β€” ~1.5s debounce on scene add/remove/reorder/rename/hide, source add/remove/reorder, and any source transform change; flush on window close. 5. **Schema** β€” `Scene` (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder), `Asset` (Id, Hash, Data, PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled, X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder), `Socials` (Id, BarPosition, BarJustify β€” back-compat, unread, BarEnabled added via `ALTER`), `SocialEntry` (Id, SocialsId FK cascade, Service, Handle, ProfileUrl, SortOrder) β€” `user_version` **8** (v1 β†’ v2 = `ALTER TABLE` adds the two webcam columns; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey` for the live-capture backdrop; v6 = `Scene.HasBackdrop` β€” the backdrop is **Live-only by policy** (one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop sources; `EnforceBackdropPolicy` re-normalizes every load); v7 = `Scene.HasSocialBar` (per-scene toggle dropped β€” column back-compat, unread); v8 = `Socials.BarEnabled`). The `SocialEntry.Software` fediverse-software column is a column-presence migration (commented v8β†’v9, no version bump). `WindowHandle` stays in-memory (per-session). Save = transactional rewrite; orphaned assets pruned. 6. **Startup** β€” load the active file; seed the five canonical scenes (Starting/Live/BRB/Chat/Ending, `SceneCatalog`) only when the DB is empty. The (+) button re-adds a missing canonical scene and is hidden once all five are present; adding beyond the five is rejected β€” work with less, never more. --- --- ## TASK 6 β€” UI polish batch (scenes/sources rows, dedup naming, social bar) **Goal:** clean up the two side lists and the social bar per gramps's review. ### Status: βœ… Done 1. βœ… Scene rows are pure selection rows β€” the per-row edit/trash/visibility icons and the inline rename TextBox are gone (`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` + handlers + `Scene.IsEditing` removed; `IsHidden` stays persisted + dims hidden rows) 2. βœ… Source rows gained the trio β€” edit (inline rename via new `EditElementCommand` + `SceneElement.IsEditing`), visibility eye (new `ToggleElementVisibilityCommand` flips `SceneElement.IsVisible`; the eye style now binds `IsVisible`, open/slashed + row dims to 0.45 when hidden), and the existing trash 3. βœ… Duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3…) β€” next free number derived from the names actually in the scene, so deleting a middle resource never collides (`AddSource` + `AddReusedImage` both use it) 4. βœ… Social bar renders the full validated handle β€” `MaxWidth=200` + `TextTrimming` removed from BOTH `SocialBarRenderer` and the preview template (mastodon `@gramps@…` no longer cuts off) 5. βœ… Side panels stay fixed-width (left 220 / right 300) β€” deliberate: they never re-layout on resize, the preview absorbs it 6. βœ… Focus-loss capture lag documented as a known OS limit in `ai.md` β€” deferred by user decision (no code change) ### Design decisions 1. **Next free number from names, not type counts** β€” the old scheme counted elements by `SourceType` (`count == 0 ? baseName : base+count+1`), which collided after deletions; the new helper scans actual names. 2. **One WPF App per AppDomain** β€” the real-App tests (round-clip + naming) share `RealAppHost` (a dedicated STA thread owning the single `App`) via the `RealApp` serial collection, instead of each calling `new App()`. --- --- ## TASK 7 β€” Meter scaling amplification (voice meter uses the full bar) **Goal:** the mic (and game) meters feel light β€” speech peaks should peg into the red at maxed volume. ### Status: βœ… Done 1. βœ… `AudioLevelMeter.ToDisplay` now adds **+10 dB of input amplification** before the βˆ’60..0 dBFS β†’ 0..1 log mapping (was raw dB): speech peaks (~0.2 RMS, βˆ’14 dBFS) read ~0.93 β†’ red zone; normal speech (~0.05, βˆ’26 dBFS) ~0.73 β†’ yellow; background noise ≀0.001 linear (βˆ’60 dBFS) still reads 0 (the meter never idles on it) 2. βœ… Unit tests updated to the new mapping + new `ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume` (0.2 β†’ 0.92..0.95, 0.05 β†’ 0.72..0.75) ### Design decisions 1. **Amplify at the mapping, not in the VM** β€” one knob (`ToDisplay`), shared by the mic bar and the game bar; the `Γ— MicVolume` gain-on-noise behavior is untouched (raising the slider still moves ambient noise up the bar). 2. **+10 dB, not more** β€” louder boosts push quiet speech into the lower half and make the noise floor visible; +10 puts speech peaks solidly in red while idle stays at zero. --- --- ## TASK 8 β€” Audio milestone: real stream audio + voice filters + TRAX music (2026-08-14) **Goal:** every audio issue done and tested in one branch β€” real mic/game audio reaches the encoder (replacing the `anullsrc` silence), `MicVolume`/`GameAudioVolume`/mute become real pre-AAC gains, TASK 10's voice filters land, auto-duck keeps the creator's voice over game + music, and a free background-music source ("TRAX") plays into the sound bar and the stream. ### Status: βœ… SHIPPED 2026-08-14 β€” 196 tests passing, 0 warnings (ONE integration test: `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe`) 1. βœ… **Real audio into the encoder** β€” the mixer's loopback samples were **dropped today** (`AudioMixer.OnLoopbackSample` metered only) and `MicVolume` was meter-only; `FfmpegArgs.cs:28` ran `-f lavfi -i anullsrc` (silence). Transport: **ffmpeg reads a Windows named pipe** β€” `-f f32le -ar 48000 -ac 2 -i \\.\pipe\ytllive_audio` replaces the anullsrc block, plus explicit `-map 0:v -map 1:a`; pipe name via `EncoderOptions.AudioPipeName` (`DefaultAudioPipeName = "ytllive_audio"`). Mixer stays **two inputs (mic + loopback)** β€” no N-source abstraction, no `StereoMixer` class. The VM owns the pipe lifecycle (`BeginGoLive` β†’ `_audioMixer.StartLive(pipeName)`, `StopStream` β†’ `_audioMixer.StopLive()` before the pump stops); `FfmpegEncoder.cs` is untouched. 2. βœ… **Honest gains reach the stream** β€” `MicVolume` scales the mic channel; `GameAudioVolume` + mute scale the loopback channel (the "mute in preview but stream still plays" footgun dies with it). One knob per input = KISS. Implemented as `Func` gain seams on `AudioMixer` (`micGain`/`loopbackGain`), wired by `MainViewModel`. 3. βœ… **Voice filters (TASK 10, before the meter AND the mix)** β€” `VoiceFilterChain`: bass (`LowShelfFilter` 120 Hz +4 dB) β†’ treble (`HighShelfFilter` 8 kHz +3 dB) β†’ **noise gate** (pure C#, creator's choice) β†’ compressor (threshold 0.5, 4:1). Pure stateful DSP (TDF2), sine-in unit tests. Meter stays post-filter. 4. βœ… **Auto-duck** β€” pure envelope (`AutoDucker`): mic RMS > 0.02 β†’ game+music dip Γ—0.25 (~12 dB), attack 0.05 / release 0.005, recover on release. Always-on, no knobs. Strong form of the creator's "voice always over the game volume" idea. 5. βœ… **TRAX β€” background music, FREE** (keeps the one paid line = Alerts + flash removal). `MusicPlayer`: NAudio `MediaFoundationReader` (mp3/wav/m4a) β†’ `VolumeWaveProvider16` at the hardcoded **0.20** (fixed, not changeable) β†’ `WaveOutEvent` on the default device (added the sibling `NAudio.WinMM` 2.2.1 package β€” `WaveOutEvent` isn't in `NAudio.Wasapi`), loop on end. **No 3rd mixer input and no `MusicVolume`** β€” music plays on the desktop, the existing loopback picks it up: heard in headphones, the sound-bar meter bounces, and the stream carries it through the loopback channel (ducked with game when the voice is hot). Known wrinkle (out of scope): YouTube mutes VODs carrying copyrighted music β€” future "music on live, off VOD". 6. βœ… **TRAX footer control (final spec, agreed 2026-08-14)** β€” `YtButtonSecondary`, **status dot + "TRAX"** text, in the center footer stack beside MIC: - **Status dot** (same 8px Ellipse pattern as MIC/Socials): **red** no track loaded Β· **yellow** loaded not playing Β· **green** playing. - **Left-click** β†’ toggle play/pause; no track loaded β†’ opens the picker instead. - **Right-click** β†’ always opens the in-app track picker (`TraxButton_PreviewMouseRightButtonUp`, code-behind pattern like `GameSpeaker_MouseLeftButtonUp`, `e.Handled = true` so no context menu). - **Tooltip** β†’ playing/loaded track name; "No track β€” right-click to choose background music" when empty. - **No slider** β€” volume is the 0.20 constant. Picker = plain OS `OpenFileDialog` filtered to mp3/wav/m4a/aac/flac/ogg. 7. βœ… **Sound bar shows music** β€” `IsGameAudioBarVisible = gameDetectorProducingSound || isMusicPlaying`; relabelled "Game Audio Capture" β†’ **"Desktop Audio"** (TRAX rides the same channel). **Follow-up (2026-08-15): the gating is gone β€” the game audio bar is ALWAYS visible** (the `IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` stack + the VM's 250ms poll timer and their tests were deleted; the bar kept hiding the desktop meter when no full-screen game with sound was up, and the creator wants it pinned). 8. βœ… **Capture hardening (design changed at build time)** β€” the plan's "force IEEE-float 48 kHz on both WASAPI sources" was **dropped**: NAudio 2.2.1's `WasapiCapture` exposes no overridable `GetDefaultMixFormat`, so both sources capture the device's own mix format and the mixer's pure `TinyResampler` normalizes any rate/channel count to 48 kHz stereo (the resampler IS the design, not a fallback). Stop path disposes the audio pipe (EOF) in `MainViewModel.StopStream` before the pump stops, so both ffmpeg inputs end in order. 9. βœ… **Schema v9** β€” single-row `Music` (`TrackPath`, `IsEnabled`; volume is the 0.20 constant) + migration in `LayoutStore.cs` (`user_version` 9; `Save` gained an optional `Music? music = null` param so existing 3-arg callers still compile; load query + null reset). 10. βœ… **Docs in the same commit** β€” `ai.md` audio section rewritten ("one knob per input", 2-in mix, auto-duck, TRAX free, VOD-mute wrinkle), TASK 10's status, this task's status, HANDOFF. **Files (new):** `Services/Audio/VoiceFilterChain.cs` (+ `LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`), `Services/Audio/AudioRingBuffer.cs` (async-arrival source buffers), `Services/Audio/AutoDucker.cs`, `Services/Audio/TinyResampler.cs`, `Services/Audio/MusicPlayer.cs`, `Services/Audio/NamedPipeAudioWriter.cs` (+ `IAudioPipeWriter` seam), `Models/Music.cs`. **Files (changed):** `AudioMixer.cs` (filter chain before meter+mix, loopback into the mix, gains via `Func` seams, ducker, silence-filler so the pipe never stalls, `StartLive`/`StopLive`), `WasapiMicAudioSource.cs` (device mix format β€” resampler normalizes; no forced format), `FfmpegArgs.cs` (named-pipe input + `-map`, name via `EncoderOptions.AudioPipeName`), `EncoderOptions.cs` (`AudioPipeName`), `ytLive.csproj` (`NAudio.WinMM` 2.2.1 for `WaveOutEvent`), `LayoutStore.cs` (schema v9), `MainViewModel.cs`/`MainWindow.xaml`/`MainWindow.xaml.cs` (footer TRAX group, sound-bar visibility OR music, go-live starts the pipe, end closes it first). **Testing (Good Dog Rule β€” ONE integration test):** `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe` β€” real filter chain + mixer + ducker + `NamedPipeAudioWriter`, fake `IAudioSource`s, test-side `NamedPipeClientStream` reads the bytes and asserts post-filter/post-gain/post-duck mixed stereo (ring buffers pre-filled so the first pipe tick already carries real audio β€” avoids a start-of-stream silence race). Units: each DSP stage (known sine-in β†’ expected gain), ring buffer wrap/underflow/overwrite, ducker envelope, resampler (down/up/stateful/identity), `FfmpegArgs` pipe+map, schema v9 roundtrip + no-music-row. Existing tests stay green. **Out of scope (this branch):** IP webcam (video-only when it lands β€” never an audio input), chat box source (TASK 3 item 18), alt-key crop, credits, background removal, music-off-VOD, `PremiumUrl` (TASK 10 seam). --- --- ## TASK 9 β€” YouTube Live Stream Management **Goal:** Create/bind broadcasts, monitor YouTube-side stream health β€” the v3 way. ### Status: ⏳ In progress β€” items 1–3 SHIPPED (reusable stream 2026-08-16; report-by-exception health 2026-08-16); item 4 SHIPPED (status reconciled 2026-09-01); item 5 open; item 6 = deliberate lock (TASK 36); item 7 scoped to core fields β€” advanced tab is out-of-product 1. β˜‘ **Broadcast creation** β€” title/description/privacy/scheduledStartTime via API, with the v3 flags above (SHIPPED: `CreateBroadcast` sends `enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`, `selfDeclaredMadeForKids=false`) 2. β˜‘ **Reusable stream** β€” create once, cache + reuse; bind to broadcast (SHIPPED: `GetOrCreateReusableStreamAsync` lists-then-inserts the `variable`/`isReusable` stream, cached via `LayoutStore` Settings, bound at broadcast insert via `boundStreamId`; `_rtmpUrlProvider` yields the ingest URL so go-live actually encodes + pushes) 3. β˜‘ **Health monitoring** β€” poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error (SHIPPED: `GetStreamHealthAsync(streamId)` 30s while live; pure `StreamHealthReporter.BannerFor` = report-by-exception; banner strip under the top bar, amber warning / dark-red error, via `HealthIssueBanner`/`HealthIssueBackground`; poll failures log-only; ONE integration test `GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error`) 4. β˜‘ Live chat β€” poll `liveChat/messages`, render in right panel, support Super Chat + membership badges β€” **SHIPPED** (status reconciled 2026-09-01: `YouTubeChatService` polls + parses `superChatEvent`/`newSponsorEvent` with badge/level on `ChatMessage`; the panel MOVED to the left-panel live state by Control Surface UX, "right panel" superseded; the remaining reward types land with the monetization chain, TASK 10 β†’ related work) 5. ☐ Error handling β€” the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed` 6. ❌ **Visibility picker** β€” DELIBERATE TEST-PHASE LOCK (creator decision 2026-09-01), not open work: "always Private" stays during multi-month real-world testing so breakage VODs never clutter the channel. `recordFromStart`/DVR stay on β€” Private VODs are invisible review tapes; bulk-delete pre-GA. Unlock = TASK 36 gold-pass item (remove the override in `YouTubeStreamService.CreateBroadcast`, wire the dialog selection). See `ai.md` β†’ YouTube Live API constraints. 7. πŸ”Ά **Full broadcast form** β€” RESCOPED 2026-09-01, not the old two-tab plan. **Core fields (SHIPPED as the Text drawer, 2026-08-24):** title, description, tags, visibility, made-for-kids β€” live-editable. **Scheduling:** ships as **TASK 34** (Scheduled checkbox + datetime in the same drawer). **Advanced tab is PERMANENTLY OUT** (the 10% margin): latency (locked `low`), DVR/record-from-start (locked on), embed, projection, CC, region restrictions stay fixed at sane defaults, invisible β€” every exposed field is a support ticket. `categoryId` removal: done (not a `liveBroadcast` field). Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field ### Design decisions (v3) 1. **One-click go-live** β€” `liveBroadcasts.insert` with `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. No `transition(live)` call, no testing stage, no liveStarting polling. Encoder starts β†’ YouTube brings it live by itself. 2. **Variable reusable stream** β€” `cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`. Create once per channel, cache ingestion URL + stream name, reuse for every broadcast. Any quality tier works without recreation; auto step-down needs no API calls. 3. **Report-by-exception** β€” poll `liveStreams.list`; banner only on `healthStatus` warning/error issues (`configurationIssues[]`). Bottom strip = YouTube logo + green/red connection dot (clickable β†’ opens the dialog). 4. **One dialog, three states** β€” `not connected` (sign-in) / `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. 5. **Live edits** β€” `liveBroadcasts.update` with part=`snippet,status` for title/description/privacy. 6. **End stream** β€” stop encoder β†’ `transition(complete)`, with `enableAutoStop` as the safety net. 7. **Broadcast ID == Video ID** β€” one ID to track status, health, and the auto-created VOD (`recordFromStart` + `enableDvr`). ### Requirements: 1. **Broadcast creation** β€” title/description/privacy/scheduledStartTime via API, with the v3 flags above 2. **Reusable stream** β€” create once, cache + reuse; bind to broadcast 3. **Health monitoring** β€” poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error 4. **Live chat** β€” poll `liveChat/messages`, render in right panel, support Super Chat + membership badges 5. **Error handling** β€” the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed` 6. **Visibility picker** β€” Private/Unlisted/Public from the go-live dialog 7. **Full broadcast form** β€” Core + Advanced tabs with all API-supported fields --- --- ## TASK 10 β€” Monetization: watermark-only subscription + Polar billing **Goal:** annual subscription via Polar.sh that removes the branding watermark. All features are free β€” the only difference between free and paid is the watermark. **Related work β€” monetization awareness (2026-09-01, ungated, free):** this task's billing is the *payer* side; the *product* also carries built-in, ungated monetization awareness for every creator β€” reward-event capture (all 7 `liveChatMessage` reward event types β†’ canonical `RewardEvent` β†’ SQLite persistence), per-session/broadcast reports (members, Super Chat $, stickers, gifts, milestones β€” the in-app replacement for the emailed "stream activity report"), and a journey tracker (position % + ETA vs. versioned, date-aware YPP thresholds; the Tier-2 bar doubles for new applicants 2027-02-01). Details and design notes: `ai.md` β†’ Monetization. This is tracked as related work under this task's narrative, not a separate task number β€” it ships in its own code slices with one integration test each. **Scope: v1** (2026-09-01 complete-v1 ruling β€” there is no v1.x): slice order = reward-event capture β†’ session report β†’ journey tracker β†’ Alerts (TASK 3 item 20). **Business details (pricing, Polar product/checkout/discounts) in `MONETIZATION.md` (gitignored).** **Polar product:** `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (LlamaCasty, $99/yr, currently `private`) **Checkout:** `llamacasty.com` β†’ Polar hosted page (~80% configured) **Discounts:** `LLAMAFOUNDER` (100% off, 50 uses, `once`), `LLAMA50` (50% off, 12 months, unlimited) **Org ID:** `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT β€” `organization_id` can be omitted from API calls) **Key prefix:** `LCYT-` **Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation) ### Status: πŸ”Ά In progress β€” steps 1–7 shipped, Velopack update URL pending 1. βœ… **Billing provider** β€” Polar (polar.sh) chosen and configured. Product, price, license key benefit, discounts, checkout link all created. 2. βœ… **License key entry + verification UI** β€” About overlay swap panel (same pattern as Licenses panel). Text input + "Activate" button + status message. "Enter License Key" button in the hub. 3. βœ… **Offline entitlement** β€” `LayoutStore` Settings table: `LicenseKey`, `LicenseValidatedAt`, `IsPremium`. 14-day offline grace period. Startup re-validates online, falls back to cache. 4. βœ… **BrandFlash β†’ watermark toggle** β€” `IsPremium` property setter flips `BrandFlashEnabled = !value`. Flash stops immediately on premium activation. Flash asset VideoFrame rendering deferred to TASK 4 compositor. 5. βœ… **Remove social slot locks** β€” all 6 social bar slots open to everyone (no `IsPremium` gating on slots 3-6) β€” shipped as TASK 10c 6. βœ… **"Unlock Premium" button** β€” `PremiumUrl` set to Polar checkout; `IsPremiumAvailable` is true; button lights up in the About hub. 7. βœ… **Renewal/lapse handling** β€” startup re-validates against Polar API. Lapse β†’ `IsPremium = false` β†’ flash returns. Grace period: 14 days offline. 8. πŸ”Ά **Velopack auto-updates** β€” NuGet packages + bootstrap in `App.xaml.cs`. Update URL self-hosted on DO droplet (pending configuration). --- --- ## TASK 11 β€” Post-pause polish batch: the creator's 8 review issues (2026-08-15, shipped) **Status: βœ… SHIPPED 2026-08-15 β€” 197 tests passing, 0 warnings (ONE integration test: `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`).** The creator reviewed the TASK 8 build and filed 8 issues. All fixed in one branch on `main`: 1. βœ… **Desktop/game audio volume slider had no effect** (headset OR stream) β€” the `AudioMixer`'s `micGain`/`loopbackGain` seams defaulted to unity because the VM never passed them; the sliders were decorative. Fixed: new `AudioGainProvider` (`Services/Audio/`) hands `MicMuted`/`MicVolume`/ `GameMuted`/`GameAudioVolume` to the mixer at construction, read live each mix tick. 2. βœ… **Desktop/game mute button had no effect** β€” same root cause; `GameMuted` now zeroes the loopback on the stream. Locally, `MusicPlayer.LocalGain` (new) is scaled by the game bar on every volume/mute change + on TRAX load, so the creator **hears** the control work on the music (the track rides the loopback channel, so it ducks locally exactly as it does on stream). 3. βœ… **TRAX played once then stopped** β€” `MusicPlayer.OnPlaybackStopped` only looped when `Position >= Length`, unreliable for `MediaFoundationReader`; now any clean stop (`e.Exception == null`) rewinds + replays; only errors/explicit stops surface via `PlaybackEnded`. 4. βœ… **TRAX/MIC buttons swapped** β€” TRAX no longer sits between MIC and the (mic-specific) meter; the footer's mic cluster is MIC + meter + mute + volume. 5. βœ… **Mic source persists across restarts** β€” `LayoutStore` gained a `Settings` key/value table (`SaveMicSourceName`/`LoadMicSourceName`); the VM saves on pick and restores before the mixer's first `Start`, so a restart reconnects the same already-vetted device (green dot) or reports it missing (yellow) instead of falling back to the default endpoint. 6. βœ… **TRAX moved into the game audio bar** β€” left of the "Desktop Audio" label in the preview overlay (the creator's follow-up pick), no longer in the footer; its tooltip teaches the clicks ("left-click pauses/plays, right-click chooses a track"). 7. βœ… **Backdrop's icons shifted right** β€” the trash button's `Collapsed` released its column; a new `HiddenBoolToVisibilityConverter` keeps the slot reserved (`Hidden`), so edit/eye stay in their fixed columns for every source row. 8. βœ… **No separation between scenes and sources** β€” a 1px hairline with top/bottom padding now sits between the two listboxes in the left panel. ### Design decisions 1. **The gain seams are the one source of truth** β€” `AudioGainProvider` is a thin seam (four Funcs) so the mixer contract and the mute⇔zero-volume rule are testable without constructing the VM. 2. **Stream-honest AND locally audible** β€” the game bar's controls now do two things: scale the loopback on the stream and scale the music in the headphones. Native game audio is untouched (system output, OBS-style non-monitored); an OBS-style monitor loop is out of scope. 3. **Persist the DisplayName, not the device ID** β€” the FriendlyName match was already the mic's identity end-to-end; `Settings` just makes it survive restarts. --- --- ## TASK 12 β€” Master limiter on the live mix (2026-08-15) **Queued by the creator during the TRAX discussion:** "should the soundtrack be limited to avoid squandering resources / should it cap at 20% / how do the three sound events balance?" Review conclusions (all three instincts checked out, only ONE real gap): - **File size β€” no guard needed.** `MusicPlayer` uses `MediaFoundationReader`, which **streams from disk** (progressive source): memory is flat (~a few MB) regardless of file size; CPU negligible. - **20% cap β€” already enforced by construction.** `MusicPlayer.MusicVolume = 0.20f` + music rides the SAME WASAPI loopback (and therefore the same gain) as game audio, so music:game is always exactly **0.20:1 at any slider position** β€” it literally cannot rise above 20% of the current desktop volume. - **The gap:** `AudioMixer.FillAndMix` summed mic + loopback with **no output ceiling** β€” mic 100% + loud game/music could pass 0 dBFS and clip the AAC encode. **Shipped:** 1. βœ… **`Services/Audio/MasterLimiter.cs`** (pure, unit-tested) β€” a **βˆ’1 dBFS ceiling** (`Ceiling = 0.891`), **instant attack per frame** (a hot frame is scaled exactly to the ceiling β€” no overshoot), **smoothed release** toward unity so loud passages don't pump; gain never exceeds 1 (no boosting). Applied at the end of `AudioMixer.FillAndMix`, right before the pipe write. 2. βœ… **Unit tests** β€” over-ceiling frames trimmed to ≀ ceiling; sub-ceiling frames never boosted; gain recovers to unity after the loud frame ends. 3. βœ… **ONE integration test** (`MasterLimiter_CapsTheLiveMix_OnThePipe`) β€” real mixer + pipe harness: a 0.95 loopback bed (hotter than the ceiling) is capped to exactly 0.891 on the wire while staying audible. 4. βœ… **Docs in the same commit** β€” `ai.md` (go-live audio section), `Services/index.md` (new row). No changes to `MusicPlayer`, the 0.20 cap, the ducker, or the meter zones. Build 0 warnings. --- --- ## TASK 13 β€” Social media launch kit **Goal:** marcom/social media assets and strategy for v1 launch. **Business details (positioning, messaging, platform strategy, launch assets) in `MARCOM.md` (gitignored).** ### Status: πŸ”Ά Scoped β€” nothing built; queued after all v1 features ship 1. ☐ Product positioning & messaging (one-liner, elevator pitch, competitive positioning) 2. ☐ Social media swipe files (pre-written posts for supporters) 3. ☐ Launch day assets (demo video, screenshots, GIFs, social graphics, press kit) 4. ☐ Platform strategy (YouTube, Reddit, indie dev communities, Product Hunt) 5. ☐ Founder story (67-year-old dev building his own streaming app) 6. ☐ Email announcement templates 7. ☐ "Build in public" livestream angle (stream the coding of ytLlive with ytLlive) --- ## TASK 14 β€” Creator feedback batch: quick fixes (TRAX, meter, backdrop, sliders) **Goal:** address creator's immediate UX feedback from testing session. ### Status: πŸ”Ά In progress β€” Branch 2 shipped 1. βœ… TRAX volume β€” `LocalGain = GameAudioVolume * 0.2` so TRAX is 20% of the desktop audio slider (was 100%, now quieter) 2. βœ… TRAX tooltip β€” replaced `ToolTip` binding with `ToolTipService.ToolTip` + `Placement="Top"` to fix z-order inside Viewbox overlay 3. βœ… Mic meter boost β€” `MeterLevel` multiplied by 1.2Γ— before clamping so the bar reads ~20% higher at same input level 4. βœ… Click-to-position sliders β€” clicking anywhere on the mic/game volume slider track now jumps the thumb to that position before starting the drag 5. βœ… Backdrop visibility β€” the dedicated `` now binds `Visibility` to `BackdropVisible` (ViewModel property); compositor checks `IsVisible` before blit 6. βœ… Rename "Backdrop" β†’ "Game Capture" in `EnsureBackdrop()` + test assertions 7. βœ… Elements panel β€” new "ELEMENTS" section beneath Sources list; per-element config with live preview, βœ• revert, βœ“ applied indicator 8. βœ… Webcam border config β€” color hex input + color swatch picker, border thickness 0–10 slider 9. βœ… Countdown source β€” `SourceType.Countdown`, pick list (Starting/BRB scenes only), timer minutes 1–60 default 15 10. βœ… Web source β€” `SourceType.WebSource`, pick list (all scenes), URI text input 11. βœ… Elements scrollbar deselect fix β€” clicking the Elements panel scrollbar no longer deselects the selected element (scrollbar lives inside the ScrollViewer, not the StackPanel; guard now checks `ElementsScrollViewer` instead of `ElementsPanel`) 12. βœ… Web source URI + buttons inline β€” merged URI TextBox and check/revert buttons into a single DockPanel row (βœ“ rightmost, βœ• left); buttons no longer wrap to a separate line 13. βœ… Web source URI hidden for webcam β€” URI section was showing for webcam due to WPF binding path `SelectedElement.Type` not resolving on `WebcamSceneConfig`; replaced with `IsWebSource` virtual property on `SceneElement` (mirrors existing `IsWebcam` pattern), overridden in `Source` to return `Type == SourceType.WebSource`, bound via `BoolToVis` 14. βœ… Check/revert consistent order + accept β€” all three element sections (webcam border, countdown timer, web URI) now use consistent [βœ•][βœ“] button order; green check buttons enabled with click handlers that save current values as snapshot baseline (so revert undoes to last accepted state, not initial selection) --- ## TASK 15 β€” Stock scene background images **Goal:** branded background images for each canonical scene (Starting, Live, BRB, Chat, Ending) that both provide working hints to creators and self-promote LlamaCasty. ### Status: βœ… Shipped β€” all 5 scenes seeded 1. βœ… **Starting backdrop** β€” `Assets/starting-backdrop.jpg` (embedded resource), `SeedStartingBackdrop()` in MainViewModel, idempotent 2. βœ… **BRB backdrop** β€” `Assets/brb-backdrop.jpg` (user-provided Gemini image), `SeedBrbBackdrop()` in MainViewModel, idempotent 3. βœ… **Live backdrop** β€” `Assets/live-backdrop.jpg` (user-provided image), seeded via `SeedLiveBackdropAsset()`, used as the static fallback (TASK 16) 4. βœ… **Ending backdrop** β€” `Assets/ending-backdrop.jpg` (user-provided image), `SeedEndingBackdrop()` replaces existing (not skip-if-exists) 5. βœ… **Chat backdrop** β€” `Assets/chat-backdrop.jpg` (user-provided image), `SeedChatBackdrop()` at startup 6. βœ… `EnsureDefaultBackdrop()` called on every scene switch β€” seeds the default backdrop if scene has none ### Design decisions - Images are user-provided, not generated β€” the creator owns the brand look - Each scene gets its own image (Starting = "Starting Soon", Live = live branding, BRB = "Be Right Back", etc.) - The images double as the no-game backdrop fallback (TASK 16), so the Live scene image should work as a desktop replacement --- ## TASK 16 β€” Kill infinity display (no-game backdrop fallback) **Goal:** when no full-screen game is detected, show a static branded placeholder instead of capturing the primary display (which causes the infinity mirror effect). ### Status: βœ… Shipped **Changes:** 1. `ResolveAutoCaptureKey()` returns `null` when no full-screen game is detected (was: primary monitor) 2. `ReacquireScreenCaptures()` β€” clears stale `CaptureKey` when `auto == null` (handles DB upgrades); skips `AcquireAsync` when no keys are set 3. `SeedLiveBackdropAsset()` β€” sets `AssetId` on the backdrop element itself (NOT a Background source); called from `ReacquireScreenCaptures()` after `EnsureBackdrop()` guarantees the element exists 4. `Source.DisplaySource` falls back to `_imageSource` when `VideoImageSource` is null for live captures 5. `Assets/live-backdrop.jpg` β€” user-provided image, added as `` in csproj 6. `ResolveOutputFrame()` in compositor β€” `Source { IsLiveCapture: true, CaptureKey: not null }` already skips null keys; fallback pattern `Source { AssetId: not null }` picks up the static image **Rendering flow:** | Scenario | Preview | Compositor | |---|---|---| | Game detected | Live capture (opaque, covers static asset on backdrop) | `ScreenCaptureManager.GetLatestFrame` | | No game | Static image via `DisplaySource` β†’ `_imageSource` on backdrop | `StaticPixelCache.Get(assetId)` | | Manual capture pick | Live capture | Live frame | --- ## TASK 17 β€” Web source rendering (WebView2) **Goal:** make the web source actually render URLs into the preview and stream output. ### Status: βœ… Done β€” required for v1 (2026-08-28) 1. βœ… Add `Microsoft.Web.WebView2` NuGet package 2. βœ… Schema v10: `WebUri TEXT` column on `Source` table + migration in `LayoutStore.cs` 3. βœ… Persist `Source.WebUri` on save/load (currently in-memory only β€” lost on restart) 4. βœ… Hidden off-screen `WebView2` control per web source β€” navigates to `WebUri`, renders in-app 5. βœ… Frame capture from WebView2 (`CoreWebView2.CapturePreviewAsync`) β†’ `WriteableBitmap` (BGRA8) 6. βœ… Wire into `FramePump` resolver β€” `Source { Type: WebSource }` β†’ latest WebView2 frame 7. βœ… Wire into `SceneCompositor` β€” render web source as an image element at its position/size 8. βœ… Preview shows live web content (not just a blank rectangle) 9. ☐ Handle navigation errors, invalid URIs, timeout gracefully **Rendering model (2026-08-28, canvas-size viewport + ALPHA-BBOX crop β€” DONE, creator-verified):** the page renders at the MASTER CANVAS size (1920Γ—1080), stable, never tracked (no reflow/truncation; scrollbars suppressed via `overflow:hidden`). `FindContentBounds` scans the Bgra32 capture and crops to the bounding box of non-transparent pixels β€” the widget's true rendered extent, measured from the frame itself (NO DOM query, immune to layout timing; can never truncate content; full-canvas widgets fall through to the full frame = prior verified-good image). `Stretch="Fill"` maps the cropped frame flush under the selection box β†’ box hugs the widget on all four sides. `QueryContentBoundsAsync` + the JS content-bounds script are DELETED. **RESULT: bounding verified PERFECT by the creator with two widgets.** Root-cause note on the earlier "remaining defect/gap": that was a WIDGET GLOW EFFECT (the widget's own CSS glow pushes out its perceived borders), not a code bug β€” the alpha-bbox was already hugging the glow halo. Lesson: test with a second, plain widget before changing code. ⚠️ Lesson bank: never return `JSON.stringify` from `ExecuteScriptAsync` (double-encodes); never measure DOM stuff on NavigationCompleted (unsettled layout broke the image β€” `a5b9952`); avoid sizing the container to the crop (`ab29ec8` reverted β€” made it worse); a widget glow effect can fake a gap β€” verify with a plain widget first; measure rendered pixels instead. **Properties panel (2026-08-28):** web URI βœ“/βœ• icon buttons are `IsTabStop="False"` so Tab flows Xβ†’Yβ†’Wβ†’Hβ†’URI; the βœ• button now clears the URI textbox (was reverting to the pre-accept snapshot). Slider style gained `IsMoveToPointEnabled="True"` β€” click-anywhere-on-bar jumps the thumb to the click (volume sliders keep their manual `SetSliderValueFromClick`, harmless duplication). ### Design decisions - WebView2 is the only option for Windows β€” it's pre-installed on Windows 10 20H2+ and Windows 11 - The web source is a standard element β€” positioned/sized/opacitied like any image source - Frame capture rate can be lower than video FPS (5-10 fps for web content is fine) - This enables Streamlabs/StreamElements overlays via web URLs --- ## TASK 18 β€” Local recording **Goal:** record the stream output to a local file, with or without simultaneously streaming. ### Status: βœ… Shipped `a9eb360` (2026-08-29) β€” code done (incl. manual-rename modal), build 0 warnings, 244/246 tests; remaining = running-app verification 1. βœ… `EncoderOptions` extended with `StreamEnabled` / `RecordEnabled` / `RecordPath` (independent intent flags) 2. βœ… `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags` 3. βœ… Three modes via pills: record-only / stream-only / stream+record (single ffmpeg, dual output) 4. βœ… Top-bar REC + ON-AIR pill toggles, status lights (REC green when recording, ON-AIR green when live), dynamic primary-button text, account status-light tooltip 5. βœ… Record-folder persistence (`LayoutStore` `RecordFolder` key) + picker (`ChooseRecordFolderCommand`) 6. 🟑 Branding flash carries into local recordings β€” same frame path as streaming (verify in the running app) 7. βœ… Output folder `%APPDATA%\ytLlive\recordings\` default; auto-name `ty---0000.mp4` at start, **rename-on-stop** to `ty-…-.mp4` (numeric suffix on collision) Remaining: running-app verification of the rename + dual output. ### Design decisions - FFmpeg supports multiple outputs natively (`-f flv rtmp://... -f mp4 file.mp4`) β€” no second subprocess needed - MP4 is the default container (widely compatible); MKV as an option (crash-safe, can be remuxed) - Record-only mode is useful for pre-recorded content or testing without going live - The branding flash carries into local recordings (free tier billboard extends to recordings) --- ## v1 execution order The tasks below are ordered by dependency and risk. Each task builds on the previous. 1. βœ… **TASK 9.4** β€” Live chat (right panel) β€” wire `YouTubeChatService.Start()`, parse messages, render in panel. Foundation for chat box source. 2. βœ… **TASK 3.18** β€” Chat box source β€” renders chat ON the stream. Depends on TASK 9.4 (same message parsing). 3. βœ… **TASK 10** β€” Polar billing β€” license key entry + watermark toggle + Velopack auto-updates (steps 1-7 shipped; Velopack update URL pending). 4. βœ… **TASK 19/23** β€” Control Surface UX β€” director's control room: thumbnails above central monitor, transitions (Cut/Fade/Move), edit mode offline only, left panel two-state (layers/props ↔ chat), right panel eliminated. Verified shipped 2026-08-24. 5. βœ… **TASK 20** β€” Hotkeys β€” global keyboard shortcuts. Shipped 2026-08-26: F1-F9 defaults + config UI with modifier chords, persistence, conflict detection, unbinding. 5b. βœ… **Broadcast metadata pull-out + launch geometry** β€” shipped 2026-08-24 (row 29 above). Live-screen "Text" tab β†’ broadcast form, persistence + remote update; window default/minimums bumped (1920Γ—1040 / 1366Γ—768) with size+position restore. 6. βœ… **TASK 17** β€” Web source (WebView2) β€” shipped 2026-08-28; enables alert ecosystem. 7. βœ… **TASK 18** β€” Local recording β€” shipped 2026-08-29 (running-app verification pending in handoff). 8. πŸ”Ά **TASK 21** β€” Media source β€” video file playback for non-Live scenes. **Remaining: the UI picker slice** (handoff spec in HANDOFF.md). 9. βœ… **TASK 22** β€” Audio sync offset β€” shipped 2026-08-31 (status reconciled 2026-09-01). 10. βœ… **TASK 15** β€” Stock bg images β€” all 5 scenes seeded (Starting, BRB, Live, Chat, Ending). `EnsureDefaultBackdrop()` runs on every scene switch. 11. **TASK 32** β€” Stream resilience (blip retry β†’ grace countdown on the ON-AIR sign β†’ one-click Back on air β†’ crash-safe recording β†’ pre-flight). 12. **TASK 33** β€” Bandwidth auto step-down (depends on TASK 32's restart machinery). 13. **TASK 34** β€” Scheduled streams (Text-drawer schedule + adoption at Start). 14. **TASK 35** β€” Scene-linked audio ("scenes remember the room"). 15. **Text source (TASK 3 item 17)** + **the monetization-awareness chain + Alerts (TASK 10 related work β†’ TASK 3 item 20)** β€” capture β†’ report β†’ journey β†’ alerts, one slice per integration test. 16. **TASK 13** β€” Social media launch kit (MARCOM) β€” after features, before gold. 17. **TASK 36** β€” Gold pass, always last: visibility unlock + flash-live enable + screens/layers audit + native verification suite + expiry reminders + signing/installer/EULA/Velopack. --- ## TASK 19 β€” Scene transitions β†’ MERGED into TASK 19/23 (Control Surface UX) **Superseded by the Control Surface UX task.** Cut/Fade/Move transitions, thumbnails, edit mode, left panel two-state β€” all shipped as one cohesive feature. See the Control Surface UX section in `ai.md`. ### Design decisions - **Cut is free** β€” instant switch, no blending, zero CPU cost. This is the default. - **Fade is the minimum expectation** β€” 300ms crossfade is universal. Every streaming tool ships this. - **Move is economical** β€” simple slide animation, no video decoding needed. Good middle ground. - **Custom is premium** β€” media/stinger transitions require video playback. Heavy but expected by mid-tier streamers. - Transitions happen in the compositor, not post-encode. The preview sees the same blend as the live output. --- ## TASK 20 β€” Hotkeys (keyboard shortcuts) **Goal:** keyboard shortcuts for scene switching and common actions β€” the single biggest UX gap. ### Status: βœ… Done β€” shipped 2026-08-26 (steps 1-2; all 9 checklist items complete; handoff flags it shipped) 1. βœ… `GlobalHotkeyManager` service β€” `Services/GlobalHotkeys.cs`: registers OS-level hotkeys on the window HWND via `RegisterHotKey`/`WM_HOTKEY` (`IHotkeyRegistrar` seam for tests; `RegistrarOverride` mirrors `LayoutPathOverride`). Wired in `MainWindow.OnSourceInitialized`. 2. βœ… Scene switching hotkeys β€” F1-F5 stage/transition the five canonical scenes. 3. βœ… Common action hotkeys β€” F6 start/end stream, F7 mute mic, F8 mute desktop audio, F9 TRAX play/pause. Dispatch lives in `MainViewModel.HandleHotkey` and honors command gating. 4. βœ… Hotkey configuration UI β€” `HotkeyConfigDialog` (XAML Window + `HotkeyConfigViewModel`). Click-to-capture, "Unbind" per row, Reset to Defaults. Conflict detection shows a warning; user must unbind the conflicting action first. Modifier chords supported from day 1 (Ctrl+F10, Alt+Shift+F12, etc.). 5. βœ… Global hotkeys β€” work even when app is not focused (that is what `RegisterHotKey` gives us) 6. βœ… Conflict detection β€” warns when a chord is already bound to another action; user must unbind first. 7. βœ… Persistence β€” `LoadHotkeyBindings()` / `SaveHotkeyBindings()` in `LayoutStore`, stored as `"modifiers+vk"` in Settings table. No schema migration needed. 8. βœ… Test: `GlobalHotkeyTests.WmHotkey_StagesScene_And_TogglesMic_And_RevokesOnClose` β€” real window + fake registrar, a genuine `WM_HOTKEY` posted through the window's own `HwndSource` hook stages the mapped scene and flips mic mute; close revokes every registration. 9. βœ… Test: `HotkeyConfigTests` β€” round-trip persistence, display string formatting, storage serialization. ### Design decisions - **Global hotkeys are mandatory** β€” streamers are in-game and cannot alt-tab. F1-F5 must work from anywhere. - **Modifier chords from day 1** β€” Ctrl+F10, Alt+F1, etc. for Stream Deck support and power users. - **Conflict = warning, not swap** β€” user must unbind the conflicting action before reassigning. No surprise reassignments. - **Unbinding** β€” every action can be set to "None" (no hotkey). Explicitly unbound actions are absent from the DB. - **No Stream Deck yet** β€” bare keyboard first. Stream Deck support (physical devices) is v1.1+. --- ## TASK 21 β€” Media source (video file playback) **Goal:** play video files (MP4, MOV, AVI) into scenes β€” starting soon videos, BRB loops, intro/outro clips. ### Status: πŸ”Ά In progress β€” Increment A (model + persistence) + Increment B (decoder) + slice 1 (manager + resolver/preview wire-in) + slice 2a/2b (ffprobe probe + native-FPS pacing) + slice 3 (loop mechanism) shipped. **Remaining: the UI picker slice (AddMedia command + file dialog + Acquire/Release wiring + loop-flag wiring) β€” GUI, handed off to build/verify natively on Windows (see HANDOFF β†’ "UI picker slice β€” handoff spec").** 1. βœ… `MediaSourceType` enum: `Video`, `Audio` (audio-only files via media source) 2. βœ… `SourceType.MediaSource` addition to the enum 3. βœ… `MediaSourceModel`: `MediaPath`, `MediaIsLooping`, `MediaVolume` (0-1), `MediaPlaybackState` β€” persisted in LayoutStore (schema migration + SELECT/INSERT + round-trip test) 4. βœ… `MediaVideoSource` implements `IMediaFrameSource`: FFmpeg raw video decoder β†’ `VideoFrame` pipeline β€” spawns ffmpeg `-f rawvideo -pix_fmt bgra`, drains the pipe via pure `RawVideoFrameReader` (`Services/RawVideoFrameReader.cs`), raises `FrameAvailable`/`Completed`; lifecycle is `StartAsync`/`StopAsync`; decode process behind `IDecodeProcess`/`FfmpegDecodeProcess` seam (binary-stdout mirror of `IEncoderProcess`). Shipped 2026-08-31; refactored onto `IMediaFrameSource` with this slice. 4b. βœ… Slice 2a β€” native-FPS probe seam: `FfmpegFrameRateParser` (pure, prefers `avg_frame_rate=` then `r_frame_rate=`, rational N/N/M, unknownβ†’null) + `IFrameRateProbe`/`FfmpegFrameRateProbe` (derives sibling `ffprobe.exe` from the located ffmpeg dir, reuses the `IDecodeProcess` seam; null if ffprobe absent) + `FfmpegLocator.ProbeFileName` now also extracts `ffprobe.exe` from the pinned archive (conditional). Tests: `FfmpegFrameRateParserTests` (6 pure units + 1 probe integration via fake locator/process) + `FfmpegLocatorTests` still green β€” 15/15. 5. βœ… `IMediaFrameSource` + `MediaVideoSourceManager` (`Services/IMediaFrameSource.cs`, `Services/MediaVideoSourceManager.cs`): app-wide decode-session ownership refcounted by `MediaPath` with a `Func` factory seam; `AcquireAsync`/`ReleaseAsync`/`ReleaseAllAsync`/`GetLatestFrame`; coalesces frames onto the UI dispatcher onto a single shared `WriteableBitmap` per file (mirror of `ScreenCaptureManager`); `MediaFailed` + `PreviewBitmapChanged`. Unit tests + one integration test (bitmap share/coalesce) β€” `MediaVideoSourceManagerTests`, 5/5 pass. 6. βœ… Native-FPS pacing (slice 2b): `MediaVideoSource` takes optional `IFrameRateProbe?` + `Func? delay` seams, probes FPS once in `RunAsync`, and delays by 1/fps after each emitted frame; unknown/absent probe β†’ no pacing. Test: `MediaVideoSource_PacesFramesByProbedFps` (fake probe + recording delay, one delay per frame β‰ˆ 1ms). 7. ☐ Wire into `FramePump` resolver β€” `Source { Type: MediaSource }` β†’ latest video frame β€” **resolver + preview routing + manager wired; session acquisition (start/stop on add/remove) still open (comes with the UI picker)** 8. ☐ Wire into `SceneCompositor` β€” render media source as an image element at its position/size 9. βœ… Loop control (mechanism) β€” `IMediaFrameSource.Looping`; `MediaVideoSource` takes a `Func` process factory and restarts the decode on natural EOF when `Looping` (fresh process per pass, since a `Process` can't be re-`Start()`ed). Test: `MediaVideoSource_LoopsUntilLoopDisabled` (single frame re-emits across passes, `Completed` only after loop cleared). Wiring `Source.MediaIsLooping` into the flag lands with the UI-picker (acquisition) slice. 10. ☐ Volume control β€” per-source volume slider for audio playback 11. ☐ UI: file picker (filtered to video formats), loop toggle, volume slider 12. ☐ Schema migration for media source settings (file path, loop, volume) 13. ☐ Tests: video frame extraction, loop behavior, volume scaling, file validation ### Design decisions - **FFmpeg handles all formats** β€” no codec-specific code. FFmpeg already in the project. - **Audio plays through the desktop channel** β€” media source audio is captured by the WASAPI loopback (like TRAX/game audio). No separate audio routing needed. - **This pairs with TRAX** β€” TRAX is background music, media source is background video. Together they make non-Live scenes (Starting/BRB/Ending) feel polished. - **Not a full NLE** β€” no trimming, no multi-track, no effects. Just "play this video in the scene." --- ## TASK 22 β€” Audio sync offset **Goal:** per-source audio delay compensation to prevent lip-sync drift from USB mics and capture cards. ### Status: βœ… Done (shipped TASK 22, 2026-08-31) 1. β˜‘ `AudioSyncOffsetMs` on `MainViewModel` (global, default 0, range 0..500 ms) β€” positive-only: OBS's documented fix delays the audio so it lands on video when it runs ahead; advancing audio would need a video-side delay (out of the audio layer's scope, v1.1+). 2. β˜‘ Applied in `AudioMixer` β€” post-mix interleaved-stereo delay via the pure `AudioSyncDelay` line (`Services/Audio/AudioSyncDelay.cs`), fed through a `Func syncOffsetMs` seam each mix tick. 3. β˜‘ UI: compact "SYNC" slider (0..500) on the mic bar with a status dot (green = no-op, amber = offset set) via `IntToSyncBrushConverter`. 4. β˜‘ Persisted in `LayoutStore.Settings` (`Audio.SyncOffsetMs`) via `LoadAudioSyncOffsetMs`/`SaveAudioSyncOffsetMs`; saved from `SaveLayoutNow`. 5. β˜‘ Tests: `AudioSyncDelayTests` β€” zero-delay identity, negativeβ†’0 clamp, >500 ms clamp to 500 ms, and 10 ms β†’ 960 interleaved-sample shift. ### Design decisions - **Global offset first** β€” one setting for all audio sources. Per-source is v1.1+. - **Positive-only (delay audio)** β€” the physically-correct direction (audio runs ahead of the video). True "advance" needs a video-side delay and is tracked as per-source/advance in v1.1 (line 1388). - **Simple slider** β€” 0 to +500 ms, default 0. No numeric input needed. - **Visual feedback** β€” "sync OK" status dot shows when an offset is dialled in. --- ## TASK 23 β€” Studio mode β†’ MERGED into TASK 19/23 (Control Surface UX) **Superseded by the Control Surface UX task.** Studio mode is now the entire app's paradigm β€” the director's control surface with 5 thumbnails + central monitor. See the Control Surface UX section in `ai.md`. --- ## TASK 24 β€” Toast notifications (in-app, non-blocking) ### Status: βœ… Done (shipped 2026-08-23, branch `task24-notifications`) Replace blocking MessageBoxes with in-window toasts; promote actionable log-only failures. ### Requirements: 1. Library: **Notification.Wpf 11.0.0** (Platonenkov fork of Federerer/Notifications.Wpf) β€” MIT, active, targets net8.0-windows. Notice #10 added to `THIRD-PARTY-NOTICES.txt`. 2. Placement: bottom-right above the footer (`NotificationArea x:Name="ToastArea"`, last child of MainWindow's root grid β†’ topmost z-order, `Grid.RowSpan=4`, `Margin="0,0,12,96"`, outside the preview Viewbox), max 4 stacked. 3. Behavior: Info ~4s / Success ~4s / Warning ~8s auto-dismiss; **Error sticky until dismissed**. 4. Styling: dark card tints matching the health-banner language (slate `#3a3b52` info, green `#1d5c38` success, amber `#b8860b` warning, dark-red `#8f1f1f` error); corner radius 8; keep-visible-on-hover. 5. Threading: all Show calls marshal via a Dispatcher captured at construction (encoder/pump events fire off-thread). ### Migrations (MessageBox β†’ toast): - Webcam acquire failure Γ—2 (`AddWebcamToStagedSceneAsync`, `SwapWebcamIdentityAsync`) β†’ Warning - Image read failure (`PickImageBytes`) β†’ Warning - Sign-in unsuccessful β†’ Warning; sign-in failed β†’ Error ### Promotions (log-only β†’ toast, each keeps its AppLog line): - Go-live prep Γ—3: reusable stream unavailable / broadcast insert failed / prep exception β†’ Error - Frame-pump death while live (`OnFramePumpFailed`) β†’ Error - Mic missing at startup (`StartMicCaptureAsync`) β†’ Warning (once) - Premium lapse at startup re-validation β†’ Warning - Saved-session refresh failure (signed out notice) β†’ Info Deliberately left alone: offline license re-validation skip (log-only β€” would spam every launch); SocialsDialog slot-delete YesNo confirm (stays modal). ### Tests: - Unit: pure severityβ†’request mapping (`NotificationServiceTests`) β€” area routing, lifetimes, tints. - Integration (the ONE): real window-hosted `ToastArea` + real service β€” Info auto-dismisses, Error sticks (`NotificationAreaIntegrationTests`, RealApp collection). ### Library facts (cost a hunt β€” see also ai.md): - Area routing matches the area's XAML `Name` against the request's `AreaName`; unknown name silently drops. - `NeverExpires()` = `ExpirationTime = TimeSpan.MaxValue` (not null); `NotificationColor.ToHex()` returns `#AARRGGBB`. - Overlay-window shutdown caveat avoided entirely by using the in-window area mode. --- ## TASK 25 β€” Background consolidation: one locked Background per screen + mini-view rule ### Status: βœ… Done (shipped 2026-08-23, branch `task25-backgrounds`) The creator rejected the multi-concept background model ("why are there multiple background things?"). Locked model, verbatim intent: 1. **Exactly ONE background per screen**, named "Background", at position 0. 2. It cannot be re-ordered or deleted; **no Background/Screen item in the (+) menu**. 3. Context-sensitivity exists **only on the Live screen** (Show Desktop toggle + monitor switching β€” behavior kept intact). 4. **Exactly five default background images** live in the DB; stale rows are cleaned up. ### What shipped: - **Seeder consolidation:** the five near-clone seeders (`Seed{Starting,Brb,Ending,Chat}Background`, `SeedLiveBackgroundAsset`, `EnsureDefaultBackground`) are gone. One path now: `EnsureBackground(scene)` β†’ `CreateBackground(name)` β€” Live = `DisplayCapture` row (capture machinery untouched), everything else = static `Background` art row; both named "Background". - **Heal on every load:** `NormalizeBackgrounds(scenes)` keeps the correctly-flavored row, converts a wrong-flavor survivor in place (`Source.Type`'s setter derives `IsBackground` β€” conversions must re-assert the flag), drops duplicates, seeds missing ones, renames, pins to index 0; non-canonical scenes lose backgrounds + flag. `HealBackgrounds()` stamps default art (`Assets/{scene}-background.jpg` via `AddAsset`; custom Browse art wins). This fixed the real DB's rot: four scenes carried a stray "Game Capture" duplicate beside their static row, and Settings held 16 orphaned `BackgroundUseDefault_{guid}` keys. - **Settings purge:** `LayoutStore.Save` deletes `BackgroundUseDefault_{id}` / `BackgroundPath_{id}` keys whose id is no longer a Source row. - **(+) menu:** Screen + Background items removed β†’ Webcam/Image/Text/Countdown/Web/YouTube Chat; `AddSource` also hard-refuses DisplayCapture/WindowCapture/Background parameters. - **Capture controls Live-only:** `CanChangeBackground` = staged scene is Live. The preview CanvasGrid menu (Show Desktop/Capture Desktop/Refresh Desktop) binds it directly; the layer-row context menu MultiBindings it with the row's `IsBackground` through a new `Helpers/AllTrueToVisibilityConverter`. Non-Live screens keep the Use-default pill + Browse. - **Mini rule:** minis never render live captures. While Live is staged its mini shows the green placeholder; unstaged it shows `live-background.jpg`. Fixed by making `LoadBackgroundImage` flavor-blind (`IsBackground`) and gating staged-Live to the placeholder in `RefreshSnapshotsAsync`. (Real-time rendering stays center-monitor-only β€” preview lag during live gameplay is a known unsolved OS-level problem and must not be compounded.) ### Tests (suite went 221/218 β†’ 223/220; the two stale background-policy landmine tests healed here): - Integration (the ONE): `BackgroundHealIntegrationTests` β€” seeds a dirty temp DB (duplicate rows, misnamed Live row, orphaned keys), drives the real window, asserts one Background per scene at index 0 with correct flavor/name, save purges the orphaned keys. - Unit: SceneCatalogTests rewritten for NormalizeBackgrounds/EnsureBackground flavors; BackgroundTests empty-scene flavor updated; SourceNamingTests excludes the always-present Background from numbered-name expectations. --- ## TASK 30 β€” Gear menu cleanup + Default Location for Recordings + preview layout restructure ### Status: βœ… Done (shipped 2026-08-29, commits `6031a56` β†’ `61b850f` β†’ `455a409`) The creator's KISS review: gear menu cleanup, single Start button, record-location setting, and a full layout restructure moving all live controls into the preview pane. ### What shipped: **Gear menu cleanup:** - Removed `Save Layout` / `Save Layout As…` / `Open Layout…` from Gear menu + dead VM commands/methods (`SaveLayoutCommand`, `SaveLayoutAsCommand`, `OpenLayoutCommand`, `SaveLayoutAs()`, `OpenLayoutFile()`). Multi-layout = use OBS. **Default Location for Recordings (App Settings):** - First real setting panel in the Gear menu: "Default Location for Recordings". - `DefaultRecordFolder()` (internal static): configured β†’ `%USERPROFILE%\Downloads` β†’ `MyVideos`. - `RecordFolderDisplay` property, `ResetRecordFolderCommand`, Browse + "Use Downloads" reset. - `TextBox` binding fixed to `Mode=OneWay` (get-only computed property). - ONE fallback test: `DefaultRecordFolder_Falls_Back_To_Downloads`. **Single Start button:** - Button `Content="Start"` hardcoded in XAML. Removed `PrimaryButtonText` / `IsStreamingStart` properties + all `OnPropertyChanged` references. Pills = intent, lights = state, button = trigger. **Preview layout restructure β€” all live controls under preview:** - Socials button moved from footer (scenes/sources column) to preview bottom row, left of TRAX button. `MouseEnter`/`MouseLeave` handlers pulse the `SocialBarElement` `DropShadowEffect` (sine wave BlurRadius 24β†’48, Opacity 0.7β†’1.0 at 30ms tick). - **Mic controls moved from footer into preview pane** (vertical StackPanel below Socials+TRAX): MIC button + sound meter + mute grid + volume slider β€” the same footer controls, relocated. - Preview bottom row: vertical `StackPanel Grid.Row="2"` β†’ Line 1 (Socials + TRAX), Line 2 (MIC + meter + mute + slider). All centered. **Bottom bar restructured:** - Reduced to single row (mic row removed), tighter padding (`Padding="16,4"`). - Gear icon moved to far left. Stream stats (Bitrate/FPS/Dropped/Duration) centered under preview pane. Resolution dropdown stays far right. **Avatar fix:** - Avatar `BitmapImage` no longer loads in the constructor (stale image from last session). - Only loads when `IsConnected` fires `true` via `OnViewModelPropertyChanged`. ### Tests: - Build 0 warnings, 247 tests (2 pre-existing known failures excluded). --- ## TASK 31 β€” SceneGraph component + static/dynamic compositor optimization **Goal:** extract the scene collection, element inventory, and mutation surface into a standalone `SceneGraph` component. Classify elements as static or dynamic. Enable the compositor to bake static layers once and only recompose dynamic layers per frame. ### Status: βœ… Done (2026-08-31) What landed β€” core optimization + SceneGraph component, all in one commit: - `ElementKind` (Static/Dynamic) on `SceneElement` base, overridden in `Source` and `WebcamSceneConfig` - `Services/SceneGraph.cs`: owns `Scenes` collection (ViewModel's `Scenes` property delegates to it); mutation surface `AddElement`/`InsertElement`/`RemoveElement`/`MoveElement` (each invalidates the bake); queries `GetBackground`/`GetWebcam`/`GetChatBoxes`/`GetSplitPoint`/`IsStatic`; and the `GetBakedBase` bake-cache (keyed by scene id + static element identities below the split) - `SceneCompositor.BakeStaticBase`/`CompositeLayers` + `Render(.., staticBase, split)` overload β€” split-aware, builds the cached base in source-rect space, composites dynamic/above-split per frame - `FramePump` takes an optional `SceneGraph` and uses the optimized path when wired (falls back to full render) - `MainViewModel`: routes element mutations through the graph; invalidates the bake on static layout/opacity/visibility/useDefaultBackground changes and after background heal / EnsureBackground - Integration test `SceneGraphTests.BakedStaticBase_WithDynamicLayer_CompositesCorrectly` (audio-free, verifies bake-once + cache hit + dynamic-on-top pixels + static-mutation invalidation + dynamic-only non-invalidation) **Defensive deviations from this spec (decision 2026-08-31):** 1. `ChatOverlayLayer` keeps taking `IEnumerable` rather than `SceneGraph.GetChatBoxes()` β€” it is deliberately decoupled from the graph (`// without owning the scene graph`). Forcing the graph in would couple a WPF-bound layer to it and violate that documented seam. 2. Background static helpers (`EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`) stay on the ViewModel because `BackgroundTests.cs` unit-tests `MainViewModel.EnsureBackground` directly. Queries moved to SceneGraph; helpers + their invalidation wiring stayed on the VM. 3. The full ViewModel-as-facade extraction (moving the ~all-binding-surface off Scenes/StagedScene/ LiveScene) was deliberately NOT done in this unsupervised pass β€” it is the "touch 11 files across 4 layers" regression risk the handoff flagged. SceneGraph owns the collection + mutation surface now; the remaining VM call sites binding to those still work because `Scenes` delegates. Revisit after 1.0. **Verification:** SceneCompositorTests 4, StretchMathTests 4, BackgroundTests 16, SceneCatalogTests 18, LayoutStorePersistenceTests 12, FramePumpTests 9, SceneGraphTests 1 all green. RealAppHost GUI/collection tests (SourceNaming, RoundClip, BackgroundHeal, ...) construct a real `MainWindow` and cannot run in a headless WSL session (pre-existing limitation, not caused by this change). ### Design **Element classification:** Every `SceneElement` exposes `ElementKind Kind` β€” `Static` or `Dynamic`: | Type | Kind | Why | |------|------|-----| | `Source` (Background art) | Static | Pixels don't change at runtime | | `Source` (Image) | Static | Pixels don't change at runtime | | `Source` (DisplayCapture) | Dynamic | Live game/desktop feed | | `Source` (ChatBox) | Dynamic | Live chat messages arrive continuously | | `Source` (WebSource) | Dynamic | WebView content can change | | `WebcamSceneConfig` | Dynamic | Camera feed, changes every frame | **Split point:** The **split point** is the index of the first dynamic element in a scene's z-ordered `Elements`. Everything below it = baked base. Everything from it upward (including static layers above dynamic elements) = composited per frame. If zero dynamic elements exist: entire scene is baked, compositor skipped entirely. **BakedSceneCache:** ``` BakedSceneCache { VideoFrame BaseFrame // composited static layers below split point int Version // incremented on mutation SceneElement[] BakedElements // for invalidation tracking } ``` Invalidation triggers (re-bake): element added/removed/reordered, static element moved/resized/ opacity changed, `IsVisible` toggled on a static element, background asset changed, scene switch. No invalidation when: only a dynamic element's pixels changed (webcam frame, chat message), or a dynamic element moved/resized (per-frame compositing concern, not a base rebake). **Compositor integration:** ``` Render(scene, frameFor, options): 1. Find split point (first Dynamic element index) 2. If split == scene.Elements.Count: β†’ return cached base (or bake if stale) 3. Otherwise: β†’ start from cached base (or bake if stale) β†’ composite elements[split..] on top using frameFor ``` **SceneGraph interface:** ``` SceneGraph { // Collection ObservableCollection Scenes Scene? StagedScene Scene? LiveScene // Mutation surface (single owner of element operations) AddElement(scene, element) RemoveElement(scene, element) MoveElement(scene, element, newIndex) // Queries (replace scattered LINQ) GetBackground(scene): Source? GetWebcam(scene): WebcamSceneConfig? GetChatBoxes(): IEnumerable GetSplitPoint(scene): int IsStatic(scene): bool // Events SceneChanged ElementAdded/Removed/Moved SplitPointChanged } ``` **What moves into SceneGraph:** | Current location | Moves to | |-----------------|----------| | `MainViewModel.Scenes.cs` β€” Scenes collection, StagedScene, scene switching | `SceneGraph` | | `MainViewModel.Background.cs` β€” `NormalizeBackgrounds`, `EnsureBackground`, background queries | `SceneGraph` (background helpers) | | `MainViewModel.Sources.cs` β€” `AddSource`, `RemoveElement`, element inventory | `SceneGraph` (mutation surface) | | `MainViewModel.Webcam.cs` β€” webcam queries across scenes | `SceneGraph.GetWebcam(scene)` | | `ChatOverlayLayer` β€” `scenes.SelectMany(...).OfType().Where(ChatBox)` | `SceneGraph.GetChatBoxes()` | **What stays on the ViewModel:** The binding surface β€” `StagedScene` setter still raises `OnPropertyChanged` for `ShowEmptySceneHint`, `CanAddWebcam`, etc. But now it delegates to `SceneGraph` for actual state queries. ViewModel becomes a thin binding facade over the SceneGraph (same pattern as `MainViewModel.Chat.cs` over `ChatOverlayLayer`). The compositor resolver (`frameFor` callback) stays in the ViewModel/encoder layer β€” it bridges WPF concepts (CameraManager, ScreenCaptureManager, ImageCache) into the pure `VideoFrame` seam. SceneGraph doesn't know about capture sessions; it just knows element kinds. **Migration path:** 1. Extract `SceneGraph` as a standalone class in `Services/` 2. Move `Scenes` collection + `StagedScene`/`LiveScene` + wiring 3. Move element mutation surface (add/remove/move) 4. Move background normalization/queries 5. Add `ElementKind` to `SceneElement` base 6. Add `GetSplitPoint` + `BakedSceneCache` 7. Update compositor to use split point 8. Wire ViewModel as thin facade 9. One integration test: baked static base + dynamic layer composite ### Tests ONE integration test: `SceneGraphTests.BakedStaticBase_WithDynamicLayer_CompositesCorrectly` β€” a scene with static background + image + dynamic webcam; assert the baked base is cached (renders once), dynamic layer composited on top, static element mutation invalidates the cache, dynamic-only pixel change does not. --- ## TASK 32 β€” Stream resilience (queued 2026-09-01 β€” merges the orphaned TASK 2 resume clause into the pills/signs architecture) **Goal:** a dropped stream heals itself when physically possible, tells the truth when it isn't, and gets the creator back on air in one click. The ON-AIR sign + primary button in the top bar are the surface β€” they already mean "reality" (pills = intent, lights = reality, TASK 18/30); reality just isn't two-state. Resumption of a completed broadcast is explicitly **out of scope** (impossible via the API β€” creator ruling 2026-09-01): no lying in the copy. **Slices (one integration test each, in order):** 1. ☐ **Blip retry** β€” app-side encoder restart: push dies while live (ProcessFailed / stdin backpressure death) β†’ bounded-backoff relaunch (2s/5s/10s, N attempts) against the cached constant reusable-stream ingest URL. Success = push running again while the broadcast is still live; viewers never know. Fixes the map's stale "ffmpeg does reconnect" claim (corrected 2026-09-01: reconnect flags are input-side only). Test: fake process fails once then starts β†’ pump recovers without surfacing to the VM. 2. ☐ **Measured grace + the sign** β€” while the push is dead, escalate the existing health poll (30s β†’ ~5s). ON-AIR dot goes **amber "RECONNECTING"** with a mm:ss countdown to the observed YouTube cutoff; the grace length is a **measured, pinned constant** β€” probe it on a real private test stream, cite the observation (spin-guard rule; do NOT invent a number). Poll reports the broadcast `complete` β†’ dot **red "OFF AIR"**, primary button relabels **"● Back on air"** and pulses. Test: fake status provider drives the dot through live β†’ reconnecting β†’ cut-off. 3. ☐ **One-click Back on air** β€” the relabeled button fires the existing go-live path with saved `Broadcast.*` + cached stream, dialog skipped: new broadcast, new VOD (accepted cost, stated in-product). Test: seeded saved form + fake stream service β†’ click creates + pushes, no dialog. 4. ☐ **Crash-safe recording** β€” fragmented MP4 on the record block (`-movflags frag_keyframe+empty_moov+default_base_moof`): plain MP4's trailing moov atom means a power loss kills the WHOLE file. Rename-on-stop unchanged. Closes the TASK 18 "MKV as an option" design-note remnant (fragmented MP4 chosen over MKV: single container, YouTube-upload-native β€” verify player/ editor tolerance, cite the research). Test: `FfmpegArgs` emits the movflags only on the record block. 5. ☐ **Pre-flight** β€” before broadcast insert, one TCP probe to the ingest host:1935 (~2s timeout). Unreachable β†’ Error toast, no insert, no ghost broadcast ("check VPN/network"). The "forgot the VPN" story, solved as prevention instead of recovery. Test: fake probe failure aborts go-live before any API call. ### Design decisions - **The sign is the status, the button is the action** β€” no new chrome, no log lines, no spinners. - **Honest windows:** countdown = observed grace; at zero we say OFF AIR, we don't pretend to reconnect. - **Why us > incumbents:** constant reusable stream + persisted form make retry/restart cheap; OBS shows a reconnecting log line and Streamlabs silently dies. --- ## TASK 33 β€” Bandwidth auto step-down (queued 2026-09-01; v1 per the complete-v1 ruling) **Goal:** when the upload can't hold the tier, drop it automatically instead of drowning viewers in freeze frames. The `variable` reusable stream (shipped) makes this zero-API; the governor that decides WHEN is what TASKS.md:25 always claimed existed and was never built. 1. ☐ Pure `StepDownPolicy` β€” sliding window over the encoder's reported dropped-frame rate (`StreamHealth`, already parsed): sustained overrun β†’ downgrade exactly one 16:9 tier (1080p60 β†’ 1080p30 β†’ 720p60 β†’ 720p30). Hysteresis cooldown; **one-way within a broadcast** (no oscillation β€” a recovered connection doesn't climb back mid-show; the creator may manually step up). Vertical tier stays a manual choice. 2. ☐ Restart machinery = TASK 32 slice 1 (tier change is an encoder relaunch at new WΓ—H/FPS over the same pipe/URL; the master canvas is 1920Γ—1080 regardless, so geometry never rewrites). 3. ☐ Surface: the resolution badge + dropdown update to the landed tier; ONE Info toast ("Quality lowered to hold your stream") β€” no nagging after that. 4. ☐ Tests: pure-policy units (drop-rate windows, one-way, cooldown) + the ONE integration: fake health stream with sustained drops β†’ exactly one lower-tier encoder restart. --- ## TASK 34 β€” Scheduled streams, Text-drawer version (queued 2026-09-01; option (b) with the creator's placement) **Goal:** announce "Friday 8pm" from inside the app β€” YouTube notifies subscribers and runs the channel countdown; come Friday, one click goes live into the scheduled broadcast. Scheduling is a "my channel" concern, so it lives in the always-visible **Text drawer**, not the go-live modal. 1. ☐ Drawer gains β˜‘ *Scheduled livestream* + date/time (replacing the read-only "Scheduled Start" row). Checking + Save **creates** the broadcast: insert `liveBroadcasts` with a future `scheduledStartTime`, bound to the cached reusable stream β€” the drawer's Save path grows its first create operation (today it can only update an existing broadcast). 2. ☐ Invariant: **at most one open scheduled broadcast**; unchecking before airtime β†’ `liveBroadcasts.delete` (legal in created/ready). 3. ☐ **Adoption:** go-live checks for the open scheduled broadcast and pushes into it instead of inserting a new one. Mid-stream title/desc edits ride the existing Update path (unchanged). 4. ☐ `LiveBroadcastFormViewModel` owns `IsScheduled` + `ScheduledStart` (nullable), persisted with the other `Broadcast.*` keys; local time in, RFC3339 offset on the wire β€” the timezone edge is where the bugs will live: test it (DST boundary, non-UTC creator). 5. ☐ **Research flags (cite before building β€” spin guard):** (a) pushing *before* the scheduled start with `enableAutoStart` β€” live immediately or held? Decides a "starting early β€” it goes live NOW" confirmation. (b) delete semantics for a ready broadcast bound to a reusable stream. 6. ☐ Test (the ONE): schedule β†’ persists + inserts (fake service captures a future scheduledStartTime + boundStreamId); start β†’ adopts instead of inserting; uncheck β†’ delete called. - **Test-phase note:** dark by design while the visibility lock holds (Private notifies nobody); the creator can still exercise insert/adopt/cancel. Full shine arrives with TASK 36's unlock β€” pleasing coupling, deliberate. --- ## TASK 35 β€” Scene-linked audio: "scenes remember the room" (queued 2026-09-01; spec approved by the creator same day) **Goal:** the BRB scene mutes the mic; the Chat scene brings it back. The creator stops fiddling with audio at transitions β€” the scene carries the room state. OBS needs scripts/plugins; native = the creator-proof pitch. 1. ☐ `Scene` gains nullable `SceneMutesMic` / `SceneMutesDesktop` β€” `null` = don't touch (the default; scenes are passive until told). Schema column bump + roundtrip. 2. ☐ Applied on **live transitions only** (thumbnail click / hotkey), ONCE at entry β€” never on offline staging (no surprise stream-state edits while auditioning). 3. ☐ The creator is boss: a manual mute/unmute mid-scene is never overridden until the next entry. 4. ☐ BRB ships `SceneMutesMic = true`; other scenes default `null` β€” creator's choice. 5. ☐ Edit UI: two checkboxes in the scene's Properties section (left panel, edit mode) β€” no new chrome. 6. ☐ Test (the ONE): transition applies scene state; `null` leaves state untouched; manual override survives until the next entry. --- ## TASK 36 β€” Gold pass (queued 2026-09-01; the going-gate, always the last work unit) **Goal:** the single deliberate pass that turns the dev product into the shipped product. Nothing here may be done opportunistically mid-development β€” each item has a lock/comment pointing at this task. 1. ☐ **Visibility unlock** β€” remove the `CreateBroadcast` Private override (TASK 9 item 6); dialog selection rules. The channel-protection stance ends at GA, by hand, here. 2. ☐ **Branding flash goes live** β€” end the preview-only pre-GA posture: composite the escalating obnoxious-promo flash onto output + recordings for free users. Paid removes it; that stays the ONLY paid delta. (Escalation curve finalized here, first.) 3. ☐ **Screens/layers settings audit** β€” the retired 2026-08-22 landmine (ai.md): fine-tooth-comb every screen's Background layer properties, pill persistence, context-menu visibility, (+) items. 4. ☐ **Native-Windows verification suite** β€” the checklist TASKS/HANDOFF never had: real webcam, real mp4 decode/loop/pacing (TASK 21 picker must have landed), real recording file opens + plays (fragmented MP4), a real private go-live end-to-end, GUI/RealApp test classes green on the desktop (they've only ever run against fakes). 5. ☐ **License-expiry reminders** β€” T-14 / T-7 / T-1 toasts via the existing notification stack ("renews β€” manage here" β†’ `PremiumUrl`); lapsed: flash returns (the product itself is the notice) + one Info toast; active paid users see ZERO license UI. Research: does Polar's validate response carry `expires_at` reliably β€” cite before building. 6. ☐ **Release engineering** (the `Distribution.md` plan, 644 lines, finally tasked): code signing (HARD blocker β€” an unsigned installer is a SmartScreen scarewall), installer + Velopack update URL (TASK 10 item 8), EULA draft/review, THIRD-PARTY-NOTICES license-texts gate (TASK 4 req 9). Build posture (agreed ceiling): compile flags max out at `HARDENED` (release-only anti-debug/ tamper checks) + `MOCK_REWARDS` (fake reward payloads for Alerts/dev) β€” **additive-only**, never branching normal code paths, both compiled every CI build so they can't rot. Obfuscation / assembly-splitting stay GA-time decisions, not compile-time ones. --- ## v1 = the finished product (creator ruling, 2026-09-01) **There is no v1.x.** v1 ships feature-complete: everything in the queue above lands, or is explicitly buried below. The parking lot is closed β€” nothing is "deferred to a later version" anymore; the only two states are **v1 task** and **out of product, permanently**. The 10% margin of error is this list, bounded and written down β€” not vibes. **Monetization posture restated (what "final" means):** free gives everything; the only difference is the branding flash. License activation flips exactly one bit (`IsPremium` β†’ flash off). Nagware = the escalating obnoxious self-promo flash itself β€” never modals, never feature-locks. Pre-GA the flash is preview-only (see ai.md β†’ Monetization; TASK 36 flips it live). ## Out of product β€” permanently (the 10% margin, closed list β€” do not re-litigate) | Idea | Verdict & reason | |------|------------------| | Stream Deck / Loupedeck integration | The device sends keystrokes β€” **global hotkeys already ARE the integration** (TASK 20). Redundant, not deferred. | | Per-source audio sync offset | Global offset shipped (TASK 22); per-source is the OBS mixer rabbit hole. Condemned by the Audio Assumption (one knob). | | Profiles / presets (multi-config) | Same hydra as multi-layout, already ruled at TASK 30: **that's what OBS is for.** | | Chroma key / background removal | Already ❌ (TASK 3 item 19). NVIDIA Broadcast does it free; model+GPU+lighting caveats = support hell for a solo dev. Stays buried. | | Virtual camera output (Zoom/Discord) | Different product, device-driver swamp. Our output is one YouTube stream. | | Replay buffer (instant replay) | Game-clipping culture, not our persona. **The most valuable corpse here** β€” if the list is ever reopened at all, this is candidate #1, and it reopens by creator ruling, not by AI suggestion. | | Multi-destination restreaming | "Casual streamers outgrow LlamaCasty first" β€” the map already said it; the per-destination audio/monitoring expectations are a different product. | | Stream clipping | YouTube killed clips; trimming local MP4 is an editor. | | Advanced broadcast-form tab (latency/DVR/embed/projection/CC/region) | TASK 9 item 7 rescoped: fixed sane defaults, invisible. Every exposed field is a support ticket. | | In-app bug-report mechanism | Never built, never will be: support = email + GitHub issues. (ai.md's support line corrected to match, 2026-09-01. Second-place corpse if the list ever reopens: with replay buffer.) | | D3DImage/GPU preview compositor | TASK 3 item 16 superseded: XAML preview + software output compositor are the design; a D3D11 swap remains a seam-respecting possibility, not a feature. | *If a future session is tempted by anything on this list, the answer is already written. New ideas must survive this page before they get a task number.*