The 2026-08-09 pin was a DAILY build; BtbN retention aged it out and the cold-cache download 404'd on the creator's first native recording attempt (2026-09-01, startup.log). Re-pinned to the MONTH-END build autobuild-2026-08-31-13-27 (N-126342, lgpl-shared win64 — 2-year retention; tag+variant recorded in TASKS.md per the licensing rule). Verified alive: HEAD 200 + zip contents (bin/ffmpeg.exe, bin/ffprobe.exe, 7 libav DLLs) match the name-agnostic extractor. Also closes the wrap-gap: HttpRequestException escaped the locator untouched, surfacing a raw 'Response status code... 404' from the frame pump; now wrapped in IOException with the refresh-pin-or-install-ffmpeg message. FfmpegLocatorTests 7/7 (one updated, one added for the 404 case).
150 KiB
ytLlive — Task List
Task queue and authoritative research. Memory-map conventions:
schema.md; architecture/decisions:ai.md. Update statuses here whenever a task moves.
Checklist markers — every task's Status list uses the same states:
- ✅ — completed (green check)
- 🔶 — in progress (amber diamond)
- ☐ — not completed / pending (empty box)
- ❌ — 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).
- liveBroadcasts.insert requires:
snippet.title,snippet.scheduledStartTime,status.privacyStatus,status.selfDeclaredMadeForKids(COPPA). - 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. - Title / description / privacy: editable at any time, including while live (
liveBroadcasts.update, part=snippet,status). - contentDetails (DVR, recordFromStart, monitorStream, embed, latency): editable only in
created/ready. - Transition to live only allowed when the bound stream's
status.streamStatus == active.
Two features that reshape the design
- enableAutoStart / enableAutoStop — instant one-click go-live, no transition call. With
enableAutoStart=truewe never calltransition(live): the broadcast auto-goes-live the moment the encoder starts. Combined withenableMonitorStream=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. 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)
liveStreams.status.healthStatus:good | ok | bad | noDataplusconfigurationIssues[]withtype+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.- Encoder must comply or YouTube flags it: keyframes ≤ 4s (
gopSizeLong), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only. - Error codes to handle:
errorStreamInactive,invalidTransition,redundantTransition,liveStreamDeletionNotAllowed,liveStreamModificationNotAllowed,liveBroadcastBindingNotAllowed.
Tips we should take advantage of
- 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. - Backup ingestion address: YouTube provides a simultaneous-push backup — future hardening, not v1.
recordFromStart+enableDvrdefault true → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.latencyPreference:normal | low | ultraLow— for homelab streamers talking to chat,low(orultraLow, capped at 1080p) is a real feature.- 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
- ✅ Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
- ✅ Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
- ✅ MainViewModel: scene management, stream controls, chat
- ✅ MainWindow: scene/source panel, preview area, chat panel, status bar
- ✅ 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
- ✅ 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
- ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) +
YouTubeAuthServicecomplete: browser launch,HttpListenercallback, token exchange, refresh, channel fetch - ✅ Token persistence via Windows DPAPI (
Helpers/TokenStore.cs→%APPDATA%\ytLlive\ytLlive.auth), best-effort reload + proactive refresh at startup, saved after every exchange/refresh - ✅ 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)
- ✅
End Livestream signs outSUPERSEDED 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()callsYouTubeAuthService.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 andenableAutoStopends 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). - ✅ 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:
- 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) - Local HTTP listener —
HttpListeneronhttp://localhost:PORT/oauth2/callbackto catch the redirect - Browser launch — open the authorization URL in the default browser
- Token persistence — store access/refresh tokens securely (Windows DPAPI), reload on startup
- UI state — account shown in the Start Stream dialog; "Change Account" action triggers re-auth
- Go Live gated on auth — Start Stream dialog requires sign-in to enable the Start button; scene building works without it
Tests:
- Mock token exchange response, verify channel info parsed
- Verify token refresh triggers when near expiry
- 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
- ✅ 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 - ✅ Schema v3 (Ship Branch A) — multi-scene webcam (singleton
Webcam+ per-sceneWebcamSceneConfig), right-click border/context menu, static OBS-style borders, 50%-per-dimension webcam size cap, device-swap (ReleaseAllAsync) — 25 tests passing - ✅ Schema v4 — round→rect restore persisted (
WebcamSceneConfig.RectWidth/RectHeight) + one-time legacy-square 16:9 heal on load - ✅ 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) viaWin32FullScreenDetector(now withGetDisplays()/PrimaryMonitorIndex()for the in-app display picker), content re-designated via the OSGraphicsCapturePicker("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions inScreenCaptureManagermirroringCameraManager— 45 tests passing - ✅ 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 toWindowsRuntimeMarshal.TryGetDataUnsafe(the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was floodingstartup.logwith 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes anImageBrushevery frame (Image + EllipseGeometry clip) — the live-mode stutter fix - ✅ 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 - ✅ 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 inAddWebcamToActiveSceneAsync/ReacquireWebcamnow hands the running shared frames to any newly addedWebcamSceneConfig— 65 tests passing - ✅ Chat scene webcam size cap — raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9,
MaxWebcamWidthFor/MaxWebcamHeightForkeyed by canonical name) so the viewer sees the creator better - ✅ "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 —
SwapWebcamIdentityAsyncnow swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…" - ✅ Webcam resource validation + first-frame proof —
MediaCaptureFrameSourcevalidates post-init (VideoDeviceId match, stream properties ≥1,reader.StartAsync()status read + throws on non-Success); subscribescapture.Failed+CameraStreamStateChanged→SourceFailedevent on the seam; fallback ladder (VideoPreview → VideoRecord).CameraManager.AcquireAsyncrequires first-frame proof (4s timeout): returns true only after a real frame arrives — silent empty box impossible.MainViewModelsubscribesCameraFailed→ redWebcamErrorchip in preview + MessageBox names suspect apps (CameraConflictProbe). 19041 SDK projection gaps:Exclusive/DeviceLostnot projected;CameraStreamState.Failedcompared by(int)2. 81 tests passing - ✅ Scenes/sources UI — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
- ✅ 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 (
IGameAudioDetectorseam + defaultGameAudioDetectorpollingIFullScreenDetector+ the live loopback level, floor 0.5%, into a pureGameAudioHysteresis: SHOW after ~500ms of fullscreen+sound, HIDE ~1s after leaving fullscreen, silence never hides an active bar; the VM polls it on a 250msDispatcherTimer); capture now runs for the app's lifetime (mixer started once at startup viaStartMicCaptureAsync, disposed inShutdown— not go-live) so the meters preview live; the MIC label became a button with a status dot (Models/MicStatus: green = connected via the sourceStartedevent, 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 viaAudioLevelMeter.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, sameToggleMicMuteCommand; 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'sToDisplayinput is the pre-filter mic level; when filters land they must apply BEFORE the meter/encoder mix - ✅ 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) - ✅ 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 + injectedISocialValidator/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, mirrorsStopStream), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation:DetectService(URL domain / fediverse@user@domain/ bare→Website) → asyncISocialValidatoron confirm/Save; valid snaps to text + real service logo (bundled SVG path data viaLogoDataFor, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (BarJustifyunread), per-scene toggle dropped (Scene.HasSocialBarback-compat). Post-test fixes (2026-08-12): footer label "Socials" (not "Social"); fediverse@user@domainvalidates — the full handle is the identity end-to-end (DetectService/CanonicalUrlFor/HttpSocialValidatorbuildhttps://domain/@user, no domain loss); Cancel is a hard stop —ISocialValidator.LookupAsynctakes aCancellationToken, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), andConfirmEditskips re-submitting identical text (LostFocus on dismiss never re-fires a lookup).HttpSocialValidatornow has real tests (fakeHttpMessageHandler). 105 tests passing. Post-test fixes (2026-08-12, round 2): fediverse@user@domainno longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (/.well-known/nodeinfo→software.name;SocialService.Fediverseenum member +SocialEntry.FediverseSoftwarepersisted in a newSocialEntry.Softwarecolumn, 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 —IconButtonstyle gainsForeground=#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.tubewhere the mastodon instance lives atmastodon.llamachile.tube) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, soHttpSocialValidatorfollows the bare roothttps://domain/302 to the real instance host and re-runs the nodeinfo lookup there. - ✅ Window capture — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
- ❌ 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
VideoFrameseam, not a feature - ☐ Text source — live text ("Starting soon", "Back in 5", handle, callout)
- ✅ 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 - ❌ Background removal (milestone 2) — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
- ☐ 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 canonicalRewardEventfeed (all 7liveChatMessagereward types, persisted to SQLite + the session report — see TASK 10 → "Related work — monetization awareness"), so it consumes that already-built data rather than re-integratingliveChatMessages. That capture is the required prior milestone. - ✅ Show Desktop toggle —
Source.ShowDesktopbool 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.ClearBackdropCaptureAsyncclearsCaptureKey+VideoImageSourceso static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem). - ✅ Top bar — status light + avatar + Log In button — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via
BitmapImagecode-behind. "Log In" button visible when disconnected, callsStartStreamCommand→ GoLiveWindow.ShowStartStreamnow requiresIsConnected. - ✅ Chat preview — one-at-a-time mock messages —
RunMockChatPreviewAsync: simple async loop, displaysMockChatMessages[n]viaTake(n+1), 1000ms between messages, wraps at end.ChatPreviewEnabledproperty on Source (default true). Stops on real messages, restarts on fade timer clear. - ✅ All backdrops seeded + EnsureDefaultBackdrop — Live, Chat, Ending backdrops added (ending-backdrop.jpg replaced).
EnsureDefaultBackdrop()called on every scene switch.SeedEndingBackdropreplaces existing (not skip-if-exists). Chat scene no longer skipped. - ✅ SceneCatalog fix — Chat scene display name corrected to "Chat" (was "YouTube Chat" — that name belongs to the layer/SourceType.ChatBox, not the scene).
- ✅ Left panel spacing + context menu reorder — HR margins matched, Row 4 changed from
*toAuto. Context menu reordered: Border Thickness first, then Opacity, Color, Effect. - ✅ 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 fullTHIRD-PARTY-NOTICES.txttext (MainViewModel.ShowLicensesreads 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_Noticesdrives 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 - ✅ 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
afftdnpipe, 2026-08-14 — KISS). Always-on — no UI knobs; the mic stays the creator's single audio control - ✅ 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 fromCanAddWebcamToStagedScenewhose per-scene rule let a second picker run from a scene lacking the config), raised at both_webcammutation 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 testWebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity(real window + temp DB seeded with a webcam in Starting; asserts greyed while Live staged, re-enabled afterRemoveSourceCommandclears the last config + theWebcamDB row). Stale map fixed in the same commit: ai.md's "empty-canvas right-click Show Webcam" claim dropped — that XAML never shipped (ShowWebcamCommand/CanShowWebcamInStagedSceneare wired but unbound dead code, audit item). 224 tests (223 pass; the pre-existing AudioPipelineTests failure is unrelated) - ✅ 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;AddSourcerefuses a second), tooltip "One chat layer at a time — it's already in your stream". Plus the creator's label fix: layers added by commit65641d8were 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 testChatLayerGateTests.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) - ✅ 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 newYouTubeStreamService.UpdateBroadcast(PUTliveBroadcasts?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/DescriptionVM properties deleted. Launch geometry (same unit): default 1920×1040, MinWidth 1366, MinHeight 768;WindowStartupLocation=Manual+ window size/position persisted on close viaRestoreBounds(maximized-safe), restored in ctor clamped to minimums and the primary work area (disconnected-secondary fallback). ONE integration testBroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote. 228 tests (227 pass; pre-existing AudioPipelineTests failure unrelated) - ✅ 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) + deadSaveLayoutCommand/SaveLayoutAsCommand/OpenLayoutCommand+SaveLayoutAs()/OpenLayoutFile()methods. App Settings panel: first real setting —Default Location for Recordings(configured folder viaChooseRecordFolderCommand+ "Use Downloads" reset viaResetRecordFolderCommand, resolved path shown). Fallback chain: configured →%USERPROFILE%\Downloads→MyVideos.StartRecordFile()+ChooseRecordFolder()InitialDirectoryupdated to use the new fallback. Single Start button:PrimaryButtonText/IsStreamingStart+ theirOnPropertyChangedcalls removed; buttonContent="Start"in XAML — pills are the intent surface, button is always just Start.Choose Record Folderfast-path kept in the button context menu. Monetization (About links/premium) deferred to a later task. ONE fallback testDefaultRecordFolder_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.
- Webcam — the face cam. Non-negotiable.
- 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.)
- 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.
- Image — a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned.
- Text — live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this.
- Chat box — YouTube live chat rendered on the stream so viewers read along in-video. YT-native.
- 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
RewardEventfeed (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)
- A scene has resources. Resources can be shared across scenes.
- A resource exists exactly once in memory, no matter how many scenes use it (a logo in five scenes = one loaded bitmap).
- 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.
- 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". - A webcam in two scenes = one capture session, two catalog entries.
- Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
- 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:
- Cut — instant switch. The default. Zero cost.
- Fade — short crossfade (~300ms).
- Move — a simple, economical move transition, done to perfection and memory-efficient. The smart streamer's bread and butter.
- 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:
- Screen — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
- Webcam — MediaCapture (WinRT SDK projection) with device enumeration — ✅ milestone 1 done:
- 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) MediaCaptureFrameSource(CPU-first:MemoryPreference = Cpu, BGRA8 viaCreateFrameReaderAsync),MediaCaptureCameraEnumerator(DeviceInformation.FindAllAsync(DeviceClass.VideoCapture))CameraManager: refcounted byDeviceId, one sharedWriteableBitmapapp-wide, dispatcher-coalesced UI updates (~render rate, latest-frame drop), placeholder/AppLog+ warning on failureCameraPickerDialog(mirror ofReuseImageDialog) — "Searching for cameras…" / list / "No cameras found" states- One webcam app-wide: Add → Webcam greyed out once one exists ("it's already in your stream" tooltip); persisted
DeviceIdre-acquires after layout load - Default placement 16:9 480×270, bottom-right, 32px margin; drag/resize/selection shared with Image sources
- 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
- Background removal = milestone 2 (ONNX Runtime + DirectML, MediaPipe Selfie Segmentation) — not in this build
- TFM bumped to
- Background / Image / Text — static sources positioned/scaled/opacity
- Chat box — rendered from the live chat poll (right panel is the same feed, raw)
- Scene compositing — per-scene source layering (z-order = sources list order, top-to-bottom back-to-front), preview rendered via D3DImage or MediaElement
- Branding flash — the topmost full-frame "made with ytLlive!" layer at ~25% opacity, ~1s on /
300s off (see Monetization in
ai.md), gated onBrandFlashEnabled+ live/recording. Lives in the preview compositor now (BrandFlashLayerinMainWindow.xamlCanvasGrid, driven byBrandFlashActive/BrandFlashTimerinMainViewModel); the encoder output renders the same layer, and v0.2 local recordings carry it too - Drag/drop placement & reorder — intuitive, visual (per design principle):
- Preview: click-drag a source in the center panel to reposition it; resize via handles
- Scenes list: drag rows to reorder scenes
- 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
- ✅ Ship step 1 — the output compositor SHIPPED (2026-08-10)
- ✅ Ship step 2 — the FFmpeg locator SHIPPED (2026-08-10)
- ✅ 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)
- ✅ 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) - ✅ Frame-pipeline wiring SHIPPED (2026-08-12) —
CameraManager/ScreenCaptureManager→ compositor resolver → encoder, driven by a pacedFramePump(see the ship step 5 plan below) - ✅ Health stats SHIPPED (2026-08-13) —
FramePump.HealthUpdated(encoder's parsed bitrate/FPS/dropped/duration, already forwarded fromFfmpegEncoder.OnStderrLine) now lands in the bottom bar:MainViewModel.OnFramePumpHealthUpdatedmarshals to the UI thread (the stderr loop raises on a background thread) and copies intoCurrentHealth(the bottom bar's existing binding);ResetHealthzeroes 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) - ✅ One-click go live + private-only enforcement SHIPPED (2026-08-14) — the Go Live dialog is locked to Private (no dropdown,
GoLiveViewModel.Visibilityis a get-only "Private");YouTubeStreamService.CreateBroadcastalways sendsprivacyStatus = "private"(dialog + service enforcement, requirement 8 — nothing can go out non-private) and gained an injectableHttpClient? http = nullseam for tests;BeginGoLivenow callsCreateBroadcastAsyncand remembers_currentBroadcastIdfor TASK 9's bind/transition (failure →StreamStatus.Error, never a crash;StopStreamclears 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/DefaultStreamVisibilityremoved. 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:
- 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
.flvfor RTMP,.mp4/.tsfor VOD) — the format is NOT the differentiator, the license and per-GPU quality are. License guardrails (never violate — seeai.md→ "Licensing — do not violate"): only BtbNlgpl/lgpl-sharedbuilds; never GPL (gyan.dev) ornonfree(fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never dropTHIRD-PARTY-NOTICES.txtfrom the app/About screen. - 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(extractffmpeg.exeplus thelibav*.dllfamily) and cache it, offline-friendly. Behind anIFfmpegLocatorseam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL - Quality ladder — the offered tiers, with 1080p60 @ 8 Mbps as the standard/default:
- 720p30 @ 6 Mbps
- 720p60 @ 6 Mbps
- 1080p30 @ 8 Mbps
- 1080p60 @ 8 Mbps (default — mainstream ceiling, GPU hardware-encoded so the gaming machine never notices; upload headroom stays comfortable)
- 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 withvariable, 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
- 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 - Health stats — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
- One-click go live — defaults that work out of the box
- Audio capture (feeds the meter — this task ships the wiring) — WASAPI loopback (desktop/game at unity, zero UI — "it just is") + the picked mic (
MicSourceNamefrom theMicPickerDialog). The mic capture feedsAudioLevelso the realtime meter comes alive (today it reads 0 — the mixer feed is pending, seeai.mdaudio notes). AAC mono/stereo @ 48 kHz per the compliance rules. - 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, assertprivacyStatusis forced to private). - v1 release gate: bundle the full license texts (decided 2026-08-10) —
THIRD-PARTY-NOTICES.txtcurrently 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. alicenses/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):
- Backdrop — the Live scene's
IsBackdropSource (CaptureKey→ live frame),UniformToFillfull-frame (XAML's separateBackdropImagelayer; the backdrop element renders nothing — its DataTemplate Image is Collapsed for DisplayCapture). - Background — the scene's
BackgroundSource,UniformToFillfull-frame (theActiveBackgroundImagelayer, not per-element). - Elements in
Scene.Elementsorder (back→front), skipIsVisible=false. What actually renders:SourceTypeImage→ static asset,UniformToFillcover-crop into (X, Y, W, H)WebcamSceneConfig→ latest frame byDeviceId: Traditional =UniformToFillrect; Round = circle diametermin(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 atRoundBorderSize, widthBorderWidth, alphaBorderOpacityBackground/IsBackdrop/TextOverlayare NOT per-element (layers above; Text not shipped)
- Branding flash — pre-rendered full-frame "made with ytLlive!" at 25% alpha when live +
BrandFlashEnabled+ timer active. Passed in as aVideoFrame?(compositor core stays pure byte-math, no WPF; likely a bundled asset rather than runtime text rendering). - NOT in output (preview chrome only): SelectionOverlay, DimRects, output-rect outline, badge, placeholder.
New files (all in Services/Compositor/):
SceneCompositor.cs—Render(Scene, frameFor: Func<SceneElement, VideoFrame?>, flashFrame: VideoFrame?, CompositorOptions) → VideoFrame(output-sized). The caller'sframeForresolver maps each element to its frame (webcam → DeviceId, image → AssetId viaStaticPixelCache, backdrop → CaptureKey) — the compositor stays pure/hermetic/no WPF.CompositorOptions.cs— source-rect + output W×H.StretchMath.cs—UniformToFillcover-crop, ellipse mask, bilinear scale (pure, unit-tested).StaticPixelCache.cs— assetbyte[]→ cached BGRAVideoFrame(WPFBitmapDecoder+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):
- 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.txtandai.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. - Pinned URL — RE-PINNED 2026-09-01:
https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip(BtbN lgpl-shared variant, ffmpeg N-126342; ~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). The first pin (autobuild-2026-08-09-13-03, a daily) aged out on 2026-09-01 — the first real recording attempt — proving the rule; the new pin is deliberately the month-end build (2-year retention). Dated tags carry versioned asset names (ffmpeg-N-…-win64-lgpl-shared.zip), extraction is name-agnostic; download failures now wrap inIOExceptionwith an actionable message ("refreshFfmpegLocator.PinnedUrlor install ffmpeg on PATH") instead of leaking a raw 404. Once cached, the URL is never touched again. The pin is a singleconst, bumpable in one place — and must always stay on the shared variant (nevergpl,nonfree, or static; see ai.md Licensing). - Check-then-pull order — (1) PATH probe (the user's own install wins), (2) cached
%APPDATA%\ytLlive\tools\ffmpeg.exe, (3) download + extract. Extractffmpeg.exeplus thelibav*.dllfamily (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. - Seam —
IFfmpegLocator.LocateAsync(CancellationToken): search dirs, tools dir, and the downloader (Func<string, CancellationToken, Task<byte[]>>) 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/):
IFfmpegLocator.cs— the seam.FfmpegLocator.cs— the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged viaAppLog.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 <encoder> -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 <rtmpUrl>. 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):
- NAudio
NAudio.Wasapi2.2.1 — the wasapi feature package (not theNAudiometa-package): it carries the capture types (WasapiCapture/WasapiLoopbackCapture+ the MMDevice enumeration) withNAudio.Core/NAudio.Asiopulled in transitively. MIT — recorded inTHIRD-PARTY-NOTICES.txt(item 9). IAudioSourceseam (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 =WasapiLoopbackCaptureon the default render device; mic =WasapiCapturewith the NAudio device resolved byFriendlyNamematchingMicSourceName(the app only persists the DisplayName), falling back to the default capture endpoint. Mic device resolution is re-read at eachStartvia a name provider so a mic picked mid-session takes effect next go-live.AudioMixerowns both sources — starts/stops both with go-live (BeginGoLivesuccess →Start,StopStream→Stop). Mic samples feed a pureAudioLevelMeter(RMS, exponential smoothing) and raiseMicLevelChanged, marshalled to the UI thread intoAudioLevel; desktop samples are currently dropped (consumed by the encoder's AAC mix in a later step). Capture failures are logged viaAppLog(mic failure also zeroes the meter); loopback failure doesn't kill the mic.- Byte→float — pure
WaveToFloat.Converthandles the WASAPI mix formats: IEEE float 32-bit (direct) and PCM 16-bit (normalized to -1..1), includingWaveFormatExtensiblewith 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):
- Video pipeline first — the
-f lavfi -i anullsrcsilent track stays; mixing the loopback/mic WASAPI samples into the encoder's AAC track is its own later step. - RTMP URL via a provider seam —
MainViewModel._rtmpUrlProvideris aFunc<string?>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:
Services/Encoder/FramePump.cs— the frame producer. All collaborators constructor-injected seams (Func<Scene?>,Func<SceneElement, VideoFrame?>resolver,Func<CompositorOptions>,Func<EncoderOptions?>,Func<IFfmpegEncoder>,Action<string>log, injectable pacing delay) so it stays free of WPF and of the capture managers and is hermetic in tests.StartAsyncnever throws (failures log + surface viaFailed— the VM fires-and-forgets from the sync command handler); loop = snapshot → render →SubmitFrameAsync, paced at1/options.Fps(defaultTask.Delay; tests injectTask.Yield).StopAsyncstops 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.ProcessFailedself-stops the pump.HealthUpdatedforwards the encoder's stats (ship step 6 binds the bottom bar).ScreenCaptureManager.GetLatestFrame(key)— mirrorsCameraManager.GetLatestFrame(deviceId); the backdrop's live frame for the compositor.MainViewModel— owns the resolver (WebcamSceneConfig→GetLatestFrame(WebcamId);Source.IsLiveCapture→GetLatestFrame(CaptureKey); image/background →StaticPixelCache.Get(AssetId)), buildsCompositorOptionsfrom the tier +OutputRect*(doubles rounded to ints — the vertical 607.5 half-pixel crop rounds to a perfectly-centered 608), buildsEncoderOptionsfrom the tier when the URL provider returns one, constructs the realFfmpegEncoder(new FfmpegLocator()), starts the pump on go-live, stops it on end-stream, disposes inShutdown, and flipsStreamStatus.Errorwhen 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<string> 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
- ✅ SQLite database (
Microsoft.Data.Sqlite) at%APPDATA%\ytLlive\ytLlive.db; schema versioned viaPRAGMA user_version(currently v8) - ✅ Assets live in the DB (BLOB keyed by SHA-256 content hash), never file paths — deleting the original file never breaks a scene
- ✅ 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
- ✅ Auto-save (invisible) — ~1.5s debounce on scene/source add/remove/reorder/rename/hide + any source transform change; flush on window close
- ✅ 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
- ✅ Schema v1 → v9 — webcam columns (v2), singleton
Webcam+ per-sceneWebcamSceneConfig(v3),RectWidth/RectHeightround-to-rect restore (v4),Source.IsBackdrop+Source.CaptureKey(v5),Scene.HasBackdrop— backdrop Live-only by policy (v6, one-time backfill +EnforceBackdropPolicyon every load),Scene.HasSocialBar(v7, dropped per-scene toggle — column back-compat, unread),Socials.BarEnabled(v8); theSocialEntry.Softwarefediverse-software column is a column-presence migration (commented v8→v9, no version bump —user_versionstays 8); v9 = single-rowMusic. Load-time rule: sources load withIsBackgroundderived fromType(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 - ✅ 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
- SQLite database (
Microsoft.Data.Sqlite) at%APPDATA%\ytLlive\ytLlive.db; schema versioned viaPRAGMA user_version. - Assets live in the DB, not on disk —
Assettable 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. - 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.
- 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.
- 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 viaALTER),SocialEntry(Id, SocialsId FK cascade, Service, Handle, ProfileUrl, SortOrder) —user_version8 (v1 → v2 =ALTER TABLEadds the two webcam columns; v3 = singletonWebcam+ per-sceneWebcamSceneConfig; v4 =WebcamSceneConfig.RectWidth/RectHeightfor the round-to-rect restore; v5 =Source.IsBackdropSource.CaptureKeyfor 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;EnforceBackdropPolicyre-normalizes every load); v7 =Scene.HasSocialBar(per-scene toggle dropped — column back-compat, unread); v8 =Socials.BarEnabled). TheSocialEntry.Softwarefediverse-software column is a column-presence migration (commented v8→v9, no version bump).WindowHandlestays in-memory (per-session). Save = transactional rewrite; orphaned assets pruned.
- 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
- ✅ 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.IsEditingremoved;IsHiddenstays persisted + dims hidden rows) - ✅ Source rows gained the trio — edit (inline rename via new
EditElementCommand+SceneElement.IsEditing), visibility eye (newToggleElementVisibilityCommandflipsSceneElement.IsVisible; the eye style now bindsIsVisible, open/slashed + row dims to 0.45 when hidden), and the existing trash - ✅ 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+AddReusedImageboth use it) - ✅ Social bar renders the full validated handle —
MaxWidth=200+TextTrimmingremoved from BOTHSocialBarRendererand the preview template (mastodon@gramps@…no longer cuts off) - ✅ Side panels stay fixed-width (left 220 / right 300) — deliberate: they never re-layout on resize, the preview absorbs it
- ✅ Focus-loss capture lag documented as a known OS limit in
ai.md— deferred by user decision (no code change)
Design decisions
- 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. - One WPF App per AppDomain — the real-App tests (round-clip + naming) share
RealAppHost(a dedicated STA thread owning the singleApp) via theRealAppserial collection, instead of each callingnew 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
- ✅
AudioLevelMeter.ToDisplaynow 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) - ✅ 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
- Amplify at the mapping, not in the VM — one knob (
ToDisplay), shared by the mic bar and the game bar; the× MicVolumegain-on-noise behavior is untouched (raising the slider still moves ambient noise up the bar). - +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)
- ✅ Real audio into the encoder — the mixer's loopback samples were dropped today (
AudioMixer.OnLoopbackSamplemetered only) andMicVolumewas meter-only;FfmpegArgs.cs:28ran-f lavfi -i anullsrc(silence). Transport: ffmpeg reads a Windows named pipe —-f f32le -ar 48000 -ac 2 -i \\.\pipe\ytllive_audioreplaces the anullsrc block, plus explicit-map 0:v -map 1:a; pipe name viaEncoderOptions.AudioPipeName(DefaultAudioPipeName = "ytllive_audio"). Mixer stays two inputs (mic + loopback) — no N-source abstraction, noStereoMixerclass. The VM owns the pipe lifecycle (BeginGoLive→_audioMixer.StartLive(pipeName),StopStream→_audioMixer.StopLive()before the pump stops);FfmpegEncoder.csis untouched. - ✅ Honest gains reach the stream —
MicVolumescales 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 asFunc<double>gain seams onAudioMixer(micGain/loopbackGain), wired byMainViewModel. - ✅ Voice filters (TASK 10, before the meter AND the mix) —
VoiceFilterChain: bass (LowShelfFilter120 Hz +4 dB) → treble (HighShelfFilter8 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. - ✅ 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. - ✅ TRAX — background music, FREE (keeps the one paid line = Alerts + flash removal).
MusicPlayer: NAudioMediaFoundationReader(mp3/wav/m4a) →VolumeWaveProvider16at the hardcoded 0.20 (fixed, not changeable) →WaveOutEventon the default device (added the siblingNAudio.WinMM2.2.1 package —WaveOutEventisn't inNAudio.Wasapi), loop on end. No 3rd mixer input and noMusicVolume— 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". - ✅ 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 likeGameSpeaker_MouseLeftButtonUp,e.Handled = trueso 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
OpenFileDialogfiltered to mp3/wav/m4a/aac/flac/ogg.
- ✅ 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 (theIGameAudioDetector/GameAudioDetector/GameAudioHysteresisstack + 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). - ✅ 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
WasapiCaptureexposes no overridableGetDefaultMixFormat, so both sources capture the device's own mix format and the mixer's pureTinyResamplernormalizes any rate/channel count to 48 kHz stereo (the resampler IS the design, not a fallback). Stop path disposes the audio pipe (EOF) inMainViewModel.StopStreambefore the pump stops, so both ffmpeg inputs end in order. - ✅ Schema v9 — single-row
Music(TrackPath,IsEnabled; volume is the 0.20 constant) + migration inLayoutStore.cs(user_version9;Savegained an optionalMusic? music = nullparam so existing 3-arg callers still compile; load query + null reset). - ✅ Docs in the same commit —
ai.mdaudio 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<double> 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 IAudioSources, 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
- ☑ Broadcast creation — title/description/privacy/scheduledStartTime via API, with the v3 flags above (SHIPPED:
CreateBroadcastsendsenableAutoStart/Stop,enableMonitorStream=false,latencyPreference=low,selfDeclaredMadeForKids=false) - ☑ Reusable stream — create once, cache + reuse; bind to broadcast (SHIPPED:
GetOrCreateReusableStreamAsynclists-then-inserts thevariable/isReusablestream, cached viaLayoutStoreSettings, bound at broadcast insert viaboundStreamId;_rtmpUrlProvideryields the ingest URL so go-live actually encodes + pushes) - ☑ Health monitoring — poll
liveStreams.listhealthStatus+configurationIssues[], surface banner only on warning/error (SHIPPED:GetStreamHealthAsync(streamId)30s while live; pureStreamHealthReporter.BannerFor= report-by-exception; banner strip under the top bar, amber warning / dark-red error, viaHealthIssueBanner/HealthIssueBackground; poll failures log-only; ONE integration testGetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error) - ☑ Live chat — poll
liveChat/messages, render in right panel, support Super Chat + membership badges — SHIPPED (status reconciled 2026-09-01:YouTubeChatServicepolls + parsessuperChatEvent/newSponsorEventwith badge/level onChatMessage; 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) - ☐ Error handling — the YouTube error codes:
errorStreamInactive,invalidTransition,redundantTransition,liveStreamDeletionNotAllowed,liveStreamModificationNotAllowed,liveBroadcastBindingNotAllowed - ❌ 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 inYouTubeStreamService.CreateBroadcast, wire the dialog selection). Seeai.md→ YouTube Live API constraints. - 🔶 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.categoryIdremoval: done (not aliveBroadcastfield). Monetization enablement (if ever needed) rides the reward-events chain vialiveBroadcasts.update, not a form field
Design decisions (v3)
- One-click go-live —
liveBroadcasts.insertwithenableAutoStart=true,enableAutoStop=true,enableMonitorStream=false,selfDeclaredMadeForKids=false,latencyPreference=low. Notransition(live)call, no testing stage, no liveStarting polling. Encoder starts → YouTube brings it live by itself. - 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. - Report-by-exception — poll
liveStreams.list; banner only onhealthStatuswarning/error issues (configurationIssues[]). Bottom strip = YouTube logo + green/red connection dot (clickable → opens the dialog). - 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. - Live edits —
liveBroadcasts.updatewith part=snippet,statusfor title/description/privacy. - End stream — stop encoder →
transition(complete), withenableAutoStopas the safety net. - Broadcast ID == Video ID — one ID to track status, health, and the auto-created VOD (
recordFromStart+enableDvr).
Requirements:
- Broadcast creation — title/description/privacy/scheduledStartTime via API, with the v3 flags above
- Reusable stream — create once, cache + reuse; bind to broadcast
- Health monitoring — poll
liveStreams.listhealthStatus+configurationIssues[], surface banner only on warning/error - Live chat — poll
liveChat/messages, render in right panel, support Super Chat + membership badges - Error handling — the YouTube error codes:
errorStreamInactive,invalidTransition,redundantTransition,liveStreamDeletionNotAllowed,liveStreamModificationNotAllowed,liveBroadcastBindingNotAllowed - Visibility picker — Private/Unlisted/Public from the go-live dialog
- 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
- ✅ Billing provider — Polar (polar.sh) chosen and configured. Product, price, license key benefit, discounts, checkout link all created.
- ✅ 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.
- ✅ Offline entitlement —
LayoutStoreSettings table:LicenseKey,LicenseValidatedAt,IsPremium. 14-day offline grace period. Startup re-validates online, falls back to cache. - ✅ BrandFlash → watermark toggle —
IsPremiumproperty setter flipsBrandFlashEnabled = !value. Flash stops immediately on premium activation. Flash asset VideoFrame rendering deferred to TASK 4 compositor. - ✅ Remove social slot locks — all 6 social bar slots open to everyone (no
IsPremiumgating on slots 3-6) — shipped as TASK 10c - ✅ "Unlock Premium" button —
PremiumUrlset to Polar checkout;IsPremiumAvailableis true; button lights up in the About hub. - ✅ Renewal/lapse handling — startup re-validates against Polar API. Lapse →
IsPremium = false→ flash returns. Grace period: 14 days offline. - 🔶 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:
- ✅ Desktop/game audio volume slider had no effect (headset OR stream) — the
AudioMixer'smicGain/loopbackGainseams defaulted to unity because the VM never passed them; the sliders were decorative. Fixed: newAudioGainProvider(Services/Audio/) handsMicMuted/MicVolume/GameMuted/GameAudioVolumeto the mixer at construction, read live each mix tick. - ✅ Desktop/game mute button had no effect — same root cause;
GameMutednow 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). - ✅ TRAX played once then stopped —
MusicPlayer.OnPlaybackStoppedonly looped whenPosition >= Length, unreliable forMediaFoundationReader; now any clean stop (e.Exception == null) rewinds + replays; only errors/explicit stops surface viaPlaybackEnded. - ✅ 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.
- ✅ Mic source persists across restarts —
LayoutStoregained aSettingskey/value table (SaveMicSourceName/LoadMicSourceName); the VM saves on pick and restores before the mixer's firstStart, so a restart reconnects the same already-vetted device (green dot) or reports it missing (yellow) instead of falling back to the default endpoint. - ✅ 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").
- ✅ Backdrop's icons shifted right — the trash button's
Collapsedreleased its column; a newHiddenBoolToVisibilityConverterkeeps the slot reserved (Hidden), so edit/eye stay in their fixed columns for every source row. - ✅ 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
- The gain seams are the one source of truth —
AudioGainProvideris a thin seam (four Funcs) so the mixer contract and the mute⇔zero-volume rule are testable without constructing the VM. - 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.
- Persist the DisplayName, not the device ID — the FriendlyName match was already the mic's
identity end-to-end;
Settingsjust 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.
MusicPlayerusesMediaFoundationReader, 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.FillAndMixsummed mic + loopback with no output ceiling — mic 100% + loud game/music could pass 0 dBFS and clip the AAC encode.
Shipped:
- ✅
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 ofAudioMixer.FillAndMix, right before the pipe write. - ✅ Unit tests — over-ceiling frames trimmed to ≤ ceiling; sub-ceiling frames never boosted; gain recovers to unity after the loud frame ends.
- ✅ 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. - ✅ 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
- ☐ Product positioning & messaging (one-liner, elevator pitch, competitive positioning)
- ☐ Social media swipe files (pre-written posts for supporters)
- ☐ Launch day assets (demo video, screenshots, GIFs, social graphics, press kit)
- ☐ Platform strategy (YouTube, Reddit, indie dev communities, Product Hunt)
- ☐ Founder story (67-year-old dev building his own streaming app)
- ☐ Email announcement templates
- ☐ "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
- ✅ TRAX volume —
LocalGain = GameAudioVolume * 0.2so TRAX is 20% of the desktop audio slider (was 100%, now quieter) - ✅ TRAX tooltip — replaced
ToolTipbinding withToolTipService.ToolTip+Placement="Top"to fix z-order inside Viewbox overlay - ✅ Mic meter boost —
MeterLevelmultiplied by 1.2× before clamping so the bar reads ~20% higher at same input level - ✅ Click-to-position sliders — clicking anywhere on the mic/game volume slider track now jumps the thumb to that position before starting the drag
- ✅ Backdrop visibility — the dedicated
<Image>now bindsVisibilitytoBackdropVisible(ViewModel property); compositor checksIsVisiblebefore blit - ✅ Rename "Backdrop" → "Game Capture" in
EnsureBackdrop()+ test assertions - ✅ Elements panel — new "ELEMENTS" section beneath Sources list; per-element config with live preview, ✕ revert, ✓ applied indicator
- ✅ Webcam border config — color hex input + color swatch picker, border thickness 0–10 slider
- ✅ Countdown source —
SourceType.Countdown, pick list (Starting/BRB scenes only), timer minutes 1–60 default 15 - ✅ Web source —
SourceType.WebSource, pick list (all scenes), URI text input - ✅ 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
ElementsScrollViewerinstead ofElementsPanel) - ✅ 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
- ✅ Web source URI hidden for webcam — URI section was showing for webcam due to WPF binding path
SelectedElement.Typenot resolving onWebcamSceneConfig; replaced withIsWebSourcevirtual property onSceneElement(mirrors existingIsWebcampattern), overridden inSourceto returnType == SourceType.WebSource, bound viaBoolToVis - ✅ 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
- ✅ Starting backdrop —
Assets/starting-backdrop.jpg(embedded resource),SeedStartingBackdrop()in MainViewModel, idempotent - ✅ BRB backdrop —
Assets/brb-backdrop.jpg(user-provided Gemini image),SeedBrbBackdrop()in MainViewModel, idempotent - ✅ Live backdrop —
Assets/live-backdrop.jpg(user-provided image), seeded viaSeedLiveBackdropAsset(), used as the static fallback (TASK 16) - ✅ Ending backdrop —
Assets/ending-backdrop.jpg(user-provided image),SeedEndingBackdrop()replaces existing (not skip-if-exists) - ✅ Chat backdrop —
Assets/chat-backdrop.jpg(user-provided image),SeedChatBackdrop()at startup - ✅
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:
ResolveAutoCaptureKey()returnsnullwhen no full-screen game is detected (was: primary monitor)ReacquireScreenCaptures()— clears staleCaptureKeywhenauto == null(handles DB upgrades); skipsAcquireAsyncwhen no keys are setSeedLiveBackdropAsset()— setsAssetIdon the backdrop element itself (NOT a Background source); called fromReacquireScreenCaptures()afterEnsureBackdrop()guarantees the element existsSource.DisplaySourcefalls back to_imageSourcewhenVideoImageSourceis null for live capturesAssets/live-backdrop.jpg— user-provided image, added as<Resource>in csprojResolveOutputFrame()in compositor —Source { IsLiveCapture: true, CaptureKey: not null }already skips null keys; fallback patternSource { 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)
- ✅ Add
Microsoft.Web.WebView2NuGet package - ✅ Schema v10:
WebUri TEXTcolumn onSourcetable + migration inLayoutStore.cs - ✅ Persist
Source.WebUrion save/load (currently in-memory only — lost on restart) - ✅ Hidden off-screen
WebView2control per web source — navigates toWebUri, renders in-app - ✅ Frame capture from WebView2 (
CoreWebView2.CapturePreviewAsync) →WriteableBitmap(BGRA8) - ✅ Wire into
FramePumpresolver —Source { Type: WebSource }→ latest WebView2 frame - ✅ Wire into
SceneCompositor— render web source as an image element at its position/size - ✅ Preview shows live web content (not just a blank rectangle)
- ☐ 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
- ✅
EncoderOptionsextended withStreamEnabled/RecordEnabled/RecordPath(independent intent flags) - ✅
FfmpegArgs.Buildreworked into per-output blocks (stream-f flv, record-f mp4) viaAddVideoTags - ✅ Three modes via pills: record-only / stream-only / stream+record (single ffmpeg, dual output)
- ✅ 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
- ✅ Record-folder persistence (
LayoutStoreRecordFolderkey) + picker (ChooseRecordFolderCommand) - 🟡 Branding flash carries into local recordings — same frame path as streaming (verify in the running app)
- ✅ Output folder
%APPDATA%\ytLlive\recordings\default; auto-namety-<yyyymmdd>-<hhmm start>-0000.mp4at start, rename-on-stop toty-…-<hh2mm2 length>.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.
- ✅ TASK 9.4 — Live chat (right panel) — wire
YouTubeChatService.Start(), parse messages, render in panel. Foundation for chat box source. - ✅ TASK 3.18 — Chat box source — renders chat ON the stream. Depends on TASK 9.4 (same message parsing).
- ✅ TASK 10 — Polar billing — license key entry + watermark toggle + Velopack auto-updates (steps 1-7 shipped; Velopack update URL pending).
- ✅ 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.
- ✅ 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.
- ✅ TASK 17 — Web source (WebView2) — shipped 2026-08-28; enables alert ecosystem.
- ✅ TASK 18 — Local recording — shipped 2026-08-29 (running-app verification pending in handoff).
- 🔶 TASK 21 — Media source — video file playback for non-Live scenes. Remaining: the UI picker slice (handoff spec in HANDOFF.md).
- ✅ TASK 22 — Audio sync offset — shipped 2026-08-31 (status reconciled 2026-09-01).
- ✅ TASK 15 — Stock bg images — all 5 scenes seeded (Starting, BRB, Live, Chat, Ending).
EnsureDefaultBackdrop()runs on every scene switch. - TASK 32 — Stream resilience (blip retry → grace countdown on the ON-AIR sign → one-click Back on air → crash-safe recording → pre-flight).
- TASK 33 — Bandwidth auto step-down (depends on TASK 32's restart machinery).
- TASK 34 — Scheduled streams (Text-drawer schedule + adoption at Start).
- TASK 35 — Scene-linked audio ("scenes remember the room").
- 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.
- TASK 13 — Social media launch kit (MARCOM) — after features, before gold.
- 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)
- ✅
GlobalHotkeyManagerservice —Services/GlobalHotkeys.cs: registers OS-level hotkeys on the window HWND viaRegisterHotKey/WM_HOTKEY(IHotkeyRegistrarseam for tests;RegistrarOverridemirrorsLayoutPathOverride). Wired inMainWindow.OnSourceInitialized. - ✅ Scene switching hotkeys — F1-F5 stage/transition the five canonical scenes.
- ✅ Common action hotkeys — F6 start/end stream, F7 mute mic, F8 mute desktop audio, F9 TRAX play/pause. Dispatch lives in
MainViewModel.HandleHotkeyand honors command gating. - ✅ 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.). - ✅ Global hotkeys — work even when app is not focused (that is what
RegisterHotKeygives us) - ✅ Conflict detection — warns when a chord is already bound to another action; user must unbind first.
- ✅ Persistence —
LoadHotkeyBindings()/SaveHotkeyBindings()inLayoutStore, stored as"modifiers+vk"in Settings table. No schema migration needed. - ✅ Test:
GlobalHotkeyTests.WmHotkey_StagesScene_And_TogglesMic_And_RevokesOnClose— real window + fake registrar, a genuineWM_HOTKEYposted through the window's ownHwndSourcehook stages the mapped scene and flips mic mute; close revokes every registration. - ✅ 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").
- ✅
MediaSourceTypeenum:Video,Audio(audio-only files via media source) - ✅
SourceType.MediaSourceaddition to the enum - ✅
MediaSourceModel:MediaPath,MediaIsLooping,MediaVolume(0-1),MediaPlaybackState— persisted in LayoutStore (schema migration + SELECT/INSERT + round-trip test) - ✅
MediaVideoSourceimplementsIMediaFrameSource: FFmpeg raw video decoder →VideoFramepipeline — spawns ffmpeg-f rawvideo -pix_fmt bgra, drains the pipe via pureRawVideoFrameReader(Services/RawVideoFrameReader.cs), raisesFrameAvailable/Completed; lifecycle isStartAsync/StopAsync; decode process behindIDecodeProcess/FfmpegDecodeProcessseam (binary-stdout mirror ofIEncoderProcess). Shipped 2026-08-31; refactored ontoIMediaFrameSourcewith this slice. 4b. ✅ Slice 2a — native-FPS probe seam:FfmpegFrameRateParser(pure, prefersavg_frame_rate=thenr_frame_rate=, rational N/N/M, unknown→null) +IFrameRateProbe/FfmpegFrameRateProbe(derives siblingffprobe.exefrom the located ffmpeg dir, reuses theIDecodeProcessseam; null if ffprobe absent) +FfmpegLocator.ProbeFileNamenow also extractsffprobe.exefrom the pinned archive (conditional). Tests:FfmpegFrameRateParserTests(6 pure units + 1 probe integration via fake locator/process) +FfmpegLocatorTestsstill green — 15/15. - ✅
IMediaFrameSource+MediaVideoSourceManager(Services/IMediaFrameSource.cs,Services/MediaVideoSourceManager.cs): app-wide decode-session ownership refcounted byMediaPathwith aFunc<string, IMediaFrameSource?>factory seam;AcquireAsync/ReleaseAsync/ReleaseAllAsync/GetLatestFrame; coalesces frames onto the UI dispatcher onto a single sharedWriteableBitmapper file (mirror ofScreenCaptureManager);MediaFailed+PreviewBitmapChanged. Unit tests + one integration test (bitmap share/coalesce) —MediaVideoSourceManagerTests, 5/5 pass. - ✅ Native-FPS pacing (slice 2b):
MediaVideoSourcetakes optionalIFrameRateProbe?+Func<TimeSpan,CancellationToken,Task>? delayseams, probes FPS once inRunAsync, 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). - ☐ Wire into
FramePumpresolver —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) - ☐ Wire into
SceneCompositor— render media source as an image element at its position/size - ✅ Loop control (mechanism) —
IMediaFrameSource.Looping;MediaVideoSourcetakes aFunc<IDecodeProcess>process factory and restarts the decode on natural EOF whenLooping(fresh process per pass, since aProcesscan't be re-Start()ed). Test:MediaVideoSource_LoopsUntilLoopDisabled(single frame re-emits across passes,Completedonly after loop cleared). WiringSource.MediaIsLoopinginto the flag lands with the UI-picker (acquisition) slice. - ☐ Volume control — per-source volume slider for audio playback
- ☐ UI: file picker (filtered to video formats), loop toggle, volume slider
- ☐ Schema migration for media source settings (file path, loop, volume)
- ☐ 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."
UI picker slice — spec (imported from HANDOFF 2026-09-01 so it can't be lost by a rewrite)
Nothing AcquireAsyncs a media path yet, so no frames flow in a running app — decoder, manager,
pacing, and loop mechanism are shipped and unit-tested, but a media Source has no way to start a
session. Deliverables, in order:
- File picker + add/remove commands (mirror
MainViewModel.Trax.cs:68OpenFileDialogusage). A "Media" source action opens anOpenFileDialogfiltered to video (mp4/mov/avi); on OK setSource.MediaPath, add to scene, andAcquireAsync(MediaPath); on removeReleaseAsync(MediaPath)(refcounted — notReleaseAllAsync, unless the path leaves the layout entirely). OnStagedScenelayout loads/teardown, release every media path no longer present and acquire new ones so sessions track the live layout. - Wire
Source.MediaIsLooping→IMediaFrameSource.Looping— needs a manager-level per-path loop provider. Ambiguity to rule + record: sessions are refcounted perMediaPathand shared across scenes, butMediaIsLoopingis per-Source. Chosen rule: the path's sessionLooping= any live reference has it on (shared behaviour). Record it in the slice commit. - Loop toggle + volume slider in the source context menu / inspector (items 10/11).
MediaVolume/MediaPlaybackStatewiring — audio path for media is future work (desktop loopback already carries it, TRAX-style;MediaVolumeslider can drive the local file's audio separately later — out of this slice).
Design notes to preserve: ResolveOutputFrame reads _mediaManager.GetLatestFrame(MediaPath);
OnMediaPreviewBitmapChanged adopts the shared WriteableBitmap per path; MediaFailed →
OnMediaFailed (currently Debug.WriteLine — surface in UI via toast, TASK 24 stack). The compositor
scales any frame size — media renders through the generic frameFor(element) path, no compositor
change. First GUI smoke test: add a short real mp4 on native Windows, confirm it plays and previews.
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)
- ☑
AudioSyncOffsetMsonMainViewModel(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+). - ☑ Applied in
AudioMixer— post-mix interleaved-stereo delay via the pureAudioSyncDelayline (Services/Audio/AudioSyncDelay.cs), fed through aFunc<int> syncOffsetMsseam each mix tick. - ☑ UI: compact "SYNC" slider (0..500) on the mic bar with a status dot (green = no-op, amber = offset set) via
IntToSyncBrushConverter. - ☑ Persisted in
LayoutStore.Settings(Audio.SyncOffsetMs) viaLoadAudioSyncOffsetMs/SaveAudioSyncOffsetMs; saved fromSaveLayoutNow. - ☑ 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:
- 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. - 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. - Behavior: Info ~4s / Success ~4s / Warning ~8s auto-dismiss; Error sticky until dismissed.
- Styling: dark card tints matching the health-banner language (slate
#3a3b52info, green#1d5c38success, amber#b8860bwarning, dark-red#8f1f1ferror); corner radius 8; keep-visible-on-hover. - 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
Nameagainst the request'sAreaName; 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:
- Exactly ONE background per screen, named "Background", at position 0.
- It cannot be re-ordered or deleted; no Background/Screen item in the (+) menu.
- Context-sensitivity exists only on the Live screen (Show Desktop toggle + monitor switching — behavior kept intact).
- 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 =DisplayCapturerow (capture machinery untouched), everything else = staticBackgroundart 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 derivesIsBackground— 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.jpgviaAddAsset; 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 orphanedBackgroundUseDefault_{guid}keys. - Settings purge:
LayoutStore.SavedeletesBackgroundUseDefault_{id}/BackgroundPath_{id}keys whose id is no longer a Source row. - (+) menu: Screen + Background items removed → Webcam/Image/Text/Countdown/Web/YouTube Chat;
AddSourcealso 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'sIsBackgroundthrough a newHelpers/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 makingLoadBackgroundImageflavor-blind (IsBackground) and gating staged-Live to the placeholder inRefreshSnapshotsAsync. (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.RecordFolderDisplayproperty,ResetRecordFolderCommand, Browse + "Use Downloads" reset.TextBoxbinding fixed toMode=OneWay(get-only computed property).- ONE fallback test:
DefaultRecordFolder_Falls_Back_To_Downloads.
Single Start button:
- Button
Content="Start"hardcoded in XAML. RemovedPrimaryButtonText/IsStreamingStartproperties + allOnPropertyChangedreferences. 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/MouseLeavehandlers pulse theSocialBarElementDropShadowEffect(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
BitmapImageno longer loads in the constructor (stale image from last session). - Only loads when
IsConnectedfirestrueviaOnViewModelPropertyChanged.
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) onSceneElementbase, overridden inSourceandWebcamSceneConfigServices/SceneGraph.cs: ownsScenescollection (ViewModel'sScenesproperty delegates to it); mutation surfaceAddElement/InsertElement/RemoveElement/MoveElement(each invalidates the bake); queriesGetBackground/GetWebcam/GetChatBoxes/GetSplitPoint/IsStatic; and theGetBakedBasebake-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 frameFramePumptakes an optionalSceneGraphand 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):
ChatOverlayLayerkeeps takingIEnumerable<Scene>rather thanSceneGraph.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.- Background static helpers (
EnsureBackground/CreateBackground/NormalizeBackgrounds) stay on the ViewModel becauseBackgroundTests.csunit-testsMainViewModel.EnsureBackgrounddirectly. Queries moved to SceneGraph; helpers + their invalidation wiring stayed on the VM. - 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
Scenesdelegates. 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 — CORRECTED
2026-09-01: they DO run from WSL when invoked per-class through the Windows dotnet.exe vstest host
(the old "cannot run headless" claim conflated them with the full-suite WASAPI hang). Two first-launch
crashes this refactor shipped with were caught on the first real native launch and fixed same day:
ctor-order SceneGraph NRE (null! field assigned after first ctor use — now field-initialized) and
window-scope EyeButton/EyeIconStyle consumed via StaticResource from the extracted LeftPanel
(UserControl namescopes can't see window resources — styles moved to Themes/Controls.xaml, the
app-scope rule honored). The RoundClip "known failure" was then root-caused to stale test code
(window.FindName across namescopes + VisualTreeHelper.HitTest where UIElement.InputHitTest
models input) — test green 2026-09-01, the sole remaining known failure is the audio one.
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<Scene> 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<Source>
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<Source>().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:
- Extract
SceneGraphas a standalone class inServices/ - Move
Scenescollection +StagedScene/LiveScene+ wiring - Move element mutation surface (add/remove/move)
- Move background normalization/queries
- Add
ElementKindtoSceneElementbase - Add
GetSplitPoint+BakedSceneCache - Update compositor to use split point
- Wire ViewModel as thin facade
- 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):
- ☐ 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.
- ☐ 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. - ☐ 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. - ☐ 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:FfmpegArgsemits the movflags only on the record block. - ☐ 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.
- ☐ 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. - ☐ 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).
- ☐ Surface: the resolution badge + dropdown update to the landed tier; ONE Info toast ("Quality lowered to hold your stream") — no nagging after that.
- ☐ 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.
- ☐ Drawer gains ☑ Scheduled livestream + date/time (replacing the read-only "Scheduled Start"
row). Checking + Save creates the broadcast: insert
liveBroadcastswith a futurescheduledStartTime, bound to the cached reusable stream — the drawer's Save path grows its first create operation (today it can only update an existing broadcast). - ☐ Invariant: at most one open scheduled broadcast; unchecking before airtime →
liveBroadcasts.delete(legal in created/ready). - ☐ 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).
- ☐
LiveBroadcastFormViewModelownsIsScheduled+ScheduledStart(nullable), persisted with the otherBroadcast.*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). - ☐ 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. - ☐ 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.
- ☐
Scenegains nullableSceneMutesMic/SceneMutesDesktop—null= don't touch (the default; scenes are passive until told). Schema column bump + roundtrip. - ☐ Applied on live transitions only (thumbnail click / hotkey), ONCE at entry — never on offline staging (no surprise stream-state edits while auditioning).
- ☐ The creator is boss: a manual mute/unmute mid-scene is never overridden until the next entry.
- ☐ BRB ships
SceneMutesMic = true; other scenes defaultnull— creator's choice. - ☐ Edit UI: two checkboxes in the scene's Properties section (left panel, edit mode) — no new chrome.
- ☐ Test (the ONE): transition applies scene state;
nullleaves 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.
- ☐ Visibility unlock — remove the
CreateBroadcastPrivate override (TASK 9 item 6); dialog selection rules. The channel-protection stance ends at GA, by hand, here. - ☐ 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.)
- ☐ 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.
- ☐ 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).
- ☐ 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 carryexpires_atreliably — cite before building. - ☐ Release engineering (the
Distribution.mdplan, 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 atHARDENED(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.