From e1d8b10388d8798bdcf94799160c925e0f64f61b Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 1 Sep 2026 19:03:22 -0700 Subject: [PATCH] =?UTF-8?q?docs:=20v1=20=3D=20feature-complete=20ruling=20?= =?UTF-8?q?=E2=80=94=20queue=20TASKs=2032-36,=20close=20the=20out-of-produ?= =?UTF-8?q?ct=20list,=20fix=20the=20map's=20lies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of institutional knowledge lost across the refactor (creator PM session 2026-09-01): - New queue: TASK 32 resilience (blip retry/measured-grace sign/one-click back-on-air/ crash-safe fragmented-MP4 recording/pre-flight), TASK 33 auto step-down (v1 - the map claimed it existed; it didn't), TASK 34 drawer scheduling, TASK 35 scene-linked audio, TASK 36 gold pass (visibility unlock, flash-live enable, screens/layers audit, native verification suite, expiry reminders, signing/installer/EULA/Velopack, compile-flag ceiling HARDENED+MOCK_REWARDS). - 'v1 = the finished product' + closed 'Out of product' list (Stream Deck, profiles, chroma, virtual cam, replay buffer, restream, clipping, advanced-tab, bug-reporter, D3DImage preview) - the 10% margin, bounded and written. - Corrections: ffmpeg 'does reconnect' claims (input-side flags only; retry is app-side); TASK 2's orphaned 'resume deferred to TASK 3' now owned by TASK 32; TASK 2 End-signs-out superseded by TASK 18 explicit sign-out; Alerts freed from stale 'the one paid feature' language (paid = flash removal only); TASK 3 item 16 superseded; TASK 9 items 4/6/7 reconciled; TASK 10 monetization chain scoped v1; README roadmap/Structure rewritten. --- README.md | 29 ++++---- TASKS.md | 218 +++++++++++++++++++++++++++++++++++++++++++++++------- ai.md | 31 ++++---- 3 files changed, 223 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index f03a8af..7c339fa 100644 --- a/README.md +++ b/README.md @@ -45,25 +45,28 @@ dotnet run ## Version Roadmap -| Version | Scope | -|---------|-------| -| v0.1 | Scene/source management, YouTube RTMP ingest, YouTube OAuth2, live chat, stream health | -| v0.2 | Recording to local file | -| v0.3 | Stream scheduling | -| v0.4 | Multi-destination restreaming | -| 1.0 | General availability | +**v1 is the finished product** (creator ruling, 2026-09-01 — there are no v1.x releases). +Everything still queueing lands in v1; anything not on the feature list is on the closed +**"Out of product — permanently"** list in `TASKS.md` — read that page before asking for a feature. + +What v1 ships: the five-scene director's control surface, webcam + screen capture, media + web + +text sources, chat on-stream, the social bar, mic/desktop audio with auto-duck and TRAX, local +recording, variable quality tiers with auto step-down, one-click go-live + scheduling, stream +resilience (auto-reconnect inside YouTube's grace, one-click back on air), built-in monetization +awareness (reward capture, session reports, YPP journey tracker), Alerts — all free and ungated; +the subscription only removes the branding flash. ## Structure | Path | Role | |------|------| -| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat, Socials (social bar) | -| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel, SocialsDialogViewModel | -| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite), SocialValidator (social link/fediverse validation + nodeinfo icon resolution) | -| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, FocusPreservingListBox, OAuthCredentials | +| `Models/` | Scene, Source, WebcamSceneConfig, SceneElement, SceneCatalog, QualityOption, StreamConfig/Health, chat, Socials, BroadcastMetadata, Music, MicStatus | +| `ViewModels/` | MainViewModel (partial classes per area) + dialogs: GoLive, CameraPicker, MicPicker, HotkeyConfig, Socials, LiveBroadcastForm | +| `Services/` | YouTube (OAuth, streams/broadcasts, chat polling), LayoutStore (SQLite), capture (camera/screen-capture/media-file via FFmpeg), SceneCompositor + FramePump, FFmpeg encoder + RTMP, audio pipeline (WASAPI loopback + mic, mixer, filters, ducker, limiter, TRAX), SocialValidator, notifications, hotkeys, SceneGraph, ChatOverlayLayer | +| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, TokenStore (DPAPI), OAuthCredentials, converters | | `Themes/` | `Controls.xaml` — the dark-theme control styles, merged once in `App.xaml` | -| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar | -| `SocialsDialog.xaml` | Social Media Site Promotion dialog — 6-slot social bar editor with sign-in gate and validation | +| `MainWindow.xaml` | Dark-theme control surface: thumbnails + central preview, two-state left panel (layers/properties ↔ live chat), top bar, footer | +| `Distribution.md` | GA-time plan: signing, installer, EULA, hardening — executed as TASK 36 | ## Docs (memory map) diff --git a/TASKS.md b/TASKS.md index 3d63b5f..4b1fdc6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -9,6 +9,10 @@ > 3. ☐ — not completed / pending (empty box) > 4. ❌ — exception (blocked, known-issue, or deliberately excluded from this build) +> **2026-09-01 — v1 = feature-complete ruling:** there is no v1.x. Everything queues to v1 or to +> **"Out of product — permanently"** (file end). Read those two sections before adding or reviving +> anything here. + ## YouTube Live API — research facts (authoritative, v3 build) Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`). @@ -22,7 +26,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional ### Two features that reshape the design 1. **enableAutoStart / enableAutoStop** — instant one-click go-live, no transition call. With `enableAutoStart=true` we never call `transition(live)`: the broadcast auto-goes-live the moment the encoder starts. Combined with `enableMonitorStream=false` (our preview pane replaces YouTube's monitor stream — the thing that forces a testing stage), the flow is **create → bind → Start Stream → encoder starts → YouTube brings it live**. No testing, no transition polling, no liveStarting stuck-state handling. -2. **`cdn.resolution=variable` / `cdn.frameRate=variable`** — free auto step-down. YouTube auto-detects what we send; since we ARE the encoder we can drop bitrate/resolution on the fly with zero API calls. Declaring an explicit resolution instead (e.g. 1080p) requires a new stream, which can't happen mid-broadcast. Variable is the enabler for the whole auto step-down feature. +2. **`cdn.resolution=variable` / `cdn.frameRate=variable`** — free auto step-down. YouTube auto-detects what we send; since we ARE the encoder we can drop bitrate/resolution on the fly with zero API calls. Declaring an explicit resolution instead (e.g. 1080p) requires a new stream, which can't happen mid-broadcast. Variable is the enabler for the whole auto step-down feature. **(Claim-check 2026-09-01: the enabler is real, the governor is not built — the drop-side policy ships as TASK 33, v1 per the complete-v1 ruling.)** ### Compliance gotchas (maps perfectly to report-by-exception) @@ -66,7 +70,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional 2. ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch 3. ✅ Token persistence via Windows DPAPI (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`), best-effort reload + proactive refresh at startup, saved after every exchange/refresh 4. ✅ Account sign-in/change surfaced in the GoLive dialog (saved account shown with "Change Account"; "Sign in to YouTube" when none; Start disabled until signed in) -5. ✅ **End Livestream signs out** — a graceful end completes the session: `StopStream()` calls `YouTubeAuthService.ClearSession()` + `TokenStore.Clear()` + `IsConnected = false`, so the next Start Stream dialog requires a fresh sign-in. A crash never runs End, so the DPAPI token survives and the creator stays signed in. Resume/reconnect after a midstream crash is deliberately deferred to TASK 3: the socket can't be resumed (it dies with the process), so "resume" = fast reconnect with a saved broadcast ID/stream key within YouTube's disconnect-grace window; too slow and `enableAutoStop` ends the broadcast +5. ✅ ~~**End Livestream signs out**~~ **SUPERSEDED by TASK 18 (2026-08-29): explicit sign-out only** — `StopStream()` no longer clears the session (sign out via Logout / Change Account, `SignOutYouTubeAsync`); stopping a stream or recording leaves the creator signed in. Originally shipped as: a graceful end completes the session: `StopStream()` calls `YouTubeAuthService.ClearSession()` + `TokenStore.Clear()` + `IsConnected = false`, so the next Start Stream dialog requires a fresh sign-in. A crash never runs End, so the DPAPI token survives and the creator stays signed in. Resume/reconnect after a midstream crash is deliberately deferred to TASK 3: the socket can't be resumed (it dies with the process), so "resume" = fast reconnect with a saved broadcast ID/stream key within YouTube's disconnect-grace window; too slow and `enableAutoStop` ends the broadcast. **Orphan closed 2026-09-01:** this mechanism is owned by **TASK 32 (Stream resilience)** — in-session blip retry inside the grace window; cross-process "resume" of a dead broadcast is officially out-of-product (the API makes it impossible — creator ruling). 6. ✅ Tests in `ytLive.Tests` (xUnit, net8.0-windows): TokenStore DPAPI roundtrip/corrupt/missing/clear + mocked exchange channel-parse + refresh expiry bump + `ClearSession` — 7 passing **Design constraint:** Sign-in must NEVER block core exploration. Users can build scenes, add sources, and audition the software without authenticating. But **going live requires authentication** — the "Start Stream" dialog is where the account sign-in lives, alongside all stream metadata. @@ -115,11 +119,11 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional 13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES) 14. ✅ **Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop** — `ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo` → `software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there. 15. ✅ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending -16. ☐ **Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1) +16. ❌ **Scene compositing (D3DImage/MediaElement)** — SUPERSEDED, permanently out (2026-09-01): the software output compositor (TASK 4 step 1) + the XAML preview ARE the design; a D3D11 swap stays a possibility behind the `VideoFrame` seam, not a feature 17. ☐ **Text source** — live text ("Starting soon", "Back in 5", handle, callout) 18. ✅ **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video — `ChatBoxRenderer` (WPF FormattedText → VideoFrame), configurable font size/color/badges/timestamps/max-messages via Elements panel, schema v10, persisted across save/load 19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build -20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`). **Precursor — reward-event capture (2026-09-01):** Alerts render from the app's canonical `RewardEvent` feed (all 7 `liveChatMessage` reward types, persisted to SQLite + the session report — see TASK 10 → "Related work — monetization awareness"), so it consumes that already-built data rather than re-integrating `liveChatMessages`. That capture is the required prior milestone. +20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **free and ungated** (the stale "one paid feature" label predates the 2026-08-31 monetization reversal and is corrected 2026-09-01 — the paid swap is the branding flash ONLY; see Monetization in `ai.md`). **v1 IN** per the complete-v1 ruling. **Precursor — reward-event capture (2026-09-01):** Alerts render from the app's canonical `RewardEvent` feed (all 7 `liveChatMessage` reward types, persisted to SQLite + the session report — see TASK 10 → "Related work — monetization awareness"), so it consumes that already-built data rather than re-integrating `liveChatMessages`. That capture is the required prior milestone. 21. ✅ **Show Desktop toggle** — `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem). 22. ✅ **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` → GoLiveWindow. `ShowStartStream` now requires `IsConnected`. 23. ✅ **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear. @@ -147,9 +151,9 @@ the hot few and nothing esoteric. If a user needs more, they've graduated to OBS 5. **Text** — live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this. 6. **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native. 7. **Alerts** — Super Chat / membership / subscribe pop-ins. The dopamine source. **The one big lift** - (Super Chat event streaming + on-stream rendering/animation); build after the six. **Also the one - paid feature** — see Monetization in `ai.md`. Consumes the canonical `RewardEvent` feed (reward-event - capture, TASK 10 → related work). + (Super Chat event streaming + on-stream rendering/animation); build after the six. **Free and + ungated** — paid = flash removal only (stale "one paid feature" corrected 2026-09-01). Consumes + the canonical `RewardEvent` feed (reward-event capture, TASK 10 → related work). Deliberately NOT supported: game capture, browser source, media playlist, VLC, color-key voodoo, MIDI. @@ -234,7 +238,7 @@ until a frame source exists, so the compositor is ship step 1. The pipeline is ### Requirements: 1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are. **License guardrails (never violate — see `ai.md` → "Licensing — do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen. -2. **RTMP push** — **FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push + 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` (extract `ffmpeg.exe` **plus the `libav*.dll` family**) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL +2. **RTMP push** — **FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push. **No self-heal (claim-check 2026-09-01): ffmpeg's reconnect flags are input-side; an RTMP *output* push does not reconnect itself — retry is app-side, owned by TASK 32.** **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (**BtbN LGPL-shared win64** zip, ~75 MB — gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract `ffmpeg.exe` **plus the `libav*.dll` family**) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL 3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**: 1. 720p30 @ 6 Mbps 2. 720p60 @ 6 Mbps @@ -384,7 +388,7 @@ cache refresh, empty payload, missing zip entry, downloader failure) — **78 pa #### 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 +ingestion URL — one battle-tested subprocess doing encode + mux + push (retry is app-side, TASK 32 — ffmpeg does not self-reconnect an RTMP output), the app feeding frames via stdin and parsing stderr for health (req 2). **Decisions (locked):** the encoder is a thin orchestrator over `ffmpeg.exe` — no H.264/AAC code in the @@ -672,15 +676,15 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash), **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) +### Status: ⏳ In progress — items 1–3 SHIPPED (reusable stream 2026-08-16; report-by-exception health 2026-08-16); item 4 SHIPPED (status reconciled 2026-09-01); item 5 open; item 6 = deliberate lock (TASK 36); item 7 scoped to core fields — advanced tab is out-of-product 1. ☑ **Broadcast creation** — title/description/privacy/scheduledStartTime via API, with the v3 flags above (SHIPPED: `CreateBroadcast` sends `enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`, `selfDeclaredMadeForKids=false`) 2. ☑ **Reusable stream** — create once, cache + reuse; bind to broadcast (SHIPPED: `GetOrCreateReusableStreamAsync` lists-then-inserts the `variable`/`isReusable` stream, cached via `LayoutStore` Settings, bound at broadcast insert via `boundStreamId`; `_rtmpUrlProvider` yields the ingest URL so go-live actually encodes + pushes) 3. ☑ **Health monitoring** — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error (SHIPPED: `GetStreamHealthAsync(streamId)` 30s while live; pure `StreamHealthReporter.BannerFor` = report-by-exception; banner strip under the top bar, amber warning / dark-red error, via `HealthIssueBanner`/`HealthIssueBackground`; poll failures log-only; ONE integration test `GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error`) -4. ☐ Live chat — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges +4. ☑ Live chat — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges — **SHIPPED** (status reconciled 2026-09-01: `YouTubeChatService` polls + parses `superChatEvent`/`newSponsorEvent` with badge/level on `ChatMessage`; the panel MOVED to the left-panel live state by Control Surface UX, "right panel" superseded; the remaining reward types land with the monetization chain, TASK 10 → related work) 5. ☐ Error handling — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed` 6. ❌ **Visibility picker** — DELIBERATE TEST-PHASE LOCK (creator decision 2026-09-01), not open work: "always Private" stays during multi-month real-world testing so breakage VODs never clutter the channel. `recordFromStart`/DVR stay on — Private VODs are invisible review tapes; bulk-delete pre-GA. Unlock = TASK 36 gold-pass item (remove the override in `YouTubeStreamService.CreateBroadcast`, wire the dialog selection). See `ai.md` → YouTube Live API constraints. -7. ☐ **Full broadcast form** — 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 unsupported `categoryId` (not a `liveBroadcast` field, silently ignored) +7. 🔶 **Full broadcast form** — RESCOPED 2026-09-01, not the old two-tab plan. **Core fields (SHIPPED as the Text drawer, 2026-08-24):** title, description, tags, visibility, made-for-kids — live-editable. **Scheduling:** ships as **TASK 34** (Scheduled checkbox + datetime in the same drawer). **Advanced tab is PERMANENTLY OUT** (the 10% margin): latency (locked `low`), DVR/record-from-start (locked on), embed, projection, CC, region restrictions stay fixed at sane defaults, invisible — every exposed field is a support ticket. `categoryId` removal: done (not a `liveBroadcast` field). Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field ### Design decisions (v3) @@ -719,6 +723,8 @@ in-app replacement for the emailed "stream activity report"), and a journey trac ETA vs. versioned, date-aware YPP thresholds; the Tier-2 bar doubles for new applicants 2027-02-01). Details and design notes: `ai.md` → Monetization. This is tracked as related work under this task's narrative, not a separate task number — it ships in its own code slices with one integration test each. +**Scope: v1** (2026-09-01 complete-v1 ruling — there is no v1.x): slice order = reward-event capture → +session report → journey tracker → Alerts (TASK 3 item 20). **Business details (pricing, Polar product/checkout/discounts) in `MONETIZATION.md` (gitignored).** @@ -995,10 +1001,16 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev 5b. ✅ **Broadcast metadata pull-out + launch geometry** — shipped 2026-08-24 (row 29 above). Live-screen "Text" tab → broadcast form, persistence + remote update; window default/minimums bumped (1920×1040 / 1366×768) with size+position restore. 6. ✅ **TASK 17** — Web source (WebView2) — shipped 2026-08-28; enables alert ecosystem. 7. ✅ **TASK 18** — Local recording — shipped 2026-08-29 (running-app verification pending in handoff). -8. **TASK 21** — Media source — video file playback for non-Live scenes. -9. **TASK 22** — Audio sync offset — small, quality-of-life. +8. 🔶 **TASK 21** — Media source — video file playback for non-Live scenes. **Remaining: the UI picker slice** (handoff spec in HANDOFF.md). +9. ✅ **TASK 22** — Audio sync offset — shipped 2026-08-31 (status reconciled 2026-09-01). 10. ✅ **TASK 15** — Stock bg images — all 5 scenes seeded (Starting, BRB, Live, Chat, Ending). `EnsureDefaultBackdrop()` runs on every scene switch. -11. **TASK 13** — Social media launch kit — after all v1 features ship. +11. **TASK 32** — Stream resilience (blip retry → grace countdown on the ON-AIR sign → one-click Back on air → crash-safe recording → pre-flight). +12. **TASK 33** — Bandwidth auto step-down (depends on TASK 32's restart machinery). +13. **TASK 34** — Scheduled streams (Text-drawer schedule + adoption at Start). +14. **TASK 35** — Scene-linked audio ("scenes remember the room"). +15. **Text source (TASK 3 item 17)** + **the monetization-awareness chain + Alerts (TASK 10 related work → TASK 3 item 20)** — capture → report → journey → alerts, one slice per integration test. +16. **TASK 13** — Social media launch kit (MARCOM) — after features, before gold. +17. **TASK 36** — Gold pass, always last: visibility unlock + flash-live enable + screens/layers audit + native verification suite + expiry reminders + signing/installer/EULA/Velopack. --- @@ -1395,16 +1407,172 @@ dynamic-only pixel change does not. --- -## Backlog (future versions) +## TASK 32 — Stream resilience (queued 2026-09-01 — merges the orphaned TASK 2 resume clause into the pills/signs architecture) -1. v1.1 — Stream Deck / Loupedeck integration (requires hotkey foundation from TASK 20) -2. v1.1 — Per-source audio sync offset (global offset ships in TASK 22) -3. v1.1 — Multiple profiles/presets (save different configs for different stream types) -4. v1.1 — Chroma key filter (green screen removal, or ONNX background removal) -5. v1.1 — Virtual camera output (Zoom/Discord/Teams) -6. v1.1 — Replay buffer (instant replay with hotkey) -7. v2 — Multi-destination restreaming (if needed; casual streamers may outgrow LlamaCasty first) -8. v2 — Stream clipping -9. v2 — Export/import settings +**Goal:** a dropped stream heals itself when physically possible, tells the truth when it isn't, and +gets the creator back on air in one click. The ON-AIR sign + primary button in the top bar are the +surface — they already mean "reality" (pills = intent, lights = reality, TASK 18/30); reality just +isn't two-state. Resumption of a completed broadcast is explicitly **out of scope** (impossible via +the API — creator ruling 2026-09-01): no lying in the copy. + +**Slices (one integration test each, in order):** + +1. ☐ **Blip retry** — app-side encoder restart: push dies while live (ProcessFailed / stdin + backpressure death) → bounded-backoff relaunch (2s/5s/10s, N attempts) against the cached + constant reusable-stream ingest URL. Success = push running again while the broadcast is still + live; viewers never know. Fixes the map's stale "ffmpeg does reconnect" claim (corrected + 2026-09-01: reconnect flags are input-side only). Test: fake process fails once then starts → + pump recovers without surfacing to the VM. +2. ☐ **Measured grace + the sign** — while the push is dead, escalate the existing health poll + (30s → ~5s). ON-AIR dot goes **amber "RECONNECTING"** with a mm:ss countdown to the observed + YouTube cutoff; the grace length is a **measured, pinned constant** — probe it on a real private + test stream, cite the observation (spin-guard rule; do NOT invent a number). Poll reports the + broadcast `complete` → dot **red "OFF AIR"**, primary button relabels **"● Back on air"** and + pulses. Test: fake status provider drives the dot through live → reconnecting → cut-off. +3. ☐ **One-click Back on air** — the relabeled button fires the existing go-live path with saved + `Broadcast.*` + cached stream, dialog skipped: new broadcast, new VOD (accepted cost, stated + in-product). Test: seeded saved form + fake stream service → click creates + pushes, no dialog. +4. ☐ **Crash-safe recording** — fragmented MP4 on the record block (`-movflags + frag_keyframe+empty_moov+default_base_moof`): plain MP4's trailing moov atom means a power loss + kills the WHOLE file. Rename-on-stop unchanged. Closes the TASK 18 "MKV as an option" design-note + remnant (fragmented MP4 chosen over MKV: single container, YouTube-upload-native — verify player/ + editor tolerance, cite the research). Test: `FfmpegArgs` emits the movflags only on the record block. +5. ☐ **Pre-flight** — before broadcast insert, one TCP probe to the ingest host:1935 (~2s timeout). + Unreachable → Error toast, no insert, no ghost broadcast ("check VPN/network"). The "forgot the + VPN" story, solved as prevention instead of recovery. Test: fake probe failure aborts go-live before any API call. + +### Design decisions + +- **The sign is the status, the button is the action** — no new chrome, no log lines, no spinners. +- **Honest windows:** countdown = observed grace; at zero we say OFF AIR, we don't pretend to reconnect. +- **Why us > incumbents:** constant reusable stream + persisted form make retry/restart cheap; OBS + shows a reconnecting log line and Streamlabs silently dies. --- + +## TASK 33 — Bandwidth auto step-down (queued 2026-09-01; v1 per the complete-v1 ruling) + +**Goal:** when the upload can't hold the tier, drop it automatically instead of drowning viewers in +freeze frames. The `variable` reusable stream (shipped) makes this zero-API; the governor that decides +WHEN is what TASKS.md:25 always claimed existed and was never built. + +1. ☐ Pure `StepDownPolicy` — sliding window over the encoder's reported dropped-frame rate + (`StreamHealth`, already parsed): sustained overrun → downgrade exactly one 16:9 tier + (1080p60 → 1080p30 → 720p60 → 720p30). Hysteresis cooldown; **one-way within a broadcast** + (no oscillation — a recovered connection doesn't climb back mid-show; the creator may manually + step up). Vertical tier stays a manual choice. +2. ☐ Restart machinery = TASK 32 slice 1 (tier change is an encoder relaunch at new W×H/FPS over the + same pipe/URL; the master canvas is 1920×1080 regardless, so geometry never rewrites). +3. ☐ Surface: the resolution badge + dropdown update to the landed tier; ONE Info toast + ("Quality lowered to hold your stream") — no nagging after that. +4. ☐ Tests: pure-policy units (drop-rate windows, one-way, cooldown) + the ONE integration: fake + health stream with sustained drops → exactly one lower-tier encoder restart. + +--- + +## TASK 34 — Scheduled streams, Text-drawer version (queued 2026-09-01; option (b) with the creator's placement) + +**Goal:** announce "Friday 8pm" from inside the app — YouTube notifies subscribers and runs the +channel countdown; come Friday, one click goes live into the scheduled broadcast. Scheduling is a +"my channel" concern, so it lives in the always-visible **Text drawer**, not the go-live modal. + +1. ☐ Drawer gains ☑ *Scheduled livestream* + date/time (replacing the read-only "Scheduled Start" + row). Checking + Save **creates** the broadcast: insert `liveBroadcasts` with a future + `scheduledStartTime`, bound to the cached reusable stream — the drawer's Save path grows its first + create operation (today it can only update an existing broadcast). +2. ☐ Invariant: **at most one open scheduled broadcast**; unchecking before airtime → + `liveBroadcasts.delete` (legal in created/ready). +3. ☐ **Adoption:** go-live checks for the open scheduled broadcast and pushes into it instead of + inserting a new one. Mid-stream title/desc edits ride the existing Update path (unchanged). +4. ☐ `LiveBroadcastFormViewModel` owns `IsScheduled` + `ScheduledStart` (nullable), persisted with the + other `Broadcast.*` keys; local time in, RFC3339 offset on the wire — the timezone edge is where the + bugs will live: test it (DST boundary, non-UTC creator). +5. ☐ **Research flags (cite before building — spin guard):** (a) pushing *before* the scheduled start + with `enableAutoStart` — live immediately or held? Decides a "starting early — it goes live NOW" + confirmation. (b) delete semantics for a ready broadcast bound to a reusable stream. +6. ☐ Test (the ONE): schedule → persists + inserts (fake service captures a future scheduledStartTime + + boundStreamId); start → adopts instead of inserting; uncheck → delete called. +- **Test-phase note:** dark by design while the visibility lock holds (Private notifies nobody); the + creator can still exercise insert/adopt/cancel. Full shine arrives with TASK 36's unlock — pleasing + coupling, deliberate. + +--- + +## TASK 35 — Scene-linked audio: "scenes remember the room" (queued 2026-09-01; spec approved by the creator same day) + +**Goal:** the BRB scene mutes the mic; the Chat scene brings it back. The creator stops fiddling with +audio at transitions — the scene carries the room state. OBS needs scripts/plugins; native = the +creator-proof pitch. + +1. ☐ `Scene` gains nullable `SceneMutesMic` / `SceneMutesDesktop` — `null` = don't touch (the default; + scenes are passive until told). Schema column bump + roundtrip. +2. ☐ Applied on **live transitions only** (thumbnail click / hotkey), ONCE at entry — never on + offline staging (no surprise stream-state edits while auditioning). +3. ☐ The creator is boss: a manual mute/unmute mid-scene is never overridden until the next entry. +4. ☐ BRB ships `SceneMutesMic = true`; other scenes default `null` — creator's choice. +5. ☐ Edit UI: two checkboxes in the scene's Properties section (left panel, edit mode) — no new chrome. +6. ☐ Test (the ONE): transition applies scene state; `null` leaves state untouched; manual override + survives until the next entry. + +--- + +## TASK 36 — Gold pass (queued 2026-09-01; the going-gate, always the last work unit) + +**Goal:** the single deliberate pass that turns the dev product into the shipped product. Nothing here +may be done opportunistically mid-development — each item has a lock/comment pointing at this task. + +1. ☐ **Visibility unlock** — remove the `CreateBroadcast` Private override (TASK 9 item 6); dialog + selection rules. The channel-protection stance ends at GA, by hand, here. +2. ☐ **Branding flash goes live** — end the preview-only pre-GA posture: composite the escalating + obnoxious-promo flash onto output + recordings for free users. Paid removes it; that stays the + ONLY paid delta. (Escalation curve finalized here, first.) +3. ☐ **Screens/layers settings audit** — the retired 2026-08-22 landmine (ai.md): fine-tooth-comb + every screen's Background layer properties, pill persistence, context-menu visibility, (+) items. +4. ☐ **Native-Windows verification suite** — the checklist TASKS/HANDOFF never had: real webcam, + real mp4 decode/loop/pacing (TASK 21 picker must have landed), real recording file opens + + plays (fragmented MP4), a real private go-live end-to-end, GUI/RealApp test classes green on + the desktop (they've only ever run against fakes). +5. ☐ **License-expiry reminders** — T-14 / T-7 / T-1 toasts via the existing notification stack + ("renews — manage here" → `PremiumUrl`); lapsed: flash returns (the product itself is the + notice) + one Info toast; active paid users see ZERO license UI. Research: does Polar's + validate response carry `expires_at` reliably — cite before building. +6. ☐ **Release engineering** (the `Distribution.md` plan, 644 lines, finally tasked): code signing + (HARD blocker — an unsigned installer is a SmartScreen scarewall), installer + Velopack update URL + (TASK 10 item 8), EULA draft/review, THIRD-PARTY-NOTICES license-texts gate (TASK 4 req 9). + Build posture (agreed ceiling): compile flags max out at `HARDENED` (release-only anti-debug/ + tamper checks) + `MOCK_REWARDS` (fake reward payloads for Alerts/dev) — **additive-only**, never + branching normal code paths, both compiled every CI build so they can't rot. Obfuscation / + assembly-splitting stay GA-time decisions, not compile-time ones. + +--- + +## v1 = the finished product (creator ruling, 2026-09-01) + +**There is no v1.x.** v1 ships feature-complete: everything in the queue above lands, or is +explicitly buried below. The parking lot is closed — nothing is "deferred to a later version" +anymore; the only two states are **v1 task** and **out of product, permanently**. The 10% margin of +error is this list, bounded and written down — not vibes. + +**Monetization posture restated (what "final" means):** free gives everything; the only difference +is the branding flash. License activation flips exactly one bit (`IsPremium` → flash off). Nagware = +the escalating obnoxious self-promo flash itself — never modals, never feature-locks. Pre-GA the +flash is preview-only (see ai.md → Monetization; TASK 36 flips it live). + +## Out of product — permanently (the 10% margin, closed list — do not re-litigate) + +| Idea | Verdict & reason | +|------|------------------| +| Stream Deck / Loupedeck integration | The device sends keystrokes — **global hotkeys already ARE the integration** (TASK 20). Redundant, not deferred. | +| Per-source audio sync offset | Global offset shipped (TASK 22); per-source is the OBS mixer rabbit hole. Condemned by the Audio Assumption (one knob). | +| Profiles / presets (multi-config) | Same hydra as multi-layout, already ruled at TASK 30: **that's what OBS is for.** | +| Chroma key / background removal | Already ❌ (TASK 3 item 19). NVIDIA Broadcast does it free; model+GPU+lighting caveats = support hell for a solo dev. Stays buried. | +| Virtual camera output (Zoom/Discord) | Different product, device-driver swamp. Our output is one YouTube stream. | +| Replay buffer (instant replay) | Game-clipping culture, not our persona. **The most valuable corpse here** — if the list is ever reopened at all, this is candidate #1, and it reopens by creator ruling, not by AI suggestion. | +| Multi-destination restreaming | "Casual streamers outgrow LlamaCasty first" — the map already said it; the per-destination audio/monitoring expectations are a different product. | +| Stream clipping | YouTube killed clips; trimming local MP4 is an editor. | +| Advanced broadcast-form tab (latency/DVR/embed/projection/CC/region) | TASK 9 item 7 rescoped: fixed sane defaults, invisible. Every exposed field is a support ticket. | +| In-app bug-report mechanism | Never built, never will be: support = email + GitHub issues. (ai.md's support line corrected to match, 2026-09-01. Second-place corpse if the list ever reopens: with replay buffer.) | +| D3DImage/GPU preview compositor | TASK 3 item 16 superseded: XAML preview + software output compositor are the design; a D3D11 swap remains a seam-respecting possibility, not a feature. | + +*If a future session is tempted by anything on this list, the answer is already written. New ideas +must survive this page before they get a task number.* diff --git a/ai.md b/ai.md index 90a0d6e..e5209ef 100644 --- a/ai.md +++ b/ai.md @@ -186,7 +186,7 @@ The AI hallucinated through multiple commits that night on background/scene prop ### Current limitations / TODOs -- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`) +- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). Sign-out is **explicit only** (Logout / Change Account → `SignOutYouTubeAsync`): `StopStream()` does NOT clear the session — TASK 18's 2026-08-29 reversal ("stopping a recording leaves the creator signed in") — the older "graceful End signs out" line here was stale and is corrected against the code (2026-09-01). `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`) - Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state persists (LayoutStore Settings table — `LicenseKey`/`LicenseValidatedAt`/`IsPremium`, 14-day offline grace) - `YouTubeStreamService` manages the **variable reusable stream** (shipped 2026-08-16): `GetOrCreateReusableStreamAsync` lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, creating it only on first use (`resolution=variable`, `frameRate=variable`); the stream is cached via `LayoutStore` (`SaveReusableStream`/`LoadReusableStream`, Settings table) and bound at broadcast insert (`boundStreamId`). Health (shipped 2026-08-16): `GetStreamHealthAsync(streamId)` polls `liveStreams?part=status` for `healthStatus` + `configurationIssues[]` → `StreamHealth`; banner decision in pure `StreamHealthReporter` - Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED**, **frame-pipeline wiring (TASK 4 ship step 5) is SHIPPED**, **health stats (TASK 4 ship step 6) is SHIPPED**, **one-click go-live + private-only enforcement (TASK 4 ship step 7) is SHIPPED** — TASK 4 (RTMP Ingest) is fully done; full plan in `TASKS.md` @@ -870,11 +870,14 @@ crooked. Product: `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0`. Checkout: `llamacasty.com` → Polar hosted page. Org ID: `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` omitted from API calls). Key prefix: `LCYT-`. Discounts: `LLAMAFOUNDER` (100% off, 50 uses), `LLAMA50` (50% off, 12 months). Details in `MONETIZATION.md`. -- **Support (creator's model):** in-app bug-reporting mechanism → issues into git; most queries are +- **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the + once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with - emergency patches; not a 24/7 service promise. + emergency patches; not a 24/7 service promise. The only license chatter is the paid-user expiry + reminder (TASK 36 item 5); active subscribers see zero license UI. -**Monetization awareness (built-in, ungated, free — 2026-09-01):** the app is monetization-aware by +**Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1 +ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by design, for **every** creator, free and ungated (the only subscriber swap remains the branding-flash removal above). It has three layers, all free — this is the product's differentiator, not a paid tier: @@ -904,8 +907,9 @@ removal above). It has three layers, all free — this is the product's differen **What was rejected:** always-on watermark (obscurable — replaced by the flash), hard stream-time cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams routinely run 2-4 hours), soft-limit nagging, freemium feature tiers, and donation-only (relies on -the kindness of strangers). Resolution/quality ceilings are **deferred** — that decision belongs to -the resolution & streaming-constraints conversation, not monetization. +the kindness of strangers). Resolution/quality ceilings stay rejected (fixed 2026-09-01, no longer +"deferred"): the free tier never loses quality — auto step-down (**TASK 33**) is a *protect the +stream* feature, never a monetization penalty. ## Auth gates Go Live, but not exploration @@ -940,16 +944,7 @@ These are the hard facts behind every decision. Full list in `TASKS.md`. to subscribers and serve as post-mortem review tapes; bulk-delete pre-GA. The unlock is a deliberate final-pass item in **TASK 36 (gold pass)**, wired with the dialog selection — never opportunistic. The PRIVATE badge keeps showing when the stream is actually private. -- **Full broadcast form (TASK 9 item 7)** — the go-live dialog exposes all YouTube API-supported fields. - **Core tab** (always visible): title, description, visibility (Private/Unlisted/Public), made-for-kids, - schedule (start + optional end datetime). **Advanced tab** (expandable, sane defaults): latency - (Normal/Low/Ultra-Low, default Low), DVR (default on), embed (default on), record-from-start - (default on), projection (rectangular/360°, default rectangular), closed captions - (disabled/embedded/HTTP, default disabled), auto-start (default on), auto-stop (default on), - monitor stream (default off, for testing), region restrictions (country codes, optional). - `categoryId` is removed from `CreateBroadcast` (not a `liveBroadcast` field, silently ignored). - Monetization via `liveBroadcasts.update` (not settable on insert) — separate step after broadcast - creation. Go-live order (TASK 9, shipped 2026-08-16): +- **Full broadcast form (TASK 9 item 7) — RESCOPED 2026-09-01** — the core editable fields ship (since 2026-08-24) as the always-visible **Text drawer**: title, description, tags, visibility, made-for-kids, live-editable; scheduling ships as **TASK 34** (drawer ☑ Scheduled + adoption at Start). The old **Advanced tab is permanently out of product** (the 10% margin — see TASKS.md → "Out of product"): latency locked `low`, DVR/record-from-start locked on, embed/projection/CC/region fixed at sane defaults, invisible — every exposed field is a support ticket. `categoryId` is removed from `CreateBroadcast` (not a `liveBroadcast` field, silently ignored). Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field. Go-live order (TASK 9, shipped 2026-08-16): `BeginGoLive` → `PrepareAndStartLiveAsync` — ensure the reusable stream (`GetOrCreateReusableStreamAsync`, cache it), create the broadcast bound to it (`CreateBroadcast(..., stream.Id)` → `boundStreamId`), THEN start the pump (the URL must exist before `FramePump.StartAsync`, which reads it once). Failure → @@ -959,7 +954,9 @@ These are the hard facts behind every decision. Full list in `TASKS.md`. (`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`) only on first use; the ingestion URL is cached via `LayoutStore` Settings (`SaveReusableStream`/`LoadReusableStream`) and bound to each broadcast at insert (`boundStreamId`). Any quality tier works without recreating the - stream, and auto step-down is done by us dropping bitrate on the fly (zero API calls). + stream, and auto step-down rides the same property — we drop bitrate/resolution on the fly, zero + API calls — **but the deciding governor is not built**: the old present tense there was a map-lie, + corrected 2026-09-01; step-down is **TASK 33** (v1 scope). - **Quality is greyed out while live** — resolution/frameRate/ingestionType are immutable after stream creation; editing title/description/privacy is fine at any time. - **Report-by-exception health (SHIPPED 2026-08-16, TASK 9 item 3)** — `YouTubeStreamService.GetStreamHealthAsync(streamId)`