Encoder dual-output: StreamEnabled/RecordEnabled/RecordPath options; per-output ffmpeg block (stream ->flv, record ->mp4); throw unless at least one output. RecordingFile: auto-name ty-<yyyymmdd>-<start hhmm>-0000.mp4, rename-on-stop to ty-...-<len hhmmss>.mp4 with numeric-suffix on collision. LayoutStore persists record folder. MainViewModel: REC/ON-AIR pills, status light, single Start<->End button (replaces Start/Stop variants), StopStream no longer signs out, explicit session timer. About overlay widened. Sign In label + spacing before Start. Tests: 244 passed / 246 (2 pre-existing: AudioPipeline gains-mute, RoundClip). Build 0 warnings. Scope check clean. Memory-map correction (derived-solution rule, 2026-08-29): image-shrink recipe was never recorded when first done, so it was re-derived. Added Derived-solution/recipes rule to AGENTS.md; MyMistakes.md now a permanent recipes registry (records the verified WPF-imaging shrink recipe); schema.md rows MyMistakes as registry; ai.md points to the rule. README hero image replaced: 3.2MB screenshot -> 1400x794 -> 188KB docs/ytLlive-preview.jpg (JPEG q82).
115 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)
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.
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 out — 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 - ✅ 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
- ✅ 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 — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1)
- ☐ 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; the one paid feature (see Monetization in
ai.md) - ✅ 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)
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. Also the one
paid feature — see Monetization in
ai.md.
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: 🔶 In progress
- ✅ 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 + reconnect. 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 —
https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip(~75 MB zip — earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention keeps the last 14 daily builds + each month-end build for 2 years, so a cold cache after retention expiry 404s — a logged, recoverable failure (the seam throws; the encoder step surfaces it). Once cached, the URL is never touched again. The pin is a 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 + reconnect, 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); items 4–7 still open (each its own branch/PR)
- ☑ 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 - ☐ Error handling — the YouTube error codes:
errorStreamInactive,invalidTransition,redundantTransition,liveStreamDeletionNotAllowed,liveStreamModificationNotAllowed,liveBroadcastBindingNotAllowed - ☐ Visibility picker — remove temporary "always Private" enforcement (shipped as test-only; now unlocked for v1). User picks Private/Unlisted/Public from the go-live dialog. Trivial: remove the hardcoded override in
YouTubeStreamService.CreateBroadcast(currentlyprivacyStatus = "private"regardless of dialog selection) - ☐ Full broadcast form — expose all YouTube API-supported fields in the go-live dialog. Core tab: title, description, visibility, made-for-kids, schedule (start + optional end). Advanced tab (expandable, sane defaults): latency (Normal/Low/Ultra-Low), DVR, embed, record-from-start, projection (rectangular/360°), closed captions, auto-start, auto-stop, monitor stream, region restrictions. Monetization via
liveBroadcasts.update(insert-only on that resource) — separate step after broadcast creation. Remove unsupportedcategoryId(not aliveBroadcastfield, silently ignored)
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.
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: 🔄 In progress — encoder/CLI + VM + top-bar UX shipped (2026-08-29)
- ✅
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: manual rename dialog for the user (toast actions unsupported — modal window was planned but parked), 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. Steps 1-2 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) — enables alert ecosystem.
- TASK 18 — Local recording — independent, but pairs with stream.
- TASK 21 — Media source — video file playback for non-Live scenes.
- TASK 22 — Audio sync offset — small, quality-of-life.
- ✅ TASK 15 — Stock bg images — all 5 scenes seeded (Starting, BRB, Live, Chat, Ending).
EnsureDefaultBackdrop()runs on every scene switch. - TASK 13 — Social media launch kit — after all v1 features ship.
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: ◐ In progress — step 2 shipped 2026-08-26
- ✅
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: ☐ Not started — required for v1
- ☐
MediaSourceTypeenum:Video,Audio(audio-only files via media source) - ☐
SourceType.MediaSourceaddition to the enum - ☐
MediaSourceModel:FilePath,IsLooping,Volume(0-1),PlaybackState - ☐
VideoFrameSource: FFmpeg-based video decoder →VideoFramepipeline - ☐ Frame capture from video file (decode at native FPS, output BGRA8 frames)
- ☐ Wire into
FramePumpresolver —Source { Type: MediaSource }→ latest video frame - ☐ Wire into
SceneCompositor— render media source as an image element at its position/size - ☐ Loop control —
IsLoopingproperty, restart on end - ☐ 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."
TASK 22 — Audio sync offset
Goal: per-source audio delay compensation to prevent lip-sync drift from USB mics and capture cards.
Status: ☐ Not started — required for v1
- ☐
AudioSyncOffsetproperty on AudioSource models (default 0ms, range -500ms to +500ms) - ☐ Apply offset in
AudioMixer— delay or advance audio samples relative to video - ☐ UI: offset slider per audio source (or global offset for simplicity)
- ☐ Persist offset in
LayoutStore(schema migration) - ☐ Tests: offset application, positive/negative delay, boundary values
Design decisions
- Global offset first — one setting for all audio sources. Per-source is v1.1+.
- Simple slider — -500ms to +500ms, default 0. No numeric input needed.
- Visual feedback — show a "sync OK" indicator when offset is applied.
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.
Backlog (future versions)
- v1.1 — Stream Deck / Loupedeck integration (requires hotkey foundation from TASK 20)
- v1.1 — Per-source audio sync offset (global offset ships in TASK 22)
- v1.1 — Multiple profiles/presets (save different configs for different stream types)
- v1.1 — Chroma key filter (green screen removal, or ONNX background removal)
- v1.1 — Virtual camera output (Zoom/Discord/Teams)
- v1.1 — Replay buffer (instant replay with hotkey)
- v2 — Multi-destination restreaming (if needed; casual streamers may outgrow LlamaCasty first)
- v2 — Stream clipping
- v2 — Export/import settings