diff --git a/AGENTS.md b/AGENTS.md index 89e5406..b67f07d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ it or revert the change. The script enforces what the rule demands. From WSL, ALWAYS use the Windows dotnet host — Linux `dotnet` re-downloads the `windowsdesktop.app.*` packs over the slow 9p bridge and re-restores twice (WPF `_wpftmp`), and `--no-restore` right after an interrupted restore produces bogus -`NETSDK1064` errors. See `ai.md` → Run for the exact commands and why. +`NETSDK1064` errors. See `docs/ai/environment.md` → Run for the exact commands and why. ```bash "/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" diff --git a/ai.md b/ai.md index 435410a..2c533b5 100644 --- a/ai.md +++ b/ai.md @@ -39,39 +39,8 @@ it into `MyMistakes.md` → **Recipes registry that same session**, and grep it before ever re-deriving. A solution recorded once ends the loop; an un-recorded one guarantees the user hears it derived again (see `AGENTS.md` → Working rules → 🔬). -## LAN infrastructure (192.168.50.x) -All LAN credentials live in `CREDENTIALS.md` (gitignored). Server IPs and roles: - -| Host | IP | Role | -|------|----|------| -| corsair | .132 | Gaming/streaming primary — RTX 5070 Ti (this machine) | -| llamavault | .87 | ODROID-HC4, 12TB RAID5, media storage (NFS). SSH: `ssh mshallop@192.168.50.87`. **DHCP-bound — it moved off `.86` after the 2026-10 power outage, so confirm with a ping before trusting any path.** SMB is reached by the **`llamavault` name**, pinned in the Windows hosts file, never by literal IP: `\\llamavault\llamaVault\...` | -| pivault | .158 | RPi5 8GB, 10.83TB RAID5 NAS | -| jarvis | .210 | AMD Ryzen 5 5600X, RX 6600 XT — AI inference, SearXNG, Docker registry, Immich | -| ultron | .108 | AMD Ryzen 7 7840HS — AI orchestrator, llama-server, Qdrant | -| gordito | .144 | Home Assistant, Alarmo, Zigbee | -| officerfriendly | .26 | Pi-hole DNS | -| octopi | .197 | OctoPrint (Prusa MK4) | -| BigBlinkyRouter | .1 | ASUS ROG GT-BE98 Pro, WiFi 7 | - -Web infra (DO droplet): `llamacasty.com` + `llamachile.tube` at `143.244.176.131`. - -**Pushing to the Gitea instance (`llgit.llamachile.tube`).** The repo is **`gramps/LlamaCasty`** -(renamed from `gramps/ytLlive`; the old path 301-redirects). **Push over HTTPS**, not SSH: -`origin = https://llgit.llamachile.tube/gramps/LlamaCasty.git`, with the credential helper -`/home/gramps/bin/gitea-credential` (registered as -`credential.https://llgit.llamachile.tube.helper`) reading the account password from the -gitignored `CREDENTIALS.md` on demand — nothing stored in plaintext, nothing in a command line. -**The Gitea account password IS the portal password** in `CREDENTIALS.md`, and the same password -authenticates the REST API (`curl --config …/api/v1/user`) — **that API is the fastest way to ask -the server questions** (the repo's real name and clone URL, the account's SSH keys, a token). -⛔ **The SSH endpoint on `llgit:2214` is not a usable push path**: the account's SSH keys ARE -registered (key id 6 "Corsair VSC" = this machine's `~/.ssh/id_ed25519`) yet the server refuses -the publickey, and the port later refused connections outright. Do not spend a session on key -archaeology — ask the API, then push over HTTPS. - -### No-Fluff Mode (on demand) +## No-Fluff Mode (on demand) Invoke with "no-fluff mode" (or similar) when you want ruthless review instead of reassurance. In that mode: @@ -84,177 +53,6 @@ of reassurance. In that mode: This is an occasional, explicitly-invoked mode — **never the default**. The default response style above stays in effect unless invoked. -## Brand - -Brand palette and official assets live in `MARCOM.md` (gitignored — read it for hex values, -asset file names, and marketing copy). The brand red `#e94560` is the primary accent used -throughout the UI. - -### Product name vs repo/assembly branding (2026-09-14) - -The product is **llamacasty** (llamacasty.com, llamachile.tube, `llgit.llamachile.tube`). The -repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths (`%APPDATA%\ytLlive\...`), -and most internal namings are the legacy **ytLive/ytLlive** — a re-brand that never renamed -the internals. Treat the two as separate: external/user-facing language says "llamacasty", -code/assembly/repo names stay `ytLive`. If a full internal re-brand is ever done, this note -and the UI locator (Premium/wordmark) are the checklists; it is NOT a goal pre-1.0. - -**Say "LlamaCasty" when reporting to the creator** — never "ytLive" in prose. `ytLive` is -the internal repo/assembly/namespace name only. Exact current split: - -| surface | name | note | -|---|---|---| -| product / site / marketing | **LlamaCasty** | llamacasty.com, llamachile.tube | -| repo dir, csproj, `RootNamespace` | `ytLive` | 230 files: `namespace ytLive.*` | -| `AssemblyName` → shipped exe | `ytLive` | `ytLive.exe` | -| app data dir | `%APPDATA%\ytLlive` | **capital L**, drifts from `ytLive` | -| `Distribution.md` tells customers | `LlamaCasty.exe` | ⚠ **mismatch vs the real `ytLive.exe`** | - -**Consequences, so nobody re-hunts them:** -- Renaming `` to `LlamaCasty` is the cheap win (the customer-visible exe - name matches what `Distribution.md` already promises; namespaces can stay `ytLive.*`). - It is **4 lines**, but it hard-breaks three pack URIs that spell the assembly name: - `MainWindow.xaml:14` (the **window icon** — taskbar/Alt-Tab), `OverlayHost.xaml:96`, - `RenameRecordingDialog.xaml:5`. Miss those and you ship a broken icon. Queued in TASKS.md - under the shipping task, not done. -- Renaming the `%APPDATA%\ytLlive` folder is **breaking** — it would orphan every existing - user's layout DB, `Assets` and settings. Never do it casually; needs a migration. -- No code compares its own assembly name (no `GetExecutingAssembly().GetName().Name` checks), - so the assembly name is otherwise free to change. - -### AI transparency - -This project is built through AI-assisted pair-programming. All code is generated -under the supervision of an experienced developer with decades of coding experience. -Architecture decisions, product direction, and quality gates are human-owned; -AI accelerates implementation. This is a testimonial to successful human-AI -collaboration, not autonomous code generation. - -## Run - -**First time on a fresh clone (or if `ffmpeg/` was deleted) — this is REQUIRED, not optional:** - -```bash -./scripts/fetch-ffmpeg.sh # ~65 MB once; verifies SHA-256, extracts 9 files into ./ffmpeg -``` - -FFmpeg is **bundled, never downloaded at runtime** (Windows Store policy 10.2.2 — the old runtime -download 404'd on 2026-09-01 and would fail certification). The binaries are gitignored because -135 MB of DLLs in git would tax every commit on this 9p tree; the pinned URL + SHA-256 in the script -are the audit trail. `ytLive.csproj`'s `GuardBundledFfmpeg` target **fails the build** with that -exact instruction if the folder is missing. `FFMPEG_ZIP=` reuses a pre-downloaded archive. - -```bash -# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project: -"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" -"/mnt/c/Program Files/dotnet/dotnet.exe" run -c Release # the launch command (creator, 2026-10-16): Release, not Debug — perf takes must read Release numbers -``` - -**Run-mode convention (creator directive 2026-10-16):** the creator launches with `-c Release` -by DEFAULT. Debug runs happen ONLY when the AI explicitly names the Debug command for a -specific reason — never implicitly, and never for reading performance numbers (Debug pixel -paths are several × slower; every take in the 141ms saga read Debug numbers where Release is -the real reading). - -**Pre-commit gate (write it down — I forgot once):** ALWAYS run -`./scripts/verify.sh "" "" ...` (declared scope) before committing. It forces a -**clean** build (0 warnings), the full test suite (passes only the 2 known pre-existing `[FAIL]`s), and -the scope check in one shot. An **incremental** build can report `0 Warning(s)` while skipping -recompiles — that's how `CS8601` slipped past on 2026-08-29. Never claim 0 warnings from anything but -`verify.sh`'s clean build. - -`EnableWindowsTargeting=true` in `ytLive.csproj` lets a cold restore work from WSL, but a Linux -`dotnet run`/`build` re-downloads 100M+ of `windowsdesktop.app.*` packs into the Linux NuGet cache -(which lacks them) over the slow 9p `/mnt/c` bridge — twice, because the WPF `_wpftmp` generated -project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the -packs resolve from `C:\Program Files\dotnet\packs`, so the Windows host never re-downloads. -Never use `--no-restore` right after an interrupted restore — the stale `project.assets.json` -produces misleading `NETSDK1064` "package not found" errors. Running requires Windows anyway. - -## Tests - -xUnit in `ytLive.Tests` (net8.0-windows10.0.19041.0, matches the app TFM). Run on Windows — -WSL can't run net8.0-windows tests: - -```bash -dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll" -``` - -Good Dog Rule: ONE integration test per change (no feature branches pre-1.0 — work lands on -`main`, see AGENTS.md). Current: TokenStore DPAPI -roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount + -frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip -interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip, -LayoutStore background roundtrip, LayoutStore HasBackground roundtrip + v5→v6 non-Live backfill, -ScreenCaptureManager refcount + shared-bitmap + coalescing -(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackgroundTests, SceneCatalogTests, -the TASK 25 dirty-layout heal (BackgroundHealIntegrationTests), WebcamSafeguardTests, -SceneCompositorTests (full-scene composite + vertical tier), StretchMathTests, FfmpegLocatorTests, -FfmpegEncoderTests, FramePumpTests, the TASK 8/11/12 audio chain (AudioPipelineTests + MasterLimiter), -the Socials fediverse-heal roundtrip, AboutHubTests, NotificationAreaIntegrationTests (TASK 24), -GlobalHotkeyTests + HotkeyConfigTests (TASK 20), WebcamMenuGateTests (TASK 26), ChatLayerGateTests -(TASK 27), BroadcastPullOutTests (TASK 29), DefaultRecordFolder fallback (TASK 30), WebView2ManagerTests -(TASK 17), RecordingFileTests + OnAirSignTests (TASK 18), SessionTeardownTests + -WebcamOutputKeyTests + GameMeterHonestyTests (2026-09-01 recording-verification fixes) — **ZERO -known failures as of 2026-09-01. `AudioPipelineTests` 25/25 green in 26ms — -the "known failing" `Mix_HonorsProviderGains…` and the class's notorious STANDALONE HANG shared -one root cause: TASK 22's `_delayedMix` (nullable, never initialized) was dereferenced -(`delayed.Length` on null) every live-mix tick — a swallowed NRE starved the pipe the tests -read and flooded startup.log. Test was right, code drifted from the map's own contract -(loopbackGain = GameAudioVolume — now honored; the "unity because loopback scales with the -endpoint" assumption was disproven by the creator's 20%-volume meter observation). The former -RoundClip "known failure" (stale-test layers: namescoped `FindName` + `VisualTreeHelper.HitTest` -where `UIElement.InputHitTest` models input — see MyMistakes) was fixed the same day. -Per-class runs through the Windows dotnet.exe host execute the RealApp/MainWindow suites fine; -only the FULL-suite run still hangs (WASAPI teardown, pre-existing) — and the whole-suite -"247 total" era count is stale; trust per-class results.** - -Reward-event capture (monetization awareness, see the Monetization section) will add its integration -tests here when it ships: one real chat-poll payload containing all seven reward event types → -assert the persisted canonical `RewardEvent` rows (type + amount/currency/tier/memberLevel/participant -ids) round-trip into SQLite. - -### Launch POSITION is derived, never restored (2026-10-04) - -Creator ruling: the window must open in the **upper-left of its screen with ~75px padding**, because -the restore path was putting it below the fold. `MainWindow.RestoreWindowGeometry` now ignores the -saved position entirely and calls the pure `TopLeftPlacement(workArea, w, h, padding)`, which takes -the work-area origin plus `LaunchPadding = 75` and clamps so the window is never pushed off-screen: -a window that fits stays wholly visible, one too big to fit falls back to the work-area origin. -`LaunchPadding` is in **device-independent units**, so it scales with DPI instead of being a fixed -physical pixel count. - -**Size and maximized state are still persisted and restored** — only position was dropped. The -clamp still applies to size: saved values are `Math.Clamp`ed to `[MinWidth, work.Width]`, which is why -a saved 800×500 comes back as 1366×768 (the XAML `MinWidth`/`MinHeight`). Reverting this is -reverting the one block in `MainWindow.xaml.cs`; the saved values are still written, so nothing is lost. - -**Why the old clamp was wrong (do not "fix" it back):** it tolerated `Left` as low as -`work.Left - width + 120` and `Top` up to `work.Bottom - 80` — ranges wide enough to leave the window -almost entirely off-screen while still looking like a successful clamp. A clamp that permits the -window to be ~unreachable is worse than no clamp, because it reads as a guarantee it does not keep. - -`BroadcastPullOutTests.Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote` (renamed -from `..._WindowRestores_Clamped_...`) is the integration guard — it asserts the constructor applies -the derived placement for both an on-screen and a wildly off-screen (`-99999`) saved position. -Placement math itself is covered by `WindowPlacementTests` (pure function, no HWND needed). - -### Real-MainWindow tests MUST be hermetic (DB pollution bug) - -The integration test boots a real `MainWindow` → `MainViewModel` → real `LayoutStore` -(`%APPDATA%\ytLlive\ytLlive.db`). `Shutdown()` on close **saves the layout** (full rewrite: -DELETE all scenes/sources, re-insert), so any source a test adds would be persisted over the -user's real ones — this happened and wiped the real webcam source (DeviceId replaced by the -test's fake `test-camera`). Rule: a test that constructs `MainWindow` MUST first set -`MainViewModel.LayoutPathOverride` to a temp DB path and reset it (plus -`SqliteConnection.ClearAllPools()` + delete) in `finally`. The seam is -`internal static string? LayoutPathOverride` (line ~529 in `MainViewModel.cs`), -`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`. - -The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so -save/load round trips are exact: an element removed in the UI (`RemoveElement` → -`scene.Elements.Remove` → `OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on -close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this). ## Architecture @@ -269,128 +67,36 @@ C# / WPF (.NET 8) following MVVM: | `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) | | `MainWindow.xaml` | Dark theme; top bar + health banner; work area has a fixed 360px left column and preview column, top-left readouts/resolution and scene strip above preview, lower left panel + preview; bottom bar is an empty spacer | -### Key patterns -- `ViewModelBase.SetProperty()` for property change notifications -- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows) -- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row; Socials is on the preview rail. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC / ON-AIR pills + signs (TASK 18, 2026-08-29):** the top-center area is now two **sliding pill toggles** (intent) next to two **status signs** (reality). REC pill (`RecordPillOn`) is the local-recording intent — works signed-out; ON-AIR pill (`OnAirPillOn`) is the streaming intent — greyed/disabled until `IsConnected`. The **REC sign** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`) is dark-gray + dim offline and **green-tinged, pulsing only while actually recording** (`IsRecording`), not merely pill-on. The **ON-AIR sign** (`OnAirBrush` = `#555` offline, `#22c55e` live) is green while actually streaming (`IsLive`); the `PRIVATE` badge (`IsLivePrivate`) still shows when the broadcast is private. Both sign labels share the `StatusSignText` style (`Themes/Controls.xaml`) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows while `IsLive || IsRecording`. The connected account's avatar/name shows in the top bar next to the primary button (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live. -- ViewModels are constructed in XAML (`` as DataContext) -- **True decomposition beats partial-shuffling (2026-08-31, Commit G):** `MainViewModel` is a ~4100-line god-object split into 17 partials — but partials are a *myth of decomposition*: every partial shares the same class, same `SetProperty` state, same collaborators via `Scenes`/`StagedScene`/`IsLive`/`Source.VideoImageSource`, so an AI fetching one partial still reconstructs the whole class. The only thing that actually improves retrieval is a boundary where a feature **owns its own state + collaborators**. `Services/ChatOverlayLayer.cs` is the model (extracted from `MainViewModel.Chat.cs` 194→44): the layer owns the message buffer, `ChatBoxRenderer`, fade/mock timers, preview renders, and the live `RenderFrame`; the VM keeps only the binding surface (`ChatMessages` delegates to `_chatLayer.Messages` so XAML + LeftPanel `CollectionChanged` hold; the two computed gate props stay on the VM because their `OnPropertyChanged` is raised from VM setters). **`Services/AlertOverlayLayer.cs` (TASK 43, 2026-09-24) is a second true component built fresh on the same contract** — it owns the event queue, the 33ms playback ticker, the per-source preview renders and the live `RenderFrame`, with `Advance(double)` exposed as the deterministic test clock; the VM again keeps only the facade (`RenderAlertBox`, `UpdateAlertBoxPreview`, `CanAddAlerts`). Two COMPONENT layers, one facade partial. **Diagnostic — glue vs. component:** before extracting, count `OnPropertyChanged`/`SetProperty` touches (glue) and bound-property reads per partial. Zero/low-glue + a cohesive state blob (renderer/timers/buffer) = extract (chat). High glue over an already-extracted service (Audio, Webcam, Background, Scenes — `AudioMixer`/`CameraManager`/`ScreenCaptureManager`/`ChatBoxRenderer` already exist) = leave as VM glue; forcing it adds coupling, doesn't remove it. Don't manufacture seams to hit a line count — the line count is a guideline for context, not a design goal. -- **SceneGraph component + baked-crust compositor (TASK 31):** `Services/SceneGraph.cs` owns the scene collection (`Scenes` — the ViewModel's `Scenes` property delegates to it) and the element mutation surface (`AddElement`/`InsertElement`/`RemoveElement`/`MoveElement`, each invalidating the bake cache) plus queries that were scattered LINQ (`GetBackground`/`GetWebcam`/`GetChatBoxes`/`GetSplitPoint`/`IsStatic`). Elements expose `ElementKind Kind` (`Static` = images/background art, `Dynamic` = webcam/live capture/chat/web). The **split point** is the index of the first dynamic element; `SceneGraph.GetBakedBase` bakes/caches all static layers below it (keyed by scene id + static element identities), and `SceneCompositor.BakeStaticBase`/`CompositeLayers`/the `Render(.., staticBase, split)` overload composite the dynamic/above-split layers per frame. `FramePump.RenderScene` uses the optimized path when a `SceneGraph` is wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (via `InvalidateBake` from the VM's element-property and background-heal paths). **Defensive deviation from the TASK 31 spec:** `ChatOverlayLayer` keeps taking `IEnumerable` instead of depending on `GetChatBoxes()` — it is deliberately decoupled from the graph (its doc comment says "without owning the scene graph"); and the static background helpers (`EnsureBackground`/`NormalizeBackgrounds`) stay on the ViewModel because `BackgroundTests.cs` unit-tests `MainViewModel.EnsureBackground` directly. Queries + invalidation moved; helpers stayed. -- Services are currently instantiated in MainViewModel's constructor — no DI container yet -- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load -- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug). -- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names. -- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox`, the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back — **but that guard sees only those three control types**, so a picker built as something else (a `Button` + a code-built `ContextMenu`) slipped through it and the creator had to ask twice (TASK 63) -- **The properties panel is a HIERARCHY (TASK 59, 2026-10-21):** a **title owns its content, and the content is indented one level (10px) under it** — the creator's ruling: *"the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur."* So the webcam's block reads `Shape` (the mirror switch + the shape choice) → `Geometry` → `Border Color` → `Border Thickness` → `Background` (Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px `#a0a0b0` like the existing ones. **The shape is a CHOICE, not a toggle button:** `IsRectShape`/`IsRoundShape` on `SceneElement` are two settable bools and the panel shows them as two `SegmentToggle` options (Rect | Circle) with **Rect selected by default** (an element that never picked a shape is a rect) — the old single button showed the *next* action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips through `ToggleClipShape()` and both props are re-raised), **not** in a WPF `RadioButton` group — a RadioButton group unchecks its siblings by setting `IsChecked` in code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs` -- **One list for the offered colours (TASK 60):** the border-colour drop-down builds its menu from - `Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` + `NameOf(hex)` — **empty** for a colour - outside the palette, "no name" being the honest answer), NOT from a list inside - the click handler — the creator asked for a colour NAME next to each hex (`#e94560 Crimson`), and a - hard-coded `string[]` cannot carry one. A palette the creator may want to extend belongs in a - testable catalogue, with the UI as a consumer. **TASK 62:** the field that shows the *current* colour - shows the name beside it too (`SceneElement.BorderColorName`), and the hex field keeps a fixed width - so the name stays next to the hex instead of drifting to the row's far edge -- **A picker IS the app's drop-down (TASK 63, 2026-10-21):** the border colour is now a - **`YtComboBox`** — the same widget the Display and Font pickers wear — not a `...` button that - built a `ContextMenu` in code. The creator's complaint (*"one of the first things we did was define - branding. We established look-and-feel standards. And yet you keep ignoring these"*) had a precise - cause: the TASK 58 sweep looked for `ComboBox`/`CheckBox`/`TextBox` **types**, and the border picker - was neither — it was a hand-rolled `Button` + code-built menu, so it was never in scope, and the - guard could not see it. **Lesson: a style sweep must ask "is this the app's widget?", not "is this - control type themed?"** — anything hand-rolled that *looks* like a picker (a button that opens a - menu) is invisible to a type-based guard. The hex field stays (a custom colour can still be typed); - the drop-down carries the swatch and the colour's NAME, the rows carry swatch + hex + name. - Guard: `ytLive.Tests/BorderColorPickerTests.cs` -- **A ComboBox's CLOSED box and its ROWS can differ — only through a template selector (TASK 63):** - WPF copies `ItemTemplate` into `SelectionBoxItemTemplate`, and that property is **read-only in - C#** ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned"), so a plain - `ItemTemplate` renders the SAME content in the closed box and in every row. The one mechanism that - tells the two apart is an `ItemTemplateSelector`: WPF calls it with the `ContentPresenter`'s - **templated parent** — the **`ComboBox`** when the closed box renders, the **`ComboBoxItem`** when a - row renders. `Helpers/ClosedBoxTemplateSelector.cs` does exactly that (`ClosedTemplate` for the - ComboBox, `RowTemplate` for the ComboBoxItem). A `ComboBoxItem.ContentTemplate` set through an - `ItemContainerStyle` does **not** work: WPF overwrites the container's `ContentTemplate` with the - ComboBox's `ItemTemplate` (null) — so use the selector. -- **The theme's drop-down popup sizes to its CONTENT, not to the ComboBox (measured 2026-10-21):** - the `YtComboBox` popup carries no width binding, so the pop-up is as wide as its widest row - (measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped, - and the popup may be wider than the closed box. Measure (`RenderTargetBitmap`/`ActualWidth` in a - throwaway test), don't guess. **And a `Popup`'s visual root (`PopupRoot`) has a `null` visual - parent (`VisualTreeHelper.GetParent(popupRoot) == null`)** even though `PopupRoot` IS a `Visual`: - its only link back to the `Popup` and the owning `ComboBox` is `LogicalTreeHelper.GetParent`. Any - ancestor walk used by a window-level click-outside guard (`LeftPanel.IsDescendantOf`) MUST fall - back to `LogicalTreeHelper.GetParent(child)` when `VisualTreeHelper.GetParent(child)` returns - `null`, or clicking a `ComboBoxItem` inside `PART_Popup` looks like an outside click and - deselects `SelectedElement` before the item click registers (fixed 2026-10-22). -- **A field in a dense panel carries a FIXED width (TASK 61):** a WPF element sizes itself to its - content, and in a `DockPanel` the **last child fills the row** — so a row of same-kind fields looks - broken unless every field has an explicit `Width` AND the row sets `LastChildFill="False"`. The - geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's - `FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard: - `ytLive.Tests/GeometryFieldWidthTests.cs` -- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p30@8 and 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height -- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug). **The log is SIZE-CAPPED (TASK 55, 2026-10-22):** at 5 MB `startup.log` rolls to `startup.1.log` (one rolled copy kept) — the policy lives in `Helpers/LogRotation.cs` as a pure function over a path, so `AppLog`'s 156-call-site surface never changes and the rule is testable on its own. Guard: `ytLive.Tests/AppLogRotationTests.cs` +### Environment, LAN, build & tests — moved -### ⚠️ Known issue: screens/layers settings audit — RETIRED INTO THE GOLD PASS (2026-08-22, dispositioned 2026-09-01) +Server IPs and SSH, the Windows `dotnet.exe` build commands, the test suite map and the +hermetic real-`MainWindow` test rule live in +**[`docs/ai/environment.md`](docs/ai/environment.md)** — read it before building, running, or +adding a test. -The AI hallucinated through multiple commits that night on background/scene property placement, repeatedly misreading the spec and introducing bugs. Specifically: moved properties to the wrong superclass level, used `new` instead of `override` breaking WPF bindings, removed working code, and added UI elements that weren't spec'd. The audit was never run since. The creator ruled (2026-09-01): it is a **going-gold requirement**, not session debt — the fine-tooth-comb pass (every screen's Background layer properties, pill toggle persistence, context menu visibility, "Add Layer"/"(+)" menu items) is a named checklist item under **TASK 36 (gold pass)** and this landmine is closed here. +### Product, brand, licensing & distribution — moved -### Current limitations / TODOs +The brand/naming split, AI transparency, the GPL licensing prohibitions (do not violate), the +free-forever monetization posture, auth/account assumptions, and packaging/distribution live +in **[`docs/ai/product.md`](docs/ai/product.md)**. -- `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**, and that means **Logout — NOT Change Account**: `StopStream()` does NOT clear the session — TASK 18's 2026-08-29 reversal ("stopping a recording leaves the creator signed in") — the older "graceful End signs out" line here was stale and is corrected against the code (2026-09-01). `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` + `openBrowser` callback (test seams + save hook; services are still constructed in `MainViewModel`). **Change Account must never sign out first** (fixed 2026-09-27): it used to `await SignOutYouTubeAsync(); await SignInAsync();`, and since `SignOutYouTubeAsync` → `TokenStore.Clear()` **deletes** `ytLlive.auth`, cancelling in the Google chooser left the creator logged out of a working account. It is now just `await SignInAsync()` — success overwrites the token via the `sessionChanged` hook, cancel/failure leaves the session untouched. The OAuth flow also **releases its loopback callback port on retry**: abandoning the browser leaves the attempt holding `localhost:8765` for its 5-minute timeout, so a new `AuthenticateAsync` cancels and awaits the abandoned attempt before binding, serialised by a `SemaphoreSlim` (`_authGate`) — without that gate, two overlapping calls both see a free port and race to bind it (the exact failure the takeover prevents) -- **Google Cloud project ownership (recorded 2026-09-27, do not re-hunt):** OAuth ClientId is `198374416003-s8tm3rb5csh91quvlg6mnec6srmrr6md.apps.googleusercontent.com`, so the **Cloud project number is `198374416003`**. That project is owned by the **`llamachile.shop@gmail.com`** ("gramps") Google account. ⛔ **This is NOT the same account as the YouTube channel a creator signs into the app with** — console access and channel identity are separate logins, and assuming they are the same was the trap that stalled the search. Direct link that resolves it in one hop: `https://console.cloud.google.com/apis/credentials?project=198374416003`. If the picker shows no match, it defaults to the **"Recent"** tab — switch to **"All"** before concluding the project is missing. -- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state persists (LayoutStore Settings table — `LicenseKey`/`LicenseValidatedAt`/`IsPremium`, 14-day offline grace) -- `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`; the real API nests them under `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — the parse accepts that object shape and the legacy flat-string shape (fix 2026-09-22: the object shape crashed every poll with `requires an element of type 'String'`); 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` -- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead -- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) shipped — steps 1-7 (Velopack bootstrap, PolarLicenseService, license entry UI, LayoutStore entitlement, BrandFlash toggle, startup re-validation, PremiumUrl wired); Velopack update URL pending: - - TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates - - TASK 19/23 merged: Control Surface UX — director's control room with an explicit EDIT toggle, scene strip above the preview, readouts in the top-left cell, and preview-rail drawers; current behavior is detailed in “Control Surface UX” below - - TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap - - Broadcast metadata pull-out + launch geometry — shipped 2026-08-24 ("Text" tab on the Live screen, see its section below) - - TASK 21: Media source (video file playback) — starting soon videos, BRB loops - - TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards - - These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in `TASKS.md`. +### YouTube Live API — moved -### Control Surface UX (TASK 19/23 foundation; surface redesign started 2026-10-26) +Go-live ordering, the broadcast-binding fix, the private-only lock, health parsing, end-stream +pre-flight, Test mode and chat-insert shape live in +**[`docs/ai/youtube-api.md`](docs/ai/youtube-api.md)**. -The app is a **director's control surface**, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor. +### Overlays: social bar, alerts, chat — moved -**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer. +The social bar, the native alert box (six animations, alert clip, ticker) and the chat box +conventions live in **[`docs/ai/overlays.md`](docs/ai/overlays.md)**. -- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output. -- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand/gear and account surfaces remain at the sides. The gear's left-click menu remains; right-click opens App Settings directly. -- **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition. -- **Preview rail:** stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls. -- **Central monitor:** shows the staged scene while editing and the program scene when editing is off. Preview transitions snapshot and animate the outgoing picture in every mode; output transitions run while streaming or recording. Go Live itself remains a Cut. -- **Edit safety:** edit availability follows `EditingActive`; it is no longer a hard lock derived from "offline and not recording." Keep the explicit toggle and output/edit behavior distinct. -- **Selected-layer preview access:** a draggable layer selected from the list temporarily gets the highest preview `Panel.ZIndex` and first priority in the preview's custom hit test, so it can be grabbed even underneath a higher scene layer. Clearing/replacing selection restores the preview stack; `Scene.Elements` order is untouched, so output and saved z-order do not change. Guard: `ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs`. +### UI surface — moved -**Scene thumbnails:** `Scene.Snapshot` is a 320×180 compositor render. The strip uses per-scene `ThumbnailAssetId`, then background art, then a placeholder; existing saved `OnAirImageAssetId` can still replace the staged tile. The old hardcoded CRT-thumbnail and on-air seed paths are empty, so a fresh layout no longer imports that artwork. Minis never render live captures. `DESIGN.md` records exploratory Stitch color/type tokens only; it is not a layout spec and has not been reconciled into `Themes/Controls.xaml`. - -### Global hotkeys (TASK 20 — step 1 shipped 2026-08-24) - -- `Services/GlobalHotkeys.cs`: `HotkeyId` enum (9 actions), `IHotkeyRegistrar` seam, `Win32HotkeyRegistrar` (`RegisterHotKey`, no modifiers), `GlobalHotkeyManager`. Fixed defaults: F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop-audio mute, F9 TRAX play/pause. -- `MainWindow.OnSourceInitialized` creates the manager and forwards `Activated` → `MainViewModel.HandleHotkey`; window `Closed` detaches (unregister all). -- `MainViewModel.HandleHotkey` maps ids to `TransitionToScene`/start-end commands (honoring `CanExecute`)/mute toggles/`TraxLeftClick`. -- Tests: `RegistrarOverride` static seam mirrors `LayoutPathOverride`. **Landmine:** forcing a WPF window's handle with `WindowInteropHelper.Handle` does NOT raise `SourceInitialized` and returns Zero pre-show — use `EnsureHandle()` in tests; it both creates the HWND and fires the event. - -### Broadcast metadata pull-out + launch geometry (2026-08-24) - -**The "Text" tab** (always visible, 2026-08-24 revision): a white 30px tab with rotated YouTube-red "Text" sits on the right edge of the preview, vertically centered — **shown regardless of login/stream state** (creator decision; original IsLive gating removed same day). Click **toggles** the drawer (`ToggleDrawerCommand`); any left-click outside the form also collapses it (`Window_PreviewMouseLeftButtonDown` checks `TextPullOutHost` ancestry), as does the ✕. The drawer is a 320px dark panel over the preview's right edge (200ms CubicEase on `Width`, `BroadcastForm.IsDrawerOpen` drives it). The Update button still greys until `CurrentBroadcastId` exists. - -**Fields — the always-editable snippet/status set** (⚠️ creator premise inverted: contentDetails fields like latency/DVR/embed are editable ONLY in created/ready, NOT while live; they are deliberately absent): -- Title / Description / Tags (comma-separated string → `snippet.tags[]`) / Visibility ComboBox (private/unlisted/public) / Made-for-Kids CheckBox — all live-editable pre- and post-launch -- Scheduled Start is read-only ("set at Go Live") - -**Plumbing:** `Models/BroadcastMetadata` POCO; `Services/LiveBroadcastFormViewModel` (owns field state, persists every edit to Settings keys `Broadcast.*` immediately via `Func` seam — survives layout re-pointing; owns drawer state; `UpdateBroadcastCommand` gated by `CurrentBroadcastId != null && !IsUpdating`). `YouTubeStreamService.UpdateBroadcast` PUTs `liveBroadcasts?part=snippet,status` and **must echo scheduledStartTime — update replaces the whole snippet part**, omitting it clears the schedule. Go Live prefills the dialog from the form (`BroadcastForm.Title/Description`) and calls `CaptureGoLive(title, description, now)` on confirm. `MainViewModel.CurrentBroadcastId` exposes the private `_currentBroadcastId`; notified on create/end. - -**The old Default Stream Title/Description** (un-persisted, App Settings overlay) are DELETED — the form owns those fields end-to-end; Go Live prefill routes through it. - -**Launch geometry:** default **1920×1040**, MinWidth **1366**, MinHeight **768**, `WindowStartupLocation=Manual`. Window size/position persist on close via `RestoreBounds` (+ WindowState Normal/Maximized; Minimized treated as Normal) into Settings keys `Window.*`; restored in the MainWindow ctor BEFORE show, clamped to the minimums and the primary `SystemParameters.WorkArea` (a position saved on a since-disconnected secondary monitor falls back on-screen). Multi-monitor restore is not supported yet. - -Test seam note: `YouTubeAuthService.SetSession()` is public — tests inject a channel with future `TokenExpiry` so `EnsureToken()` passes offline; pair with an `HttpClient(new StubHandler())`. +The control surface, scene strip, preview rail, hotkeys, broadcast drawer, toasts, transitions, +brand logo, launch geometry and the theme/properties conventions live in +**[`docs/ai/ui-surface.md`](docs/ai/ui-surface.md)**. ### Capture — backdrop, webcam, effects & the vertical tier — moved @@ -467,7 +173,7 @@ Invariants that hold even if you never open that file: - **Args stay pure** (`FfmpegArgs.Build`) and **never use `-re`** — the pump is the pacer; a second clock on the rawvideo demux fights it. Keyframes `-g fps×4` with a closed GOP, `-bf 0`, yuv420p. - **Encoder choice is probed, never libx264** (GPL): NVENC → QSV → AMF → OpenH264. Never add a static - or GPL codec path — see the licensing guardrails in `ai.md`. + or GPL codec path — see the licensing guardrails in `docs/ai/product.md`. - **REC and ON-AIR are radio-exclusive by creator ruling.** The dual-output machinery stays generic, but the UI can never arm both: one ffmpeg, one output, "we're not them". - **A modal's falsy result must never fall through into its side effect.** Cancel on the recording @@ -502,807 +208,21 @@ Invariants that hold even if you never open that file: - **Judge output in Release, by the burned `_outputIndex`** — the 6-digit counter burned into each frame is what lets a recorded file be audited for drops after the fact; stats alone are not proof. -### Social bar (TASK 6 — shipped 2026-08-12, plan in TASKS.md; bar bug-fix branch 2026-08-13) -A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the -output and carries the creator's social links — content-sized, centered, GREEN glow when ON, position is a -**click-toggle top ⇄ bottom** (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum -(YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/ -Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/ -`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` + `BarEnabled`; `BarJustify` dropped, -column back-compat). Configured in the "Social Media Site Promotion" dialog (`SocialsDialog.xaml` + -`ViewModels/SocialsDialogViewModel`, WPF-free, injected `ISocialValidator` + sign-in/sign-out fakes): -ON/OFF bar switch (schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; -signed-out → sign-in gate → OAuth; delete → confirm sign-out), row 2 free, rows 3–6 lock icons on -freemium. Service detection (`SocialServiceIcons.DetectService`): URL domain / fediverse `@user@domain` → -**Fediverse** / bare→Website. Validation (`Services/SocialValidator.cs`, `ISocialValidator` seam + -`HttpSocialValidator` default): async GET of the canonical profile URL; 200/redirect = valid, 404/failure = -rejected. Fediverse additionally does a **best-effort nodeinfo lookup** (`/.well-known/nodeinfo` → -`software.name`, stored in `SocialEntry.FediverseSoftware` / the `SocialEntry.Software` column, -column-presence migration, no version bump) so the entry shows the **instance's real logo** -(`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse -honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodeinfo failure still validates -(glyph falls back). If the identity domain's nodeinfo is blocked (YunoHost SSO gates -`/.well-known/nodeinfo` behind the login page) but the bare root 302s to the real instance, the lookup -**follows the root redirect** and asks the resolved host. Cancel is a hard stop: `LookupAsync` takes a -`CancellationToken` (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations -never touch slot state). +### Current limitations / TODOs -**Bar bug-fix branch (2026-08-13) — three changes:** +- `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**, and that means **Logout — NOT Change Account**: `StopStream()` does NOT clear the session — TASK 18's 2026-08-29 reversal ("stopping a recording leaves the creator signed in") — the older "graceful End signs out" line here was stale and is corrected against the code (2026-09-01). `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` + `openBrowser` callback (test seams + save hook; services are still constructed in `MainViewModel`). **Change Account must never sign out first** (fixed 2026-09-27): it used to `await SignOutYouTubeAsync(); await SignInAsync();`, and since `SignOutYouTubeAsync` → `TokenStore.Clear()` **deletes** `ytLlive.auth`, cancelling in the Google chooser left the creator logged out of a working account. It is now just `await SignInAsync()` — success overwrites the token via the `sessionChanged` hook, cancel/failure leaves the session untouched. The OAuth flow also **releases its loopback callback port on retry**: abandoning the browser leaves the attempt holding `localhost:8765` for its 5-minute timeout, so a new `AuthenticateAsync` cancels and awaits the abandoned attempt before binding, serialised by a `SemaphoreSlim` (`_authGate`) — without that gate, two overlapping calls both see a free port and race to bind it (the exact failure the takeover prevents) +- **Google Cloud project ownership (recorded 2026-09-27, do not re-hunt):** OAuth ClientId is `198374416003-s8tm3rb5csh91quvlg6mnec6srmrr6md.apps.googleusercontent.com`, so the **Cloud project number is `198374416003`**. That project is owned by the **`llamachile.shop@gmail.com`** ("gramps") Google account. ⛔ **This is NOT the same account as the YouTube channel a creator signs into the app with** — console access and channel identity are separate logins, and assuming they are the same was the trap that stalled the search. Direct link that resolves it in one hop: `https://console.cloud.google.com/apis/credentials?project=198374416003`. If the picker shows no match, it defaults to the **"Recent"** tab — switch to **"All"** before concluding the project is missing. +- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state persists (LayoutStore Settings table — `LicenseKey`/`LicenseValidatedAt`/`IsPremium`, 14-day offline grace) +- `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`; the real API nests them under `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — the parse accepts that object shape and the legacy flat-string shape (fix 2026-09-22: the object shape crashed every poll with `requires an element of type 'String'`); 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` +- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead +- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) shipped — steps 1-7 (Velopack bootstrap, PolarLicenseService, license entry UI, LayoutStore entitlement, BrandFlash toggle, startup re-validation, PremiumUrl wired); Velopack update URL pending: + - TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates + - TASK 19/23 merged: Control Surface UX — director's control room with an explicit EDIT toggle, scene strip above the preview, readouts in the top-left cell, and preview-rail drawers; current behavior is in `docs/ai/ui-surface.md` + - TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap + - Broadcast metadata pull-out + launch geometry — shipped 2026-08-24 ("Text" tab on the Live screen; see `docs/ai/ui-surface.md`) + - TASK 21: Media source (video file playback) — starting soon videos, BRB loops + - TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards + - These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in `TASKS.md`. -1. **Positioning is a click-toggle (KISS)** — the original drag set a local `Canvas.SetTop` value that - permanently overrides the `{Binding SocialBarTop}` (a binding can never win over a local value), so the - bar stayed wherever it was dropped. The first drag-snap fix (`SocialBarSnap.Decide` + `ClearValue` on - release) still failed for shaky hands — jitter around the ±6px deadzone snapped the bar up but wouldn't - let it come back down. **Superseded by the user's click-toggle:** clicking the bar flips it top ⇄ bottom - (`MainViewModel.ToggleSocialBarPosition`), the bar rides the binding alone, and `SocialBarSnap` is gone. -2. **Fediverse software self-heal** — the DB row `@gramps@llamachile.tube` had `Software = NULL` because - nodeinfo was only ever asked of the identity domain (a landing page; the real instance is - `mastodon.llamachile.tube`). `HttpSocialValidator.ResolveFediverseSoftwareAsync` 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. `MainViewModel` runs the - static `HealFediverseSoftwareAsync` off the UI thread on layout load, applies matches via the dispatcher, - and saves; `SocialEntry.FediverseSoftware` is settable and raises `LogoData`, so the icon updates in place. -3. **The bar renders on the live output** — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a - transparent straight-alpha BGRA strip (1920-wide, 40px content + 24px glow pad, the green glow baked in, - Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame - pipeline"). - -### Toast notifications (TASK 24 — shipped 2026-08-23, plan in TASKS.md) - -Non-blocking user-facing messages replace blocking MessageBoxes. **Seam:** `INotificationService.Show(title, -message, AppNotificationSeverity)` (`Services/INotificationService.cs`) + `Info/Success/Warning/Error` -extension methods; `MainViewModel` news up the real `NotificationService` at its field initializer -(no DI container — the VM is built from XAML). **Library:** `Notification.Wpf` 11.0.0 (Platonenkov fork, -MIT — notice #10 in `THIRD-PARTY-NOTICES.txt`). Facts that cost a hunt: -- The `NotificationArea` XAML control registers itself with the manager on `Loaded`; routing matches - the area's `Name` against the request's `AreaName` — our host is `x:Name="ToastArea"` in MainWindow's - root grid (last child = topmost z-order, `Grid.RowSpan=4`, bottom-right above the footer via - `Margin="0,0,12,96"`, outside the preview Viewbox). An unknown AreaName silently drops the toast. -- `NeverExpires()` sets `ExpirationTime = TimeSpan.MaxValue` (NOT null); `NotificationColor.ToHex()` - returns `#AARRGGBB` (alpha prefix). -- Lifetimes: Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky until dismissed (the pure mapping - lives in `NotificationService.BuildRequest` + the `Cards` tint table: slate info `#3a3b52`, green - success `#1d5c38`, amber warning `#b8860b`, dark-red error `#8f1f1f` — matching the health-banner language). -- Calls marshal to the captured Dispatcher (`_dispatcher.Invoke`) — encoder/pump events fire off-thread. - -Migrated MessageBoxes: both webcam-acquire warnings, image-read warning, sign-in unsuccessful/failed. -Promoted from log-only (each keeps its AppLog line): go-live prep failures ×3 (reusable stream, -broadcast insert, prep exception), frame-pump death while live, mic-missing at startup, premium lapse, -saved-session refresh failure (sign-out notice). Deliberately left log-only: offline license re-validation -skip (would spam every launch). Still modal by design: SocialsDialog's slot-delete YesNo confirm. - -### Licensing — do not violate (GPL-3.0 open source since 2026-10-09; see `LICENSE`, `ADDITIONAL-TERMS.md`, `THIRD-PARTY-NOTICES.txt`) - -**LlamaCasty is free software under GPL-3.0-or-later** (creator rulings 2026-10-09 — free forever, public -repo, "GPL-3 with attribution"). Copyright (C) 2026 Gramps. `ADDITIONAL-TERMS.md` carries the three -§7 terms: **7(b)** every copy keeps "Based on LlamaCasty by Gramps — https://llamacasty.com" in its -About screen; **7(c)** modified versions are marked and renamed; **7(e)** no trademark rights. The -**icon + logo files** (`Assets/llamacasty-icon.png/.ico`, `Assets/llamacasty-logo.jpg`) are **all -rights reserved, outside the GPL** — verbatim copies may carry them, forks must replace them (this is -what keeps the merch brand protected). The scene backgrounds + default alert video are AI-generated -**placeholders** (creator: made "with the intent of individual creators to immediately replace with -their own images") — they go out under the GPL with the code, no reservation. `LICENSE` + `ADDITIONAL-TERMS.md` ship beside the exe -(csproj `None` items); the About panel shows `THIRD-PARTY-NOTICES.txt`, whose header carries the -LlamaCasty license notice (GPL §5(d) "Appropriate Legal Notices"). **Source offer (§6):** -`https://llgit.llamachile.tube/gramps/LlamaCasty` (the Gitea repo; creator toggles it public — the -old `gramps/ytLlive` path 301-redirects there), recorded in the notices header + README. - -Every bundled component must stay GPL-3.0-compatible (MIT/BSD/Apache-2.0/LGPL/public domain all are; -**GPL-2.0-only and proprietary are not**; Apache-2.0 is fine with GPL-3 but NOT with GPL-2). The -rules below predate the GPL switch; where their *reason* was "proprietary product" it is noted, but -the rule itself still stands for its other reasons. **NEVER:** - -- **Swap the FFmpeg pin casually** — the BtbN `lgpl-shared` pin is the verified build. A GPL FFmpeg - build would now be license-compatible (the old "GPL contaminates the paid product" reason is gone), - but changing builds means re-verifying encoders, and libx264 carries no H.264 patent grant (Cisco's - covers OpenH264). Keep `lgpl-shared` unless a task says otherwise. -- **Distribute the static lgpl build** — LGPLv2.1 §6 wants relinkable object files for static linking. - The **shared** (dynamic-DLL) build sidesteps that: compliance is "license text + source offer + - unmodified binaries". The pin is `lgpl-shared`; when the pin is refreshed, keep the shared variant. -- **Use BtbN's `nonfree` variant** — it adds fdk-aac (Fraunhofer code licensing). The native FFmpeg AAC - encoder is fine (no Fraunhofer code) but grants no AAC patent license — accepted low-risk posture for - RTMP→YouTube, since encoder vendors cover their implementations (Cisco OpenH264, NVIDIA NVENC, Intel - QSV, AMD AMF). -- **Link FFmpeg into the app** — it stays a separate subprocess fed frames over a pipe; that separation - keeps the app's own code out of LGPL reach. -- **Drop `THIRD-PARTY-NOTICES.txt`** from the shipped app or the About screen, or alter the FFmpeg - copyright/LGPL notices inside the downloaded binaries. Automating the download counts as distribution - — the obligations are not optional. -- **Pin to a moving target** — the `latest` BtbN release tag floats. Only immutable autobuild tags give - a reproducible source offer. Record the tag + variant beside the URL (TASKS.md) every time the pin moves. -- **Use non-CC0 icon art** — the social bar's bundled SVG logo path data comes from **Simple Icons** - (CC0 1.0, public domain — see `THIRD-PARTY-NOTICES.txt`). Replacing or adding logos must stay CC0 or - another public-domain source; a logo asset under an incompatible or attribution-heavy license - would complicate every redistribution. The app's icon + logo (`Assets/llamacasty-icon.*`, - `Assets/llamacasty-logo.jpg`) are the creator's own art — all rights reserved, outside the GPL - (`ADDITIONAL-TERMS.md`). -- **Ship an ML model without clean provenance** — the background-effects model (TASK 51) is Google's - official MediaPipe Selfie Segmenter download (Apache-2.0, model card), converted by our own script. - Model-zoo re-hosts (e.g. PINTO #109 = ML Kit's model, extracted) are off-limits. Apache-2.0 §4 - obligations (license notice + "we converted it") live in `THIRD-PARTY-NOTICES.txt`. -- **Forget the v1 license-texts gate** — `THIRD-PARTY-NOTICES.txt` links the canonical license texts; at - **v1 (GA)** the full texts of every license it names MUST ship alongside it (TASK 4 requirement 9 is the - release blocker). Queued early is wrong; the release pass owns it. -- **Break the in-app About requirement** — since TASK 11 the About overlay is the app's in-app creator - hub: the real logo, the creator links (YouTube channel, Mastodon, Buy me a coffee, and the greyed - "Unlock Premium" seam whose billing URL is tabled), and a **Licenses & legal** sub-panel that loads - `THIRD-PARTY-NOTICES.txt` from the executable directory and renders it in a scrolling panel — - **never the OS viewer, no Notepad, no browser tab**. The premium/coffee link constants live in - `MainViewModel` (`PremiumUrl`, `CoffeeUrl`, `ChannelUrl`, `MastodonUrl`); filling in the billing URL - lights up the premium button. Do not reintroduce an "open the notices in Notepad" button. - -## Design Principle - -> This software is so intuitive that even the most right-brained person can easily intuit and use it. - -Apply this to every UI decision: -- One-click go-live with working defaults -- Prefilled YouTube defaults (RTMP URL, bitrate, resolution, latency) -- Visual/drag-and-drop scene building over property panels -- Every action produces a visible outcome — no dead ends -- **"We're not them" (2026-09-01):** assume the creator's hardware is mediocre, because it is. - Every feature spends the machine's budget once — never twice for the same result (record OR - stream, not both; one reusable stream; one webcam; two mixer inputs). If a feature only sings on - a high-end chassis, it doesn't ship — the OBS escape hatch is open by design. - -## Monetization (design decision — the branding flash is the sword) - -> **⛔ Superseded 2026-10-08/09 — free forever, no branding on the stream.** The credit is now -> **OFF by default**: `MainViewModel.ShowBrandFlash` (internal static, default `false`) is the -> master switch, and `BrandFlashEnabled => ShowBrandFlash && !IsPremium` feeds the presenter at -> construction and on every licence change. The code (`BrandFlashPresenter`, the FramePump -> `brandFlash:` seam, the preview layer) is kept, not deleted — creator ruling: "don't delete it, -> just make it conditionally displayed". `BrandFlashOutputTests` opt in by setting the switch -> before constructing a `MainWindow`; `Credit_IsOffByDefault_EvenOnTheFreeTier` guards the default. -> Everything below is the historical paid-tier design. - -Full pricing and the Year 1 revenue forecast in `MONETIZATION.md` (gitignored). ⚠️ **No Polar -product, discount code, or license key exists any more** — nothing bills today; Polar is dormant. -Brand palette and assets in `MARCOM.md` (gitignored). This section covers the in-app model. - -Free forever: **all features unlocked for everyone** — no feature lock, no paid tier, and since -2026-10-08 **no watermark either** (the credit is OFF by default; see the banner above). No -creator will tolerate a watermark, and as a good-guy developer we give everyone complete access -so no one can call us crooked. Money comes from gratuity/merch, not gating (TASK 54). - -- **Free:** a periodic branding credit — "made with LlamaCasty!" in **full-frame neon** (white core, - feathered red/blue halo with the channels split in opposite directions), at a **random position - inside the frame on every presentation** (single pre-measured line, so it never wraps; travel range - is `0 … 1920−textWidth`), held **2s** (500ms fade in, 1000ms full, 500ms fade out), first credit - ~5s after go-live then **every `rand(30s)+30s`** (30–60s), on the live output **and** local - recordings. An always-on watermark can be cropped or covered; an intermittent full-frame flash can't - be cropped and is impractical to edit around on a live feed. - **The flash is also the free tier's billboard** — every free stream advertises LlamaCasty to its own - viewers; the free tier is distribution, not compromise. - Implemented as `Services/Compositor/BrandFlashPresenter.cs` (raster + envelope + placement + licence - gate), published to **both** the frame pump (`FramePump(brandFlash:)` → the compositor's per-frame - `flashFrame` slot) and the preview (`BrandFlashElement`, bound to `BrandFlashImageSource`) from the - **same** `VideoFrame`, so the preview cannot drift from the broadcast. Deliberately NOT the - `flashFrame` parameter of `SceneGraph.GetBakedBase` — `SceneRegion.Matches` compares element ids - only, so a credit folded into the cached bake would freeze there and never expire. - **Pre-GA posture — REVERSED 2026-09-26 (TASK 36 shipped).** This used to render *in the preview - only*, never on the broadcast, so test VODs stayed clean. The creator ruled that the free tier must - be honest advertising: it now composites into the recording and the stream. Test VODs from an - unlicensed instance carry the credit — use a license key or the paid build for clean captures. - **Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting. - License activation still flips exactly one bit: `IsPremium` → flash off. Nothing else changes - between free and paid, ever. - **Cadence is APP-LIFETIME, not go-live (creator ruling 2026-09-26):** the presenter is - `Start()`ed **once in the `MainViewModel` ctor**, not from `UpdateLiveVisuals()`. It used to be - gated on `IsLive`, which meant a recording made without ever going live carried no credit — the - creator's ruling: *"the made with llamacasty flash should appear in all scenes, not just live"* - and *"should also appear in recordings"*. One start now covers every scene, the preview, the - stream and the recording, because go-live and local recording are the **same `FramePump`** - (`Streaming.Operations.cs:68` and `:199` both call `StartAsync` with the same `brandFlash:` - delegate) — verified, not assumed; there is no second encoder path needing a "redirect". The - licence gate needs no live branch: `IsPremium`'s setter pushes `Enabled` from anywhere. - **Consequence of app-lifetime — the premium edge restarts the timer.** `Enabled = false` stops - the `DispatcherTimer` (cutting the advertisement mid-credit). Because `Start()` is no longer - called per go-live, nothing would ever restart it, so a key entered mid-session would leave the - credit dead until the process restarted. The setter therefore restarts it when re-enabling while - `_running` (`IsCadenceTimerEnabled` is the test seam). - **Test-arithmetic trap, hit again 2026-09-26:** `Advance(5.1)` in ONE call cannot observe a - credit — `Advance` both *opens* the presentation and *ages* it by the same delta, so a single - 5.1s step blows clean through the 2s window. Always step in 1/30s increments like the real timer - (the `Step` helpers in `BrandFlashOutputTests`). -- **Paid:** branding flash removed. `BrandFlashEnabled` is now a **derived, non-assignable** - `!IsPremium` and the presenter's own `Enabled` gate is re-checked on every frame, so the - entitlement changing mid-credit cuts the advertisement on the next tick rather than letting it - finish. That's it. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording — - everything is free. **How `IsPremium` is obtained is a billing concern and has changed twice; - what it gates has not.** -- **Pricing: ⛔ SUPERSEDED 2026-10-08 by the free-forever ruling (TASK 54).** Retained as - reasoning only: `$10/mo` subscription or `$400` lifetime, no annual, ruled 2026-09-27 and - overtaken ten days later. If a paid tier ever returns, these are the numbers to argue from. - **How `IsPremium` would be obtained is a billing concern; what it gates is not.** -- **Billing: ⏸ dormant — no paid tier exists** (free-forever ruling 2026-10-08, TASK 54). The - Store IAP plan of 2026-09-27 was never implemented (`Windows.Services.Store` appears nowhere - in the repo) and was withdrawn with the Store route on 2026-10-22. If a paid tier ever - returns, the provider is **Polar** — merchant of record for VAT/sales tax, 5% + $0.50 Starter, - **$50 already paid**; `Distribution.md` §4.2/§4.5 holds the design. ⚠️ No Polar product, org - ID, key prefix, or discount code exists (`d105dfa1-…`, `c05fb364-…`, `LCYT-`, `LLAMAFOUNDER`/ - `LLAMA50`, the customer-portal validate endpoint, and the 14-day offline-grace machine are all - gone). -- **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the - once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are - how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with - emergency patches; not a 24/7 service promise. There is **no licence chatter at all** — the app - is free, there are no keys, and no user sees licence UI. TASK 36 item 5 (expiry reminder) is - struck. - -**Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1 -ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by -design, for **every** creator, free and ungated (the only subscriber swap remains the branding-flash -removal above). It has three layers, all free — this is the product's differentiator, not a paid tier: - -- **Reward-event capture (the data foundation).** The live-chat poll (`YouTubeChatService.Poll`) - receives every reward event over `liveChat/messages` and — since TASK 43 (2026-09-24) — - **decodes all six types**, not two: `superChatEvent`, `superStickerEvent`, `newSponsorEvent`, - `membershipGiftingEvent`, `giftMembershipReceivedEvent`, `memberMilestoneChatEvent` land as - `ChatMessage.Kind` (`ChatEventKind`) with their payload fields (display string, sticker - description, gift count, gifter name, milestone months); free subscriptions emit NO event - (API fact — a sub mention is a plain text row). The remaining half of the original capture - plan stays open (TASK 3 item 20): persist each event to the canonical SQLite `RewardEvents` - table (broadcastId, type, timestamp, amountMicros, currency, tier...) + `superChatEvents.list` - (30-day lookback) backfill — that persistence is what the session report and the journey - tracker read. Alerts did not wait on it: the shipped alert path (TASK 43) renders directly - from the in-memory `ChatMessage` feed. -- **Session / broadcast report.** A session is created at Go Live; the poll + End roll that session's - reward totals (new members, Super Chat $, stickers $, gifts, milestones). This is the in-app - replacement for the emailed external "stream activity report" — the incumbents (Streamlabs et al.) - email near-useless post-stream summaries; we track the same data natively and maintain it. -- **Journey tracker (TASK 39, slice 1 shipped 2026-09-22).** A **"YPP"** tab below the - Stream Settings pull-out (Live screen right edge) shows the connected creator's position - toward each YPP tier threshold. Slice 1 = current-scope data only: `channels.list?mine=true - &part=statistics,contentDetails` (subs/views/videos + uploads-playlist id) + - `playlistItems.list` for 90-day public uploads. Every refresh appends - a `YppSnapshot` to SQLite (schema v10) — the history a later slice needs for velocity/ETA. - **The `auditDetails` part is OUT (fixed 2026-09-23):** readding it 403s the WHOLE request — - that part alone requires the `youtubepartner-channel-audit` scope no app for normal creators - should request (the standing flags sat unreadable since slice 1 shipped; the app's `youtube` + - `youtube.force-ssl` scopes were never sufficient for them — the "no re-consent" slice-1 claim - was wrong for that part). Channel standing now deep-links to the Earn page; API stats come from - `statistics,contentDetails` only. YPP/API failures log the response body's `error.reason`. - **YPP thresholds are versioned, date-aware DATA, never constants** — the Tier-2 bar doubles - for new applicants on **2027-02-01** (long-form 4,000 → 8,000 qualified hrs / 365d; Shorts - 10M → 20M / 90d), while the Tier-1 fan-funding bar (500 subs / 3,000 hrs / 3M Shorts) is - unchanged. Hardcoding the pre-2027 numbers would ship the wrong ETA. Watch-hours / Shorts / - velocity / ETA are **slice 2**: Analytics-API scopes (`yt-analytics.readonly`, additive - `yt-analytics-monetary.readonly`) behind the `IChannelStatsProvider` seam (additive - capability, requires an OAuth re-consent). Compliance items no API exposes (2FA, AdSense - linkage) are in-app self-reported checkboxes with deep links; channel standing (exposed only - via the partner-audit scope) deep-links to the Earn page. Slice 2's once-daily refresh gate + - snapshot history were baked into slice 1. - -**What was rejected:** always-on watermark (obscurable — replaced by the flash), hard stream-time -cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams -routinely run 2-4 hours), soft-limit nagging, freemium feature tiers, and donation-only (relies on -the kindness of strangers). Resolution/quality ceilings stay rejected (fixed 2026-09-01, no longer -"deferred"): the free tier never loses quality — auto step-down (**TASK 33**) is a *protect the -stream* feature, never a monetization penalty. - -## Auth gates Go Live, but not exploration - -The app is fully usable without authentication: users can build scenes, add sources, compose -previews, and audition the software with zero commitment. But **going live requires authentication** — -it's the one capability gated behind YouTube sign-in. The sign-in should never pressure the user -("sign in (optional)", not a modal wall): the two-state top bar shows **Start Stream** (offline) / -**End Stream** (live), and the Start Stream dialog hosts the account — a saved session appears as -the default with "Change Account"; with none saved, a "Sign in to YouTube" button starts OAuth and -the Start button stays disabled until signed in. - -## Account assumption (do not build an account setup flow) - -Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator -account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no -YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently -replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to -YouTube" button; going live is unreachable until an account is connected. - -## YouTube Live API — design constraints (do not violate) - -> ## ✅ SOLVED 2026-10-04 — the broadcasts never went live because they were never BOUND. -> -> **The bug, in one line: `contentDetails.boundStreamId` on `liveBroadcasts.insert` does not bind -> the broadcast.** Binding requires an explicit `POST liveBroadcasts/bind?id=…&streamId=…&part=id`. -> -> **How it was finally proven, after a week of misdiagnosis** — the diagnostic line, once its own -> JSON-nesting bug was fixed (`FirstItem`), read: -> ``` -> lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING -> | streamStatus=active activeChat=none cdn=rtmp/variable@variable -> ``` -> Every field is real, and together they are conclusive: -> - `bound=NONE` **with** `autoStart=true`. Both live in the *same* `contentDetails` object on the -> insert, so the request was well-formed and accepted — the API took one field and silently ignored -> the other. That asymmetry is the proof; a malformed request would have failed outright. -> - `lifeCycle=created`, **not** `ready`. Google: `created` = *"incomplete settings, so it is not -> ready to transition to a live or testing status"*. An unbound broadcast is precisely incomplete. -> - `streamStatus=active` — the ingest half was never broken. YouTube had an active stream and a -> broadcast that was not attached to it. -> -> **The causal chain, complete:** no binding → `lifeCycleStatus` stuck at `created` → -> `enableAutoStart` has no bound stream to fire from → never live → close-out 403s -> `invalidTransition` because `created → complete` is not a legal edge (only `ready`/`live` are). -> -> **Why it survived a week, and the lesson worth keeping:** every observable pointed away from the -> binding. ffmpeg pushed ~99 MB cleanly, the reusable stream went `active`, close-out returned a -> *transition* error (which reads like a lifecycle-timing problem), and the insert returned a valid -> broadcast ID (which reads like success). **A reusable stream accepts bytes for a broadcast that is -> not watching it**, so a perfectly healthy RTMP session is exactly what an unbound broadcast looks -> like. Google says it outright in the migration guide: *"a liveBroadcast must be bound to exactly one -> stream before the live broadcast can actually start. The broadcast is not bound to any streams at -> the time that it's created."* That sentence was in the docs I read during the original design and -> was not applied. **Read the failure modes in the docs, not just the happy path.** -> -> **Shipped:** `YouTubeStreamService.BindBroadcastAsync`, called from `PrepareAndStartLiveAsync` -> immediately after `CreateBroadcast` and **before** the pump starts. Failure is fatal to the session -> (an unbound broadcast can never go live, so a live-looking UI over it is a lie). `boundStreamId` was -> **removed** from the insert body and the `streamId` parameter **removed from `CreateBroadcast`** — a -> parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible. -> The old test `CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert`, which asserted the -> disproven behaviour and therefore *laundered a guess into a guarantee*, is replaced by -> `CreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamId` plus -> `BindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body`. -> -> **Bind failures are not all "sign in again."** Google documents six reasons -> (`insufficientLivePermissions`, `liveStreamingNotEnabled`, `liveBroadcastBindingNotAllowed`, -> `liveStreamNotFound`, `liveBroadcastNotFound`, `internalError`, plus rate limiting) and only the -> first is an auth problem. `BindFailureMessage` maps each to its own text; `liveBroadcastBindingNotAllowed` -> matters most, because it means *our* ordering is wrong and a generic "check the sign-in" would hide -> a bug in our own code behind a plausible excuse. - -These are the hard facts behind every decision. Full list in `TASKS.md`. - -- **One-click go-live** — never call `transition(live)`. Insert the broadcast with - `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, - `selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live. - `enableMonitorStream=false` is what lets us skip the testing stage. -- **⛔ CORRECTION (2026-10-04): the push is provably healthy — stop investigating it.** The long - test run settles it: `liveBroadcasts.insert` at 14:17:04.577, ffmpeg `Output #0, flv` to - `rtmp://a.rtmp.youtube.com/live2/5f6z-…` at 14:17:05.686, and at 14:18:56.473 ffmpeg's own - muxing summary reads **`video:101229KiB audio:1419KiB`**, `muxing overhead: 0.202949%` — ~99 MB - of real video plus audio over 112s, which is exactly what 8000+128 kb/s should produce. **No - socket error, no 4xx from ingest, no reconnect.** The broadcast *still* never left `ready`, and - close-out earned 403 `invalidTransition` again. An earlier 8s run behaved identically, so - "YouTube needed longer" is disproven — 112s is longer. The break is between *ingest accepted* - and *broadcast told to start*, nowhere in the RTMP path. -- **The binding is the prime suspect, and the app was blind to it.** `contentDetails.boundStreamId` - ("the live stream bound to the broadcast") and `enableAutoStart` ("start automatically when you - start streaming video **on the bound live stream**") are both valid on insert, with no documented - restriction against a reusable stream. But YouTube's migration guide carries the danger: - **"a liveBroadcast must be bound to exactly one stream before the live broadcast can actually - start. The broadcast is not bound to any streams at the time that it's created."** And this - failure mode is *invisible from the RTMP side*, because **a reusable stream accepts bytes for an - unbound broadcast** — ingest succeeding proves nothing. ⇒ `LogBroadcastDiagnosticsAsync` rides - the **existing** stream-health poll (no second timer) and writes one greppable line: - `lifeCycleStatus`, `privacyStatus`, `recordingStatus`, `boundStreamId` **compared against the - stream id we are actually pushing** (`BoundMatches` → `yes`/`MISMATCH`/`NO-BINDING`/`unknown`), - `boundStreamLastUpdateTimeMs`, `enableAutoStart/Stop`, `liveChatId` presence, the stream's own - `status.streamStatus` + `activeLiveChatId`, and the CDN settings we declared. It is - **self-limiting** — `_broadcastSeenLive` latches on `live`/`liveStarting`, so the line shouts only - while the broadcast is stuck and goes quiet once healthy. **The next run reads this line; it is - the whole experiment.** Guarded by `BroadcastBindingTests.BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush`, - which pins `unknown` for a missing expectation so an absent stream id can never be misreported - as a match. - - **⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).** - `liveBroadcasts.list` returns a **list wrapper**, so the broadcast lives at `items[0]`. The - original code descended into `items` for the *stream* but read the *broadcast's* response root, - so every broadcast field came back `?` — and `BoundMatches` then dutifully reported a confident - **`boundMatches=NO-BINDING`** from a parse that had never happened. Three lines of fabricated - alarm. Two fixes, both load-bearing: **`FirstItem()`** descends into `items[0]` for both halves - (and returns `default` when there is genuinely no item), and the line now prints - **`boundMatches=unread`** when the broadcast could not be read, so an absent parse can never be - reported as a verdict. Guarded by - `BroadcastBindingTests.FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly`. - **Generalisation: a diagnostic must degrade to "I don't know", never to a confident wrong - answer** — otherwise one level of JSON nesting manufactures a bug that isn't there. - - **What the one run that *did* work proved:** the stream half was correct, and it shows - `streamStatus=ready` → **`streamStatus=active`** about 30s into the push. So the ingest does - register as active server-side, which is a genuine step forward — combined with the 99MB muxed - in the earlier run, the stream half of the system is now verified healthy. The broadcast half is - still unobserved pending a re-run. -- **Visibility picker (TASK 9 item 6) — DELIBERATE TEST-PHASE LOCK, not drift (2026-09-01).** - The "always Private" enforcement in `YouTubeStreamService.CreateBroadcast` stays while the creator - runs multi-month real-world testing: non-private test streams would clutter the channel with VODs - highlighting where the product breaks. `recordFromStart`/DVR stay ON — Private VODs are invisible - to subscribers and serve as post-mortem review tapes; bulk-delete pre-GA. The unlock is a - deliberate final-pass item in **TASK 36 (gold pass)**, wired with the dialog selection — never - opportunistic. The PRIVATE badge keeps showing when the stream is actually private. -- **⛔⛔ STOP. The always-Private lock is a DECISION, not a bug, not drift, not an open question. - (Creator ruling, restated 2026-10-04 after being asked about it AGAIN.)** - `privacyStatus = "private"` hardcoded in `YouTubeStreamService.CreateBroadcast` is - **exactly right as written. Do not change it, do not "unlock" it, and — this is the part I keep - getting wrong — do not raise it as a question, an observation, a suggestion, or a - "heads-up".** The creator has now answered this more than once. - - **What it becomes:** the hardcoded lock is replaced by a real, user-selectable - visibility option in the Go-Live dialog — **at v1, not before.** Not at 1.0-candidate, not - at GA prep, not opportunistically during any other unit. - - **Why it exists:** pre-v1 test streams would otherwise publish VODs of a product still - breaking, in public, to subscribers. Private keeps those tapes usable for post-mortem review - while staying invisible to the channel's audience. - - **Do not let these look like symptoms of the one they resemble.** All are working as designed: - `privacy=private` in the broadcast diag line; the PRIVATE badge; `recordFromStart`/DVR ON; - and the absence of any delete call in the codebase (there is no delete feature, by design at - this stage — "can we delete the test VODs" is a *retired* question, answered 2026-10-04: - `videos.delete` accepts `youtube.force-ssl` which we already hold, private is a visibility - setting not a deletion lock, and anyway a broadcast that never reaches `live` never becomes a - VOD at all). - - **`UpdateBroadcast` is NOT the unlock — and it is also currently DEAD CODE. (Fact corrected - 2026-10-04: an earlier version of this note said `CreateBroadcast:112` / - `UpdateBroadcastMetadataAsync`, both wrong.)** The method that can change a live broadcast's - privacy is `YouTubeStreamService.UpdateBroadcast(broadcastId, meta)`, which sends - `privacyStatus = meta.Visibility ?? "private"`. **It has no production caller** — the only - caller in the repo is `BroadcastPullOutTests.cs:120`. So `CreateBroadcast` hardcoding private - is not merely backed up by convention, it is the *only* thing enforcing it. - - **⇒ Is a Go Live video private? YES — verified 2026-10-04, and this answers a standing - creator doubt.** Three independent hardcodes (`CreateBroadcast` → `"private"`; - `GoLiveViewModel.Visibility => "Private"`; `BeginTestStream` → `StreamVisibility = "Private"`) - plus one unreachable method. There is no path by which a broadcast this app creates can become - non-private today. The creator's actual concern — "do not populate my channel with videos - showing the app at its worst" — is fully met. - - **⛔ THE LANDMINE, and the reason the above matters:** `LiveBroadcastFormViewModel` still - exposes `VisibilityOptions = {private, unlisted, public}` and `UpdateBroadcast` would happily - honour it. **The v1 visibility option is the first thing that will ever wire `UpdateBroadcast` - up — so v1 is exactly when this guarantee ends, by the creator's own choice.** When that - happens, private stops being guaranteed and becomes a default the user can change. That is - intended at v1; it is a disaster before v1. Do not wire `UpdateBroadcast` into any - production path before v1, and do not "fix" the dead-code smell by calling it. -- **⛔ The TEST button is HIDDEN at v1 — it exists only because of the lock above (creator - ruling 2026-10-04).** This was **absent from the map entirely** until that date, which is how the - dry-run framing below came to be the only recorded one. Both are true, and the second is the - load-bearing one: - - **Its real job is being the guaranteed-private path.** `BeginTestStream` hard-sets - `StreamVisibility = "Private"` (`MainViewModel.Streaming.Operations.cs:105`), so Test can never - publish. That is *why* it exists for the whole pre-v1 phase — a dry run you cannot leak. - - **At v1 it is redundant and gets hidden**, because once visibility is a real dialog option the - user picks Private themselves and a separate "safe" button no longer earns its place. Hiding it - is part of the v1 visibility work, not a separate cleanup. - - **The pre-existing "dry run" framing is now known to be incomplete** (`ShowTestButton`'s own - comment calls it "a dry-run of the livestream"). Do not read that comment as the reason the - button must persist, and do not re-derive the button's purpose from it. If a dry run is still - wanted at v1 it must be re-justified on its own merits — that question is **open, and is for - v1, not now.** - - **It is a computed predicate, not a setting** — `ShowTestButton => ShowStreamActions && - IsConnected` (`MainViewModel.Streaming.cs:182`), feeding both `CanStartTest` and the XAML - visibility. So the v1 hide is a one-line predicate change, not a settings migration. - - **Not a v1 blocker:** the Test route is the *same* pipeline as Go Live — - `BeginTestStream` → `StartStreamingSession(alsoRecord: false)` → `PrepareAndStartLiveAsync` — - so Test and Go Live create identical broadcasts (`boundStreamId`, `enableAutoStart`). Test - differs only in never recording (`alsoRecord: false`) and forcing Private. **This is why Test - is the correct way to run any diagnostic stream**, and why a Test result is evidence about Go - Live. -- **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 → - `StreamStatus.Error`, never a crash; `StopStream` clears `_currentBroadcastId`. -- **Variable reusable stream** (shipped 2026-08-16) — `GetOrCreateReusableStreamAsync` lists - `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once per channel - (`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 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)` - polls `liveStreams.list?part=status`; render nothing on `good`/`ok`/`noData`, surface a banner only - on `configurationIssues[]` with `warning`/`error` severity. The decision is the pure - `Services/StreamHealthReporter.BannerFor` (null text = no banner; error beats warning). The VM polls - every **30s while live** (`DispatcherTimer` `_healthPollTimer`, first poll right after go-live) and - clears on End via `ResetHealth`; poll failures log only. **Parsing (fix 2026-09-22):** the real API - nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — reading it as - a string crashed every poll (`requires an element of type 'String'`, seen 2026-09-22 on both test - sessions); the parse now reads the nested `.status`/`configurationIssues` and tolerates the old - flat-string shape. UI = a full-width banner strip under the - top bar, `HealthIssueBanner` text + `HealthIssueBackground` (amber `#b8860b` warning / dark red - `#8f1f1f` error), hidden by `NotNullToVis`. The design's bottom-strip YouTube logo + green/red dot - (clickable → dialog) is still queued. -- **One dialog, three states** — not connected / connected-offline (all editable) / live - (title + description + visibility editable; quality + account greyed out). Both entry points - (Start Stream button + bottom strip) open it; prefilled from saved session profile. -- **End stream (SHIPPED 2026-09-01; pre-flight hardened 2026-09-22)** — stop encoder → `YouTubeStreamService.EndBroadcastAsync` POSTs `liveBroadcasts/transition?broadcastStatus=complete&id=…&part=status` (AFTER RTMP EOF, so no frames post-date the end — VOD finalizes immediately instead of ~1min of frozen "stream offline"). **2026-09-22: every end logged 403 `invalidTransition`** — a blind complete races enableAutoStop/YouTube's auto-complete (the old doc's "already fired" guess was wrong; it's a broadcast state race). The call now pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only transitions from `live`/`testing`, skipping silently otherwise (`enableAutoStop` finishes every skip — never throws, never toasts a finished session). Inconclusive pre-check still posts (old behavior, backstopped). Record-only stops make ZERO API calls (guard: `wasLive && _currentBroadcastId != null`). -- **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz, - mono/stereo only. YouTube flags violations via health status. -- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD - (`recordFromStart` + `enableDvr` default true). -- **Test Stream mode (TASK 41, SHIPPED 2026-09-22)** — every broadcast is private-only anyway - (see the visibility lock above), so "Test" is a **session variant over the real go-live - pipeline**, not a new mode: `BeginTestStream` → `StartStreamingSession(alsoRecord:false)` - (the dialog path was extracted into this shared boot — no Go Live dialog for a Test, no - `BroadcastForm.CaptureGoLive` "last live" stamp, no recording). `IsTestStream` flips the face: - gold top bar + glow (`#9c6f1c`), a **TEST** badge, and the End button reads **"End Test"**; - `StopStream` clears it in every path (including pump-failure rollback). Once the liveChatId - resolves, `TestSession.BeginTest(broadcastId, liveChatId)` opens the **TEST drawer** (third - right-rail pull-out, three-way one-open-at-a-time exclusivity wired in `MainViewModel.Ypp.cs`; - any left-click outside the rail closes whichever drawer is open — TEST included (fixed - 2026-09-25, TASK 46; the code-behind outside-click close in `MainWindow` had skipped - TestSession, so only Stream Settings + YPP dismissed on an outside click). - **liveChatId resolution (fixed 2026-09-25, TASK 44; ⚠️ CORRECTED 2026-10-04):** the id is - `snippet.liveChatId` — `contentDetails` has NO liveChatId property — and - `GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` reads part=snippet and - polls with a bounded retry, and `PrepareAndStartLiveAsync` starts the frame pump FIRST before - resolving it. **The old claim that "YouTube only populates it once the broadcast is LIVE" is - WRONG, and it was inferred from a sample rather than from the API.** The 2026-10-04 diagnostic - caught `liveChatId=present` while `lifeCycleStatus=created` — not live, not even `ready`. The - official `GetLiveChatId.java` sample tests `broadcastStatus == active` before returning the id, and - that read as a statement about *when YouTube populates the field*; it is only a guard in the - sample. **Generalisation: a sample's defensive check is not a spec.** Also note the old note said - the missing id meant "the ready state" — the broadcasts were never `ready` at all, they were - `created` and unbound (see the SOLVED box above), so the terminology was wrong too. - The bounded retry is harmless and stays, but the id is available far earlier than assumed — worth - revisiting only if chat startup latency is ever reported as slow. - Drawer tooling: **Mock Chat Input** = a REAL `liveChat/messages.insert` (`YouTubeStreamService.InsertChatMessageAsync`) - that round-trips through the real ~2s poll and renders through the live overlay path; - **simulated Subscriber / New Member / Super Chat** = `YouTubeChatService.InjectSimulatedMessage`, - which raises the poller's `MessageReceived` seam, marked `ChatMessage.IsSimulated`. **API fact - (researched + hard constraint): `liveChat/messages.insert` only creates `textMessageEvent`s — - SuperChats / memberships / sponsorships cannot be inserted via any API**; simulated non-text - events are local-only by design and never reach YouTube. **Insert body shape (fixed 2026-09-25, - TASK 45):** the body MUST include `snippet.type` = `"textMessageEvent"` alongside `liveChatId` - and `textMessageDetails.messageText` — omitting type returns `400 MISSING_REQUIRED_FIELD` - (found live on the next Test Stream; the TASK 41 test had asserted body presence but not - type, so it stayed false-green). Scopes `youtube` + `youtube.force-ssl` - already cover insert. **Real stinger/TTS alert widgets are DONE (TASK 43, shipped - 2026-09-24) as the native alert box** — see the next section; Text source (TASK 3 item 16) - and the RewardEvent persistence (item 20) remain open. - -### Native events & alerts (TASK 43, shipped 2026-09-24 — StreamElements replaced) - -The creator asked to replace the external StreamElements feed with native YouTube events; -the answer is **native events + a native alert box**, and the product decisions are -published decisions (recorded in `TASKS/task-43-native-alerts.md`): - -- **No third party.** The chat feed IS the event feed: `liveChat/messages` carries all six - monetization events (superChat / superSticker / newSponsor / membershipGifting / - giftMembershipReceived / memberMilestoneChat). `streamList` is the *connection semantics* - of that same endpoint (pollingIntervalMillis-driven), not a separate call — the service - re-arms its one-shot poll on the server's cadence (clamp 1000–6000ms), `maxResults=2000`. - Cite: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList -- **`SourceType.AlertBox` — one "Stream Alerts" celebration zone per layout**, OBS - alert-box style: a designated draggable canvas area, idle = transparent. Mirrors the chat - gate (`CanAddAlerts`; `AddSource` refuses a second). Defaults 620×430 → 680×200. -- **Six events → six unique animations** (`Services/AlertRenderer.cs`): SuperChat slide-up - + gold count-up + shine sweep (green), SuperSticker scale-pop on a tilted chip (purple), - NewMember drop-in + flash band (violet), MemberGift slide-left + gift-chip `×N` fan - (amber), GiftReceived confetti scale-in (green→purple), MemberMilestone rise + tenure - growth bar (orange). Phases enter/hold/exit (~4–5s); every card draws the **brand line - "made with LlamaCasty!"** — each broadcasted event is free product placement. -- **`Services/AlertOverlayLayer.cs`** — the component: enqueues events one-at-a-time, - advances on a 33ms dispatch ticker, serves the live frame through `RenderFrame` (same - cache-first pattern as chat) and the preview through `UpdatePreview`; **`Advance(double)` - is the deterministic test clock** (tests drive playback with no timer). Queue cap 10 - (floods drop the tail, never stall). `ChatEventKind.None` rows (plain chat, sub mentions) - **never enqueue** — per the creator ruling, a subscriber mention is sufficient; there is - no free-sub alert (YouTube emits none) and no viewer count (vetoed — demotivating, - weaponizable). -- **Alert box VIDEO (TASK 47, 2026-09-26):** since TASK 47 the box plays a clip on every - alert instead of (only) an animation — `Enqueue`→`BeginClip` spawns a **per-play** - `IAlertClipDecoder` (ffmpeg `rawvideo bgra -vf scale=W:H` frame pipe + `f32le` audio - pipe, both real-time paced via `RawVideoFrameReader`; **not** the refcounted - `MediaVideoSourceManager` — alerts are one-shot plays that must be disposed at drain). - Custom clip wins per-source over the built-in ONLY when `AlertUseDefaultVideo==false` - AND the file exists; the shipped `Assets/alert-default.mp4` is stamped into the `Asset` - table at startup (`AlertDefaultVideoKey` setting; the prune keeps it via UNION) and - read-at-play; any decode failure falls back to the six `AlertRenderer` animations - (deliberate, not an error). Fades in/out via **`FadeDurationSeconds = 0.30`** - (`AlertOverlayLayer` const): fade-in by `elapsed` on straight-alpha frame copies; - EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears - `_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio - forwards to the mixer sink scaled by `volume × fade`. -- **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes - "Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` — - a strip rasterized **on the UI thread once per text** (cached, unpremultiplied - straight-alpha pill) then positioned by pure byte-math per call, so the pump calls it - from its own thread with no locking. **It is a dynamic overlay, NEVER baked** (the - social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` + - `CompositeLayers` take a trailing `tickerFrame` threaded from - `FramePump._alertTicker` (`Func` ctor seam) through - `RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll - changes the cache key. Toggled by `AlertShowTicker`. - - **It draws INSIDE the Stream Alerts box (creator ruling 2026-09-26).** Was a global - 1920px bar pinned to the top edge: *"the ticker should appear over the stream alerts - video, not over the entire preview window"*. The strip is now rendered at the - **alert box's own size** (`AlertTickerRenderer.Render(..., width, height)`, defaults - still 1920×48) and the box's origin rides on **`VideoFrame.Placement`** - (`(int X, int Y)?`, with `OriginX`/`OriginY` = 0 when unset). Every blit site reads - `tickerFrame.OriginX/OriginY` instead of a literal `0, 0` — `SceneCompositor` ×2 and - `FramePump`'s static-bake `Overlay` path. The preview binds the same numbers - (`AlertTickerLeft/Top/Width/Height` → `AlertTickerElement`'s `Canvas.Left/Top` + - `Width`/`Height`), so preview and output cannot drift — the same guarantee as the - branding credit. Product default box is **680×200 at (620, 430)** - (`MainViewModel.Sources.cs`); height is clamped to the natural 48px strip. - **No alert box in the scene ⇒ no ticker at all** — there is no global position left. - - **Why the position travels on the frame, not in a full-canvas frame:** a 1920×1080 - overlay is 8.3MB of large-object-heap garbage per tick — ~2.5GB churned over one 10s - alert at 30fps. A box-sized strip is ~370KB. The branding flash *does* render - full-canvas, but it **recycles** one 8MB buffer and bumps `Epoch`, so it never - allocates per frame; the ticker allocates per call, so it must stay small. - - `CopyStrip` clips **rows** to the target height: the pill rasterises at its natural - 48px, so a box shorter than 48px would otherwise write past the end of the buffer. - - **Three display methods** (`Source.AlertDisplayMethod`, DB column, panel "Display" - selector): `TickerScroll` (marquee), `Flash` (0.5s on / 0.5s off), `Solid` (centred, - still). The marquee is paced in **reads per alert** — `TickerReadsPerAlert = 3` - passes inside the alert's own length, speed derived from that — **never px/s**; the - old fixed `140px/s` took ~17s per pass, so a 10s alert showed the message once. - Same convention as the incumbent (Streamlabs: "Alert Duration" + "Text Delay", not a - scroll-speed slider). Flash's off half returns null. - - **A ticker still needs its own preview element, not a `Source`.** It is a frame-pump - producer, so it never rides a per-element `Image`; `AlertOverlayLayer` takes a - `tickerPreviewSink` and publishes from `RefreshAlertPreviews()` (UI thread only — - the pump thread must never touch a `WriteableBitmap`); the VM writes it into one - reused `WriteableBitmap` (`AlertTickerImageSource`/`AlertTickerVisible`) bound to - `AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`. The rect - is re-raised on **every** published frame, not just on a bitmap resize, so a box - moved or resized mid-alert tracks live. - - **Two measurement traps here:** a seamless marquee never goes blank (a wrapped copy - enters as the pill clears), so count a pass by the pill's leading edge resetting, not - by an empty frame; and the run is phase-started half a frame in, or the first - published frame is blank (the pill sits exactly off the right edge). -- **Alert audio under the master limiter:** `AudioMixer.EnqueueAlertAudio` writes a - dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` before the - limiter and added at **unity — never ducked** (creator ruling: "don't lower my game - audio for a tip"); `StartLive` clears it. Per-alert loudness = the Volume slider. - ---- - -## Windows packaging & distribution (⛔ REVERSED 2026-10-22 — direct + Steam; Store dropped) - -**The Microsoft Store route is dead.** A brand name on the Store's *publisher* line requires a -**Company** account, which requires a registered legal entity (an LLC — ~$85 New Mexico with a -~2-week SOS backlog, ~$143 Wyoming). Partner Center does **not** convert an Individual account to -a Company account later, and an Individual account publishes under the verified legal name. The -creator will not fund an entity for a nameplate, so the 2026-09-27 Store ruling was withdrawn on -2026-10-22. Store IAP was never implemented either — `Windows.Services.Store` appears nowhere in -the repo. - -**The route now:** - -1. **Direct download from `llamacasty.com`** — the app is **free forever** (TASK 54), so there is - nothing to bill in v1. -2. **Steam when $100 exists** — Steam Direct is $100 per product (recoupable after $1,000 AGR), - accepted categories include *Audio/Video Production* / *Player Tools* (OBS Studio is the - precedent), the **developer/publisher name is self-chosen** so the brand needs no entity, - 5+ screenshots at 1920×1080 + capsule art, a build Valve runs, and a **2-week live page** - before release. Valve takes 30%. -3. **Polar is dormant, not deleted-in-principle** — chosen checkout/licence provider if a paid - tier ever returns; $50 already paid. `Distribution.md` §4.2/§4.5 holds the design. -4. **The Microsoft Store: not pursued.** The MSIX package stays as a working artifact and is - submitted nowhere. History: `TASKS/research-store-certification.md`, - `TASKS/task-48-distribution-msix.md`. - -**Consequences that are architecture, not packaging detail:** - -- **⛔ The updater is now MISSING, not deleted-by-design.** Velopack and its bootstrap were - removed on 2026-10-22 (TASK 48 slice 3) because the Store was going to deliver updates — that - premise is gone. An update path must come back, and - `ytLive.Tests/StorePackagingTests.cs` (which asserts no updater returns) must be rewritten - with the chosen updater rather than left to veto one. -- **The MSIX package still builds** via `scripts/package-msix.sh` (TASK 48 slice 2) — publish - `Release -r win-x64 --self-contained` into `artifacts/msix-stage`, copy - `Packaging/Package.appxmanifest` in as `AppxManifest.xml` + `Packaging/Assets/`, then - `makeappx pack` → `artifacts/LlamaCasty.msix` (159 MB, gitignored; `MSIX_PFX` signs it for a - local install). The manifest is a **full-trust `packagedClassicApp` at `mediumIL`** with - `runFullTrust` + webcam + microphone. `Square310x310Logo`/`Wide150x150Logo` traps are in - `MyMistakes.md`. Logos come from the app's own artwork via `scripts/make-store-logos.py`. -- **⛔ Bundle ffmpeg — no runtime downloads** (was Store policy 10.2.2; still right for a direct - download, and it fixes the 2026-09-01 404). See [`docs/ai/output.md`](docs/ai/output.md) → "FFmpeg locator". -- **⛔ 64-bit Windows only (creator ruling 2026-10-09).** Both csproj files set - `x64` (the test project too — an AnyCPU test project - referencing an x64 app raises MSB3270 and breaks the 0-warning gate). Why: the bundled ffmpeg - is the win64 build, ONNX Runtime 1.31 ships no win-x86 native (only win-x64/win-arm64), and a - 32-bit address space can't hold the 1080p frame buffers. Windows 11 is 64-bit-only anyway. - Public copy says "64-bit Windows 10 or 11". - -**What does NOT change: the monetization posture.** The app is **free forever with no watermark** -(TASK 54, 2026-10-08), so `IsPremium` gates nothing in v1 and the branding flash is OFF by -default. `Monetization` above governs. - -### ⛔ Durable invariant: full trust, or recording silently breaks - -MSIX packages run at one of two trust levels. A packaged WPF app declaring -`rescap:Capability Name="runFullTrust"` runs at **mediumIL — the same permissions as a -standard desktop app, NOT AppContainer** — and may spawn child processes, which is why -bundling ffmpeg is legal. Writes to user-profile locations (AppData, Downloads, MyVideos) -**pass through unvirtualized at mediumIL**; the package install folder is read-only. - -**`ViewModels/MainViewModel.Recording.cs` writes recordings to -`%USERPROFILE%\Downloads` / `SpecialFolder.MyVideos` (`DefaultRecordFolder()`, -`ChooseRecordFolder()` via `Microsoft.Win32.OpenFolderDialog`), and the whole -temp-write-then-move lifecycle stays in one directory.** That works *only* because we are full -trust. No `broadFileSystemAccess` capability is needed, and none should be added. - -⚠️ **If anyone flips the manifest to `appContainer`, `Directory.CreateDirectory` and -`File.Move` start resolving to a per-package virtualized location and output vanishes with no -error.** A comment at `DefaultRecordFolder()` is owed **when the manifest lands** (TASK 48 -item 6) — not before; a comment describing a manifest that doesn't exist is worse than none. - -### Signing — do not buy EV - -Microsoft **removed the SmartScreen instant-bypass for EV certificates in March 2024**. An -EV-signed file now accrues reputation exactly like an OV-signed one, so EV ($400+/yr) buys -what OV (~$150–300/yr) buys. Signing is **not** instant regardless — a valid cert stops the -*malware* warning immediately, but "unrecognized publisher" clears only as SmartScreen accrues -reputation from real download volume. Cert validity is capped at **460 days** (recurring -line item) and private keys must live on an **HSM or hardware token**. **The 2026-10-22 ruling -is to ship unsigned and accept the "unrecognized publisher" warning** until download reputation -accrues — buying a certificate is deferred, not rejected. `Distribution.md` §4.3 was corrected -2026-09-27 (it previously recommended EV). - -### Distribution and licensing are independent — and the channel decision does not decide licensing - -A **Store-distributed** app *may* still verify licences with **Polar** (Store policy 10.8.1 -explicitly permits a secure third-party purchase API for non-game PC products), so "which -channel?" never implied "which billing?" — route B was a real option. The 2026-10-22 reversal -proves the point in the other direction: dropping the Store did not by itself restart Polar, -because the **2026-10-08 free-forever ruling** (TASK 54) had already removed the paid tier. -Polar is therefore *dormant*, ready if a paid tier ever returns, and nothing in v1 depends on it. -The lesson worth keeping: the licensing choice rides on a **monetization** decision, not on a -distribution decision. - -### ⛔ "Only when the user says so" is enforced by Windows, not by us - -Camera/mic consent is layered: Store policy **10.6** forbids circumventing OS checks; the -**Windows desktop-app camera/microphone toggle** (Settings → Privacy & security, since Win10 -1903) is the gate; hardware lights and tray indicators are the OS's disclosure mechanism. So -**route everything through the standard MF/DShow/WASAPI paths and we never write that -certification ourselves** — the OS does it and gets neither our credit nor our blame. - -⚠️ **Untested:** whether that toggle reliably gates a **DShow webcam and a capture card**. -Microsoft's own support page admits desktop apps "might still be able to access your camera -or microphone even when these settings are turned off." This is **not** a certification -failure (we declare `webcam` honestly and use supported APIs) but it is a real user-trust -issue — so **no privacy-forward marketing copy until it is measured.** Guard note added to -`MARCOM.md`. - -### ⛔ Durable invariant: chat is rendered, never stored - -`TASK 49`'s profanity filter is **local, on-device, opt-in, non-persistent**, with a -**user-supplied word list — never a hardcoded slur list baked into the binary** (unpleasant -artifact, false positives across dialects, extractable). It must **never match the SuperChat -amount or any reward field** — those are financial data under policy 10.5.5. - -The non-persistence half is not just hygiene: it is the load-bearing half of the **11.12 UGC -certification answer** (mirrored YouTube chat is moderated at industrial scale upstream; ~1.6B -comments removed in Q1 2026 alone, child safety the #2 reason at 124.6M). **No future feature -— chat history, moderation logs, analytics, crash payloads — may quietly break that -assumption.** If one is ever proposed, the 11.12 position must be re-argued first. - -### Minors - -The **operator is 13+ by construction**, not by promise: LlamaCasty requires a YouTube -account to stream, and YouTube's ToS sets the account floor at **13** (regional variants reach -14; 13–17 need parent/guardian permission; under-13s are on supervised/YouTube Kids accounts). -So a COPPA theory premised on collecting PI from under-13s has no purchase against the -operator. The residual is **viewers** — still possible on supervised family accounts — and it -is weak: live chat is restricted there, YouTube moderates before we see it, and we persist -nothing. COPPA is very unlikely to bind (not child-directed), but note the enforcement trend -(FTC Sept 2025 *Apitor*: operators are responsible for what third-party components collect) — -so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no -persisted chat or reward payloads." - -## Transitions (2026-10-27) - -- **Fade starts from the outgoing picture.** `TransitionService.Start` with no "from" frame sets - `CaptureRequested`; the frame pump copies its last output at the top of its next tick and calls - `SupplyFromFrame`. Before that fix the blend had no starting frame and a scene swap was instant. - Guard: `ytLive.Tests/TransitionFadeTests.cs`. -- **Type and duration** are selected in App Settings and persisted with the layout. Cut, Fade, Move, - and Wipe are offered; Wipe is a CPU left-to-right reveal. Guard: `ytLive.Tests/TransitionPickerTests.cs` - and `ytLive.Tests/WipeTransitionTests.cs`. -- **A scene change animates the outgoing preview in every mode.** Fade and Move fade it out; Wipe - shrinks it away; Cut is immediate. Program output transitions when streaming or recording, while - offline scene changes affect only the preview. Guard: `ytLive.Tests/PreviewTransitionTests.cs`. -- **Still planned (see TASKS.md):** persistent layers for Move and a capability-gated GPU Cube. - -## Brand logo (2026-10-27) - -- One locked image layer per scene, `Source.IsBrandLogo` (persisted column, additive migration), named - "Brand logo", directly above the background. Healed on load by `EnsureBrandLogos`, which also copies the - App Settings image into every scene's layer. Removal is blocked like the background. -- The image lives in the asset table; the settings table keeps its asset id and file name - (`BrandLogoAssetId` / `BrandLogoFileName`). Eye visibility is the layer's ordinary per-scene `IsVisible`. -- Default placement: top-left (32,32), 240x120 master px. diff --git a/docs/ai/capture.md b/docs/ai/capture.md index 9545cdd..5756321 100644 --- a/docs/ai/capture.md +++ b/docs/ai/capture.md @@ -476,9 +476,7 @@ new panel must use a different binding (`IsWebcamSelected`) or the selector matc --- -## Related in [`ai.md`](../../ai.md) +## Related files -- **Resolution tiers** — `OutputRect*` and the tier list (the vertical tier's 607×1080 band). -- **Key patterns** — MVVM/binding conventions the capture UI follows. -- **Tests** — hermetic-real-window rules that camera tests must obey. -- **Run** — launch/take procedure and the Release-only rule for measurements. +- [`ui-surface.md`](ui-surface.md) — **resolution tiers** (`OutputRect*`, the tier list, the vertical tier's 607×1080 band) and **key patterns** (MVVM/binding conventions the capture UI follows). +- [`environment.md`](environment.md) — **Tests** (hermetic real-window rules camera tests must obey) and **Run** (launch/take procedure, the Release-only rule for measurements). diff --git a/docs/ai/environment.md b/docs/ai/environment.md new file mode 100644 index 0000000..0389968 --- /dev/null +++ b/docs/ai/environment.md @@ -0,0 +1,140 @@ +# Environment, build & tests + +Lookup material: LAN hosts, the Windows build commands, where the DB/auth/log live, and the +test suite map. Read when you are about to build, run, or add a test — not on every session. + +> Paths and secrets belong with the runbook in [`../../HANDOFF.md`](../../HANDOFF.md) → +> "Secrets / paths" until the queue trim moves them here. +## LAN infrastructure (192.168.50.x) + +All LAN credentials live in `CREDENTIALS.md` (gitignored). Server IPs and roles: + +| Host | IP | Role | +|------|----|------| +| corsair | .132 | Gaming/streaming primary — RTX 5070 Ti (this machine) | +| llamavault | .87 | ODROID-HC4, 12TB RAID5, media storage (NFS). SSH: `ssh mshallop@192.168.50.87`. **DHCP-bound — it moved off `.86` after the 2026-10 power outage, so confirm with a ping before trusting any path.** SMB is reached by the **`llamavault` name**, pinned in the Windows hosts file, never by literal IP: `\\llamavault\llamaVault\...` | +| pivault | .158 | RPi5 8GB, 10.83TB RAID5 NAS | +| jarvis | .210 | AMD Ryzen 5 5600X, RX 6600 XT — AI inference, SearXNG, Docker registry, Immich | +| ultron | .108 | AMD Ryzen 7 7840HS — AI orchestrator, llama-server, Qdrant | +| gordito | .144 | Home Assistant, Alarmo, Zigbee | +| officerfriendly | .26 | Pi-hole DNS | +| octopi | .197 | OctoPrint (Prusa MK4) | +| BigBlinkyRouter | .1 | ASUS ROG GT-BE98 Pro, WiFi 7 | + +Web infra (DO droplet): `llamacasty.com` + `llamachile.tube` at `143.244.176.131`. + +**Pushing to the Gitea instance (`llgit.llamachile.tube`).** The repo is **`gramps/LlamaCasty`** +(renamed from `gramps/ytLlive`; the old path 301-redirects). **Push over HTTPS**, not SSH: +`origin = https://llgit.llamachile.tube/gramps/LlamaCasty.git`, with the credential helper +`/home/gramps/bin/gitea-credential` (registered as +`credential.https://llgit.llamachile.tube.helper`) reading the account password from the +gitignored `CREDENTIALS.md` on demand — nothing stored in plaintext, nothing in a command line. +**The Gitea account password IS the portal password** in `CREDENTIALS.md`, and the same password +authenticates the REST API (`curl --config …/api/v1/user`) — **that API is the fastest way to ask +the server questions** (the repo's real name and clone URL, the account's SSH keys, a token). +⛔ **The SSH endpoint on `llgit:2214` is not a usable push path**: the account's SSH keys ARE +registered (key id 6 "Corsair VSC" = this machine's `~/.ssh/id_ed25519`) yet the server refuses +the publickey, and the port later refused connections outright. Do not spend a session on key +archaeology — ask the API, then push over HTTPS. + +## Run + +**First time on a fresh clone (or if `ffmpeg/` was deleted) — this is REQUIRED, not optional:** + +```bash +./scripts/fetch-ffmpeg.sh # ~65 MB once; verifies SHA-256, extracts 9 files into ./ffmpeg +``` + +FFmpeg is **bundled, never downloaded at runtime** (Windows Store policy 10.2.2 — the old runtime +download 404'd on 2026-09-01 and would fail certification). The binaries are gitignored because +135 MB of DLLs in git would tax every commit on this 9p tree; the pinned URL + SHA-256 in the script +are the audit trail. `ytLive.csproj`'s `GuardBundledFfmpeg` target **fails the build** with that +exact instruction if the folder is missing. `FFMPEG_ZIP=` reuses a pre-downloaded archive. + +```bash +# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project: +"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" +"/mnt/c/Program Files/dotnet/dotnet.exe" run -c Release # the launch command (creator, 2026-10-16): Release, not Debug — perf takes must read Release numbers +``` + +**Run-mode convention (creator directive 2026-10-16):** the creator launches with `-c Release` +by DEFAULT. Debug runs happen ONLY when the AI explicitly names the Debug command for a +specific reason — never implicitly, and never for reading performance numbers (Debug pixel +paths are several × slower; every take in the 141ms saga read Debug numbers where Release is +the real reading). + +**Pre-commit gate (write it down — I forgot once):** ALWAYS run +`./scripts/verify.sh "" "" ...` (declared scope) before committing. It forces a +**clean** build (0 warnings), the full test suite (passes only the 2 known pre-existing `[FAIL]`s), and +the scope check in one shot. An **incremental** build can report `0 Warning(s)` while skipping +recompiles — that's how `CS8601` slipped past on 2026-08-29. Never claim 0 warnings from anything but +`verify.sh`'s clean build. + +`EnableWindowsTargeting=true` in `ytLive.csproj` lets a cold restore work from WSL, but a Linux +`dotnet run`/`build` re-downloads 100M+ of `windowsdesktop.app.*` packs into the Linux NuGet cache +(which lacks them) over the slow 9p `/mnt/c` bridge — twice, because the WPF `_wpftmp` generated +project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the +packs resolve from `C:\Program Files\dotnet\packs`, so the Windows host never re-downloads. +Never use `--no-restore` right after an interrupted restore — the stale `project.assets.json` +produces misleading `NETSDK1064` "package not found" errors. Running requires Windows anyway. + +## Tests + +xUnit in `ytLive.Tests` (net8.0-windows10.0.19041.0, matches the app TFM). Run on Windows — +WSL can't run net8.0-windows tests: + +```bash +dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll" +``` + +Good Dog Rule: ONE integration test per change (no feature branches pre-1.0 — work lands on +`main`, see AGENTS.md). Current: TokenStore DPAPI +roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount + +frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip +interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip, +LayoutStore background roundtrip, LayoutStore HasBackground roundtrip + v5→v6 non-Live backfill, +ScreenCaptureManager refcount + shared-bitmap + coalescing +(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackgroundTests, SceneCatalogTests, +the TASK 25 dirty-layout heal (BackgroundHealIntegrationTests), WebcamSafeguardTests, +SceneCompositorTests (full-scene composite + vertical tier), StretchMathTests, FfmpegLocatorTests, +FfmpegEncoderTests, FramePumpTests, the TASK 8/11/12 audio chain (AudioPipelineTests + MasterLimiter), +the Socials fediverse-heal roundtrip, AboutHubTests, NotificationAreaIntegrationTests (TASK 24), +GlobalHotkeyTests + HotkeyConfigTests (TASK 20), WebcamMenuGateTests (TASK 26), ChatLayerGateTests +(TASK 27), BroadcastPullOutTests (TASK 29), DefaultRecordFolder fallback (TASK 30), WebView2ManagerTests +(TASK 17), RecordingFileTests + OnAirSignTests (TASK 18), SessionTeardownTests + +WebcamOutputKeyTests + GameMeterHonestyTests (2026-09-01 recording-verification fixes) — **ZERO +known failures as of 2026-09-01. `AudioPipelineTests` 25/25 green in 26ms — +the "known failing" `Mix_HonorsProviderGains…` and the class's notorious STANDALONE HANG shared +one root cause: TASK 22's `_delayedMix` (nullable, never initialized) was dereferenced +(`delayed.Length` on null) every live-mix tick — a swallowed NRE starved the pipe the tests +read and flooded startup.log. Test was right, code drifted from the map's own contract +(loopbackGain = GameAudioVolume — now honored; the "unity because loopback scales with the +endpoint" assumption was disproven by the creator's 20%-volume meter observation). The former +RoundClip "known failure" (stale-test layers: namescoped `FindName` + `VisualTreeHelper.HitTest` +where `UIElement.InputHitTest` models input — see MyMistakes) was fixed the same day. +Per-class runs through the Windows dotnet.exe host execute the RealApp/MainWindow suites fine; +only the FULL-suite run still hangs (WASAPI teardown, pre-existing) — and the whole-suite +"247 total" era count is stale; trust per-class results.** + +Reward-event capture (monetization awareness, see the Monetization section) will add its integration +tests here when it ships: one real chat-poll payload containing all seven reward event types → +assert the persisted canonical `RewardEvent` rows (type + amount/currency/tier/memberLevel/participant +ids) round-trip into SQLite. + +### Real-MainWindow tests MUST be hermetic (DB pollution bug) + +The integration test boots a real `MainWindow` → `MainViewModel` → real `LayoutStore` +(`%APPDATA%\ytLlive\ytLlive.db`). `Shutdown()` on close **saves the layout** (full rewrite: +DELETE all scenes/sources, re-insert), so any source a test adds would be persisted over the +user's real ones — this happened and wiped the real webcam source (DeviceId replaced by the +test's fake `test-camera`). Rule: a test that constructs `MainWindow` MUST first set +`MainViewModel.LayoutPathOverride` to a temp DB path and reset it (plus +`SqliteConnection.ClearAllPools()` + delete) in `finally`. The seam is +`internal static string? LayoutPathOverride` (line ~529 in `MainViewModel.cs`), +`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`. + +The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so +save/load round trips are exact: an element removed in the UI (`RemoveElement` → +`scene.Elements.Remove` → `OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on +close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this). + diff --git a/docs/ai/frame-pipeline.md b/docs/ai/frame-pipeline.md index f81c157..667573d 100644 --- a/docs/ai/frame-pipeline.md +++ b/docs/ai/frame-pipeline.md @@ -122,7 +122,7 @@ allowed until a real reading names the culprit. **Read takes in Release.** The creator launches `dotnet run -c Release`; Debug pixel paths are several× slower, so a Debug number is not a reading of this pipeline (see -[`ai.md` → Run](../../ai.md#run)). +[`environment.md` → Run](environment.md#run)). **Derivative check (AGENTS.md):** this is not invented — OBS's own answer to "which source is slow" is the **Source Profiler**, which reports the *average execution time of each source's tick and render @@ -793,11 +793,11 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t --- -## Related in [`ai.md`](../../ai.md) +## Related files -This file owns the compositor and the pump. The neighbours stay in `ai.md`: +This file owns the compositor and the pump. The neighbours live elsewhere: -- **Live encoder + RTMP push** — `IFfmpegEncoder`/`FfmpegEncoder`, `FfmpegArgs`, locator and bundle. -- **Media source** — `MediaVideoSource` (ffmpeg `rawvideo` decode) feeding the pump's resolver. -- **Social bar** and **Native events & alerts** — the overlay strips the compositor blits last. -- **Run** and **Tests** — how to launch in Release and how the suite is run. +- [`output.md`](output.md) — **live encoder + RTMP push**, `IFfmpegEncoder`/`FfmpegEncoder`, `FfmpegArgs`, locator and bundle. +- [`TASKS/task-21-media-source.md`](../../TASKS/task-21-media-source.md) — **media source**, `MediaVideoSource` (ffmpeg `rawvideo` decode) feeding the pump's resolver. +- [`overlays.md`](overlays.md) — **social bar** and **native events & alerts**, the overlay strips the compositor blits last. +- [`environment.md`](environment.md) — **Run** and **Tests**, how to launch in Release and how the suite is run. diff --git a/docs/ai/index.md b/docs/ai/index.md index 5a69633..d02f6e8 100644 --- a/docs/ai/index.md +++ b/docs/ai/index.md @@ -12,9 +12,12 @@ Read the file whose row matches the task. There is no required reading order. | [`frame-pipeline.md`](frame-pipeline.md) | Render budget & measurement (`FrameCadence`, per-element composite timing, scene dump), `SceneCompositor` design + `StripPool`, and `FramePump` from scene snapshot to encoder (pacing, pool/paste caches, backpressure, teardown, health stats) | Touching the compositor, the render loop, per-frame cost, `Services/Encoder/*`, or debugging dropped/juddering frames | | [`capture.md`](capture.md) | The locked Background layer (screen/game/window capture, WinRT GraphicsCapture interop), the webcam seam + `CameraManager` sessions, MediaPipe background effects, and the vertical 9:16 tier's per-scene camera placement | Touching any frame source, the Background row, camera acquisition/conflicts, background effects, or vertical-tier camera geometry | | [`output.md`](output.md) | The bundled `ffmpeg.exe` and the encoder subprocess (args, probe order, seams), local recording (REC/ON-AIR, filenames, private-broadcast close-out), and the WASAPI capture → filter → mix → named-pipe audio chain (TRAX, sync offset, ducking, limiter) | Touching ffmpeg args/bundling, encoder or recording behaviour, REC/ON-AIR, the audio mix and its meters, or loudness/sync problems | +| [`environment.md`](environment.md) | LAN hosts/IPs/SSH and the Gitea push recipe, the Windows `dotnet.exe` build + run commands, the test suite map, and the hermetic real-`MainWindow` test rule | Before building, running, adding a test, or reaching any box on the LAN | +| [`product.md`](product.md) | Brand/naming split, AI transparency, the GPL licensing prohibitions, the free-forever monetization posture, auth/account assumptions, and packaging/distribution | Branding, marketing, pricing, licensing, or packaging decisions | +| [`youtube-api.md`](youtube-api.md) | Go-live ordering + the broadcast-binding fix, the private-only lock, health parsing, end-stream pre-flight, Test mode, and chat-insert shape | Touching go-live, broadcast lifecycle, health polling, streaming close-out, or the chat service | +| [`overlays.md`](overlays.md) | The social bar, the native alert box (six animations, alert clip, ticker) and the chat box conventions — everything composited over the frame | Adding or changing anything drawn on top of the output | +| [`ui-surface.md`](ui-surface.md) | The control surface, scene strip, preview rail, hotkeys, broadcast drawer, toasts, transitions, brand logo, launch geometry, and the theme/properties-panel conventions | Changing anything the creator sees: layout, controls, theming, properties panel | ## Split-out yet to come -The remaining `ai.md` blocks are being moved in staged units (capture, YouTube/alerts/auth, product, -UI patterns/theming, audio/recording/encoder). Add the row when the file lands; do not create empty -placeholders. +Nothing — `ai.md` is now a router with the always-read invariants; the subsystem detail lives here. diff --git a/docs/ai/output.md b/docs/ai/output.md index 32e1fbb..5438df3 100644 --- a/docs/ai/output.md +++ b/docs/ai/output.md @@ -319,8 +319,10 @@ devices, no timers). pipe and mute silences the loopback). --- -## Related in [`ai.md`](../../ai.md) and [`frame-pipeline.md`](frame-pipeline.md) +## Related files - [`frame-pipeline.md`](frame-pipeline.md) — the `FramePump` that feeds this encoder (pacing, backpressure, teardown order). -- **ai.md** — resolution tiers (W×H/FPS/bitrate), go-live/prep ordering, toast notifications, licensing guardrails. +- [`ui-surface.md`](ui-surface.md) — resolution tiers (W×H/FPS/bitrate) and toast notifications. +- [`youtube-api.md`](youtube-api.md) — go-live/prep ordering and the broadcast lifecycle. +- [`product.md`](product.md) — licensing guardrails (the ffmpeg pin, LGPL obligations). - [`TASKS/research-store-certification.md`](../../TASKS/research-store-certification.md) — §4, the ffmpeg bundling analysis. diff --git a/docs/ai/overlays.md b/docs/ai/overlays.md new file mode 100644 index 0000000..9d5fca0 --- /dev/null +++ b/docs/ai/overlays.md @@ -0,0 +1,154 @@ +# Output overlays: social bar, alerts, chat + +Everything composited *over* the frame: the social bar, the native alert box (six event +animations, alert video, ticker), and the chat box conventions. Read before touching any of +them or anything that composites a global layer. + +**Invariant:** every global overlay must be composited on **every** render path — pump, +preview/thumbnails, scene dump (`SceneCompositor.Overlay` is shared by all three). A feature +wired into one path is silently absent from the output. That bug ("preview-only forever", +2026-09-26) is why the rule exists. + +## Social bar (TASK 6 — shipped 2026-08-12, plan in TASKS.md; bar bug-fix branch 2026-08-13) + +A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the +output and carries the creator's social links — content-sized, centered, GREEN glow when ON, position is a +**click-toggle top ⇄ bottom** (default BOTTOM, persisted `SocialBarPosition`). `Models/Socials.cs`: `SocialService` enum +(YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/ +Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/ +`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` + `BarEnabled`; `BarJustify` dropped, +column back-compat). Configured in the "Social Media Site Promotion" dialog (`SocialsDialog.xaml` + +`ViewModels/SocialsDialogViewModel`, WPF-free, injected `ISocialValidator` + sign-in/sign-out fakes): +ON/OFF bar switch (schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; +signed-out → sign-in gate → OAuth; delete → confirm sign-out), row 2 free, rows 3–6 lock icons on +freemium. Service detection (`SocialServiceIcons.DetectService`): URL domain / fediverse `@user@domain` → +**Fediverse** / bare→Website. Validation (`Services/SocialValidator.cs`, `ISocialValidator` seam + +`HttpSocialValidator` default): async GET of the canonical profile URL; 200/redirect = valid, 404/failure = +rejected. Fediverse additionally does a **best-effort nodeinfo lookup** (`/.well-known/nodeinfo` → +`software.name`, stored in `SocialEntry.FediverseSoftware` / the `SocialEntry.Software` column, +column-presence migration, no version bump) so the entry shows the **instance's real logo** +(`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse +honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodeinfo failure still validates +(glyph falls back). If the identity domain's nodeinfo is blocked (YunoHost SSO gates +`/.well-known/nodeinfo` behind the login page) but the bare root 302s to the real instance, the lookup +**follows the root redirect** and asks the resolved host. Cancel is a hard stop: `LookupAsync` takes a +`CancellationToken` (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations +never touch slot state). + +**Bar bug-fix branch (2026-08-13) — three changes:** + +1. **Positioning is a click-toggle (KISS)** — the original drag set a local `Canvas.SetTop` value that + permanently overrides the `{Binding SocialBarTop}` (a binding can never win over a local value), so the + bar stayed wherever it was dropped. The first drag-snap fix (`SocialBarSnap.Decide` + `ClearValue` on + release) still failed for shaky hands — jitter around the ±6px deadzone snapped the bar up but wouldn't + let it come back down. **Superseded by the user's click-toggle:** clicking the bar flips it top ⇄ bottom + (`MainViewModel.ToggleSocialBarPosition`), the bar rides the binding alone, and `SocialBarSnap` is gone. +2. **Fediverse software self-heal** — the DB row `@gramps@llamachile.tube` had `Software = NULL` because + nodeinfo was only ever asked of the identity domain (a landing page; the real instance is + `mastodon.llamachile.tube`). `HttpSocialValidator.ResolveFediverseSoftwareAsync` 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. `MainViewModel` runs the + static `HealFediverseSoftwareAsync` off the UI thread on layout load, applies matches via the dispatcher, + and saves; `SocialEntry.FediverseSoftware` is settable and raises `LogoData`, so the icon updates in place. +3. **The bar renders on the live output** — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a + transparent straight-alpha BGRA strip (1920-wide, 40px content + 24px glow pad, the green glow baked in, + Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame + pipeline"). + +## Native events & alerts (TASK 43, shipped 2026-09-24 — StreamElements replaced) + +The creator asked to replace the external StreamElements feed with native YouTube events; +the answer is **native events + a native alert box**, and the product decisions are +published decisions (recorded in `TASKS/task-43-native-alerts.md`): + +- **No third party.** The chat feed IS the event feed: `liveChat/messages` carries all six + monetization events (superChat / superSticker / newSponsor / membershipGifting / + giftMembershipReceived / memberMilestoneChat). `streamList` is the *connection semantics* + of that same endpoint (pollingIntervalMillis-driven), not a separate call — the service + re-arms its one-shot poll on the server's cadence (clamp 1000–6000ms), `maxResults=2000`. + Cite: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList +- **`SourceType.AlertBox` — one "Stream Alerts" celebration zone per layout**, OBS + alert-box style: a designated draggable canvas area, idle = transparent. Mirrors the chat + gate (`CanAddAlerts`; `AddSource` refuses a second). Defaults 620×430 → 680×200. +- **Six events → six unique animations** (`Services/AlertRenderer.cs`): SuperChat slide-up + + gold count-up + shine sweep (green), SuperSticker scale-pop on a tilted chip (purple), + NewMember drop-in + flash band (violet), MemberGift slide-left + gift-chip `×N` fan + (amber), GiftReceived confetti scale-in (green→purple), MemberMilestone rise + tenure + growth bar (orange). Phases enter/hold/exit (~4–5s); every card draws the **brand line + "made with LlamaCasty!"** — each broadcasted event is free product placement. +- **`Services/AlertOverlayLayer.cs`** — the component: enqueues events one-at-a-time, + advances on a 33ms dispatch ticker, serves the live frame through `RenderFrame` (same + cache-first pattern as chat) and the preview through `UpdatePreview`; **`Advance(double)` + is the deterministic test clock** (tests drive playback with no timer). Queue cap 10 + (floods drop the tail, never stall). `ChatEventKind.None` rows (plain chat, sub mentions) + **never enqueue** — per the creator ruling, a subscriber mention is sufficient; there is + no free-sub alert (YouTube emits none) and no viewer count (vetoed — demotivating, + weaponizable). +- **Alert box VIDEO (TASK 47, 2026-09-26):** since TASK 47 the box plays a clip on every + alert instead of (only) an animation — `Enqueue`→`BeginClip` spawns a **per-play** + `IAlertClipDecoder` (ffmpeg `rawvideo bgra -vf scale=W:H` frame pipe + `f32le` audio + pipe, both real-time paced via `RawVideoFrameReader`; **not** the refcounted + `MediaVideoSourceManager` — alerts are one-shot plays that must be disposed at drain). + Custom clip wins per-source over the built-in ONLY when `AlertUseDefaultVideo==false` + AND the file exists; the shipped `Assets/alert-default.mp4` is stamped into the `Asset` + table at startup (`AlertDefaultVideoKey` setting; the prune keeps it via UNION) and + read-at-play; any decode failure falls back to the six `AlertRenderer` animations + (deliberate, not an error). Fades in/out via **`FadeDurationSeconds = 0.30`** + (`AlertOverlayLayer` const): fade-in by `elapsed` on straight-alpha frame copies; + EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears + `_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio + forwards to the mixer sink scaled by `volume × fade`. +- **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes + "Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` — + a strip rasterized **on the UI thread once per text** (cached, unpremultiplied + straight-alpha pill) then positioned by pure byte-math per call, so the pump calls it + from its own thread with no locking. **It is a dynamic overlay, NEVER baked** (the + social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` + + `CompositeLayers` take a trailing `tickerFrame` threaded from + `FramePump._alertTicker` (`Func` ctor seam) through + `RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll + changes the cache key. Toggled by `AlertShowTicker`. + - **It draws INSIDE the Stream Alerts box (creator ruling 2026-09-26).** Was a global + 1920px bar pinned to the top edge: *"the ticker should appear over the stream alerts + video, not over the entire preview window"*. The strip is now rendered at the + **alert box's own size** (`AlertTickerRenderer.Render(..., width, height)`, defaults + still 1920×48) and the box's origin rides on **`VideoFrame.Placement`** + (`(int X, int Y)?`, with `OriginX`/`OriginY` = 0 when unset). Every blit site reads + `tickerFrame.OriginX/OriginY` instead of a literal `0, 0` — `SceneCompositor` ×2 and + `FramePump`'s static-bake `Overlay` path. The preview binds the same numbers + (`AlertTickerLeft/Top/Width/Height` → `AlertTickerElement`'s `Canvas.Left/Top` + + `Width`/`Height`), so preview and output cannot drift — the same guarantee as the + branding credit. Product default box is **680×200 at (620, 430)** + (`MainViewModel.Sources.cs`); height is clamped to the natural 48px strip. + **No alert box in the scene ⇒ no ticker at all** — there is no global position left. + - **Why the position travels on the frame, not in a full-canvas frame:** a 1920×1080 + overlay is 8.3MB of large-object-heap garbage per tick — ~2.5GB churned over one 10s + alert at 30fps. A box-sized strip is ~370KB. The branding flash *does* render + full-canvas, but it **recycles** one 8MB buffer and bumps `Epoch`, so it never + allocates per frame; the ticker allocates per call, so it must stay small. + - `CopyStrip` clips **rows** to the target height: the pill rasterises at its natural + 48px, so a box shorter than 48px would otherwise write past the end of the buffer. + - **Three display methods** (`Source.AlertDisplayMethod`, DB column, panel "Display" + selector): `TickerScroll` (marquee), `Flash` (0.5s on / 0.5s off), `Solid` (centred, + still). The marquee is paced in **reads per alert** — `TickerReadsPerAlert = 3` + passes inside the alert's own length, speed derived from that — **never px/s**; the + old fixed `140px/s` took ~17s per pass, so a 10s alert showed the message once. + Same convention as the incumbent (Streamlabs: "Alert Duration" + "Text Delay", not a + scroll-speed slider). Flash's off half returns null. + - **A ticker still needs its own preview element, not a `Source`.** It is a frame-pump + producer, so it never rides a per-element `Image`; `AlertOverlayLayer` takes a + `tickerPreviewSink` and publishes from `RefreshAlertPreviews()` (UI thread only — + the pump thread must never touch a `WriteableBitmap`); the VM writes it into one + reused `WriteableBitmap` (`AlertTickerImageSource`/`AlertTickerVisible`) bound to + `AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`. The rect + is re-raised on **every** published frame, not just on a bitmap resize, so a box + moved or resized mid-alert tracks live. + - **Two measurement traps here:** a seamless marquee never goes blank (a wrapped copy + enters as the pill clears), so count a pass by the pill's leading edge resetting, not + by an empty frame; and the run is phase-started half a frame in, or the first + published frame is blank (the pill sits exactly off the right edge). +- **Alert audio under the master limiter:** `AudioMixer.EnqueueAlertAudio` writes a + dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` before the + limiter and added at **unity — never ducked** (creator ruling: "don't lower my game + audio for a tip"); `StartLive` clears it. Per-alert loudness = the Volume slider. + diff --git a/docs/ai/product.md b/docs/ai/product.md new file mode 100644 index 0000000..d42ee77 --- /dev/null +++ b/docs/ai/product.md @@ -0,0 +1,414 @@ +# Product: brand, licensing, monetization & distribution + +What the product is called, what it promises, what it may never do legally, and how it ships. +Read before branding, marketing, pricing, licensing or packaging work. + +## Brand + +Brand palette and official assets live in `MARCOM.md` (gitignored — read it for hex values, +asset file names, and marketing copy). The brand red `#e94560` is the primary accent used +throughout the UI. + +### Product name vs repo/assembly branding (2026-09-14) + +The product is **llamacasty** (llamacasty.com, llamachile.tube, `llgit.llamachile.tube`). The +repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths (`%APPDATA%\ytLlive\...`), +and most internal namings are the legacy **ytLive/ytLlive** — a re-brand that never renamed +the internals. Treat the two as separate: external/user-facing language says "llamacasty", +code/assembly/repo names stay `ytLive`. If a full internal re-brand is ever done, this note +and the UI locator (Premium/wordmark) are the checklists; it is NOT a goal pre-1.0. + +**Say "LlamaCasty" when reporting to the creator** — never "ytLive" in prose. `ytLive` is +the internal repo/assembly/namespace name only. Exact current split: + +| surface | name | note | +|---|---|---| +| product / site / marketing | **LlamaCasty** | llamacasty.com, llamachile.tube | +| repo dir, csproj, `RootNamespace` | `ytLive` | 230 files: `namespace ytLive.*` | +| `AssemblyName` → shipped exe | `ytLive` | `ytLive.exe` | +| app data dir | `%APPDATA%\ytLlive` | **capital L**, drifts from `ytLive` | +| `Distribution.md` tells customers | `LlamaCasty.exe` | ⚠ **mismatch vs the real `ytLive.exe`** | + +**Consequences, so nobody re-hunts them:** +- Renaming `` to `LlamaCasty` is the cheap win (the customer-visible exe + name matches what `Distribution.md` already promises; namespaces can stay `ytLive.*`). + It is **4 lines**, but it hard-breaks three pack URIs that spell the assembly name: + `MainWindow.xaml:14` (the **window icon** — taskbar/Alt-Tab), `OverlayHost.xaml:96`, + `RenameRecordingDialog.xaml:5`. Miss those and you ship a broken icon. Queued in TASKS.md + under the shipping task, not done. +- Renaming the `%APPDATA%\ytLlive` folder is **breaking** — it would orphan every existing + user's layout DB, `Assets` and settings. Never do it casually; needs a migration. +- No code compares its own assembly name (no `GetExecutingAssembly().GetName().Name` checks), + so the assembly name is otherwise free to change. + +### AI transparency + +This project is built through AI-assisted pair-programming. All code is generated +under the supervision of an experienced developer with decades of coding experience. +Architecture decisions, product direction, and quality gates are human-owned; +AI accelerates implementation. This is a testimonial to successful human-AI +collaboration, not autonomous code generation. + +## Licensing — do not violate (GPL-3.0 open source since 2026-10-09; see `LICENSE`, `ADDITIONAL-TERMS.md`, `THIRD-PARTY-NOTICES.txt`) + +**LlamaCasty is free software under GPL-3.0-or-later** (creator rulings 2026-10-09 — free forever, public +repo, "GPL-3 with attribution"). Copyright (C) 2026 Gramps. `ADDITIONAL-TERMS.md` carries the three +§7 terms: **7(b)** every copy keeps "Based on LlamaCasty by Gramps — https://llamacasty.com" in its +About screen; **7(c)** modified versions are marked and renamed; **7(e)** no trademark rights. The +**icon + logo files** (`Assets/llamacasty-icon.png/.ico`, `Assets/llamacasty-logo.jpg`) are **all +rights reserved, outside the GPL** — verbatim copies may carry them, forks must replace them (this is +what keeps the merch brand protected). The scene backgrounds + default alert video are AI-generated +**placeholders** (creator: made "with the intent of individual creators to immediately replace with +their own images") — they go out under the GPL with the code, no reservation. `LICENSE` + `ADDITIONAL-TERMS.md` ship beside the exe +(csproj `None` items); the About panel shows `THIRD-PARTY-NOTICES.txt`, whose header carries the +LlamaCasty license notice (GPL §5(d) "Appropriate Legal Notices"). **Source offer (§6):** +`https://llgit.llamachile.tube/gramps/LlamaCasty` (the Gitea repo; creator toggles it public — the +old `gramps/ytLlive` path 301-redirects there), recorded in the notices header + README. + +Every bundled component must stay GPL-3.0-compatible (MIT/BSD/Apache-2.0/LGPL/public domain all are; +**GPL-2.0-only and proprietary are not**; Apache-2.0 is fine with GPL-3 but NOT with GPL-2). The +rules below predate the GPL switch; where their *reason* was "proprietary product" it is noted, but +the rule itself still stands for its other reasons. **NEVER:** + +- **Swap the FFmpeg pin casually** — the BtbN `lgpl-shared` pin is the verified build. A GPL FFmpeg + build would now be license-compatible (the old "GPL contaminates the paid product" reason is gone), + but changing builds means re-verifying encoders, and libx264 carries no H.264 patent grant (Cisco's + covers OpenH264). Keep `lgpl-shared` unless a task says otherwise. +- **Distribute the static lgpl build** — LGPLv2.1 §6 wants relinkable object files for static linking. + The **shared** (dynamic-DLL) build sidesteps that: compliance is "license text + source offer + + unmodified binaries". The pin is `lgpl-shared`; when the pin is refreshed, keep the shared variant. +- **Use BtbN's `nonfree` variant** — it adds fdk-aac (Fraunhofer code licensing). The native FFmpeg AAC + encoder is fine (no Fraunhofer code) but grants no AAC patent license — accepted low-risk posture for + RTMP→YouTube, since encoder vendors cover their implementations (Cisco OpenH264, NVIDIA NVENC, Intel + QSV, AMD AMF). +- **Link FFmpeg into the app** — it stays a separate subprocess fed frames over a pipe; that separation + keeps the app's own code out of LGPL reach. +- **Drop `THIRD-PARTY-NOTICES.txt`** from the shipped app or the About screen, or alter the FFmpeg + copyright/LGPL notices inside the downloaded binaries. Automating the download counts as distribution + — the obligations are not optional. +- **Pin to a moving target** — the `latest` BtbN release tag floats. Only immutable autobuild tags give + a reproducible source offer. Record the tag + variant beside the URL (TASKS.md) every time the pin moves. +- **Use non-CC0 icon art** — the social bar's bundled SVG logo path data comes from **Simple Icons** + (CC0 1.0, public domain — see `THIRD-PARTY-NOTICES.txt`). Replacing or adding logos must stay CC0 or + another public-domain source; a logo asset under an incompatible or attribution-heavy license + would complicate every redistribution. The app's icon + logo (`Assets/llamacasty-icon.*`, + `Assets/llamacasty-logo.jpg`) are the creator's own art — all rights reserved, outside the GPL + (`ADDITIONAL-TERMS.md`). +- **Ship an ML model without clean provenance** — the background-effects model (TASK 51) is Google's + official MediaPipe Selfie Segmenter download (Apache-2.0, model card), converted by our own script. + Model-zoo re-hosts (e.g. PINTO #109 = ML Kit's model, extracted) are off-limits. Apache-2.0 §4 + obligations (license notice + "we converted it") live in `THIRD-PARTY-NOTICES.txt`. +- **Forget the v1 license-texts gate** — `THIRD-PARTY-NOTICES.txt` links the canonical license texts; at + **v1 (GA)** the full texts of every license it names MUST ship alongside it (TASK 4 requirement 9 is the + release blocker). Queued early is wrong; the release pass owns it. +- **Break the in-app About requirement** — since TASK 11 the About overlay is the app's in-app creator + hub: the real logo, the creator links (YouTube channel, Mastodon, Buy me a coffee, and the greyed + "Unlock Premium" seam whose billing URL is tabled), and a **Licenses & legal** sub-panel that loads + `THIRD-PARTY-NOTICES.txt` from the executable directory and renders it in a scrolling panel — + **never the OS viewer, no Notepad, no browser tab**. The premium/coffee link constants live in + `MainViewModel` (`PremiumUrl`, `CoffeeUrl`, `ChannelUrl`, `MastodonUrl`); filling in the billing URL + lights up the premium button. Do not reintroduce an "open the notices in Notepad" button. + +## Design Principle + +> This software is so intuitive that even the most right-brained person can easily intuit and use it. + +Apply this to every UI decision: +- One-click go-live with working defaults +- Prefilled YouTube defaults (RTMP URL, bitrate, resolution, latency) +- Visual/drag-and-drop scene building over property panels +- Every action produces a visible outcome — no dead ends +- **"We're not them" (2026-09-01):** assume the creator's hardware is mediocre, because it is. + Every feature spends the machine's budget once — never twice for the same result (record OR + stream, not both; one reusable stream; one webcam; two mixer inputs). If a feature only sings on + a high-end chassis, it doesn't ship — the OBS escape hatch is open by design. + +## Monetization (design decision — the branding flash is the sword) + +> **⛔ Superseded 2026-10-08/09 — free forever, no branding on the stream.** The credit is now +> **OFF by default**: `MainViewModel.ShowBrandFlash` (internal static, default `false`) is the +> master switch, and `BrandFlashEnabled => ShowBrandFlash && !IsPremium` feeds the presenter at +> construction and on every licence change. The code (`BrandFlashPresenter`, the FramePump +> `brandFlash:` seam, the preview layer) is kept, not deleted — creator ruling: "don't delete it, +> just make it conditionally displayed". `BrandFlashOutputTests` opt in by setting the switch +> before constructing a `MainWindow`; `Credit_IsOffByDefault_EvenOnTheFreeTier` guards the default. +> Everything below is the historical paid-tier design. + +Full pricing and the Year 1 revenue forecast in `MONETIZATION.md` (gitignored). ⚠️ **No Polar +product, discount code, or license key exists any more** — nothing bills today; Polar is dormant. +Brand palette and assets in `MARCOM.md` (gitignored). This section covers the in-app model. + +Free forever: **all features unlocked for everyone** — no feature lock, no paid tier, and since +2026-10-08 **no watermark either** (the credit is OFF by default; see the banner above). No +creator will tolerate a watermark, and as a good-guy developer we give everyone complete access +so no one can call us crooked. Money comes from gratuity/merch, not gating (TASK 54). + +- **Free:** a periodic branding credit — "made with LlamaCasty!" in **full-frame neon** (white core, + feathered red/blue halo with the channels split in opposite directions), at a **random position + inside the frame on every presentation** (single pre-measured line, so it never wraps; travel range + is `0 … 1920−textWidth`), held **2s** (500ms fade in, 1000ms full, 500ms fade out), first credit + ~5s after go-live then **every `rand(30s)+30s`** (30–60s), on the live output **and** local + recordings. An always-on watermark can be cropped or covered; an intermittent full-frame flash can't + be cropped and is impractical to edit around on a live feed. + **The flash is also the free tier's billboard** — every free stream advertises LlamaCasty to its own + viewers; the free tier is distribution, not compromise. + Implemented as `Services/Compositor/BrandFlashPresenter.cs` (raster + envelope + placement + licence + gate), published to **both** the frame pump (`FramePump(brandFlash:)` → the compositor's per-frame + `flashFrame` slot) and the preview (`BrandFlashElement`, bound to `BrandFlashImageSource`) from the + **same** `VideoFrame`, so the preview cannot drift from the broadcast. Deliberately NOT the + `flashFrame` parameter of `SceneGraph.GetBakedBase` — `SceneRegion.Matches` compares element ids + only, so a credit folded into the cached bake would freeze there and never expire. + **Pre-GA posture — REVERSED 2026-09-26 (TASK 36 shipped).** This used to render *in the preview + only*, never on the broadcast, so test VODs stayed clean. The creator ruled that the free tier must + be honest advertising: it now composites into the recording and the stream. Test VODs from an + unlicensed instance carry the credit — use a license key or the paid build for clean captures. + **Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting. + License activation still flips exactly one bit: `IsPremium` → flash off. Nothing else changes + between free and paid, ever. + **Cadence is APP-LIFETIME, not go-live (creator ruling 2026-09-26):** the presenter is + `Start()`ed **once in the `MainViewModel` ctor**, not from `UpdateLiveVisuals()`. It used to be + gated on `IsLive`, which meant a recording made without ever going live carried no credit — the + creator's ruling: *"the made with llamacasty flash should appear in all scenes, not just live"* + and *"should also appear in recordings"*. One start now covers every scene, the preview, the + stream and the recording, because go-live and local recording are the **same `FramePump`** + (`Streaming.Operations.cs:68` and `:199` both call `StartAsync` with the same `brandFlash:` + delegate) — verified, not assumed; there is no second encoder path needing a "redirect". The + licence gate needs no live branch: `IsPremium`'s setter pushes `Enabled` from anywhere. + **Consequence of app-lifetime — the premium edge restarts the timer.** `Enabled = false` stops + the `DispatcherTimer` (cutting the advertisement mid-credit). Because `Start()` is no longer + called per go-live, nothing would ever restart it, so a key entered mid-session would leave the + credit dead until the process restarted. The setter therefore restarts it when re-enabling while + `_running` (`IsCadenceTimerEnabled` is the test seam). + **Test-arithmetic trap, hit again 2026-09-26:** `Advance(5.1)` in ONE call cannot observe a + credit — `Advance` both *opens* the presentation and *ages* it by the same delta, so a single + 5.1s step blows clean through the 2s window. Always step in 1/30s increments like the real timer + (the `Step` helpers in `BrandFlashOutputTests`). +- **Paid:** branding flash removed. `BrandFlashEnabled` is now a **derived, non-assignable** + `!IsPremium` and the presenter's own `Enabled` gate is re-checked on every frame, so the + entitlement changing mid-credit cuts the advertisement on the next tick rather than letting it + finish. That's it. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording — + everything is free. **How `IsPremium` is obtained is a billing concern and has changed twice; + what it gates has not.** +- **Pricing: ⛔ SUPERSEDED 2026-10-08 by the free-forever ruling (TASK 54).** Retained as + reasoning only: `$10/mo` subscription or `$400` lifetime, no annual, ruled 2026-09-27 and + overtaken ten days later. If a paid tier ever returns, these are the numbers to argue from. + **How `IsPremium` would be obtained is a billing concern; what it gates is not.** +- **Billing: ⏸ dormant — no paid tier exists** (free-forever ruling 2026-10-08, TASK 54). The + Store IAP plan of 2026-09-27 was never implemented (`Windows.Services.Store` appears nowhere + in the repo) and was withdrawn with the Store route on 2026-10-22. If a paid tier ever + returns, the provider is **Polar** — merchant of record for VAT/sales tax, 5% + $0.50 Starter, + **$50 already paid**; `Distribution.md` §4.2/§4.5 holds the design. ⚠️ No Polar product, org + ID, key prefix, or discount code exists (`d105dfa1-…`, `c05fb364-…`, `LCYT-`, `LLAMAFOUNDER`/ + `LLAMA50`, the customer-portal validate endpoint, and the 14-day offline-grace machine are all + gone). +- **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the + once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are + how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with + emergency patches; not a 24/7 service promise. There is **no licence chatter at all** — the app + is free, there are no keys, and no user sees licence UI. TASK 36 item 5 (expiry reminder) is + struck. + +**Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1 +ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by +design, for **every** creator, free and ungated (the only subscriber swap remains the branding-flash +removal above). It has three layers, all free — this is the product's differentiator, not a paid tier: + +- **Reward-event capture (the data foundation).** The live-chat poll (`YouTubeChatService.Poll`) + receives every reward event over `liveChat/messages` and — since TASK 43 (2026-09-24) — + **decodes all six types**, not two: `superChatEvent`, `superStickerEvent`, `newSponsorEvent`, + `membershipGiftingEvent`, `giftMembershipReceivedEvent`, `memberMilestoneChatEvent` land as + `ChatMessage.Kind` (`ChatEventKind`) with their payload fields (display string, sticker + description, gift count, gifter name, milestone months); free subscriptions emit NO event + (API fact — a sub mention is a plain text row). The remaining half of the original capture + plan stays open (TASK 3 item 20): persist each event to the canonical SQLite `RewardEvents` + table (broadcastId, type, timestamp, amountMicros, currency, tier...) + `superChatEvents.list` + (30-day lookback) backfill — that persistence is what the session report and the journey + tracker read. Alerts did not wait on it: the shipped alert path (TASK 43) renders directly + from the in-memory `ChatMessage` feed. +- **Session / broadcast report.** A session is created at Go Live; the poll + End roll that session's + reward totals (new members, Super Chat $, stickers $, gifts, milestones). This is the in-app + replacement for the emailed external "stream activity report" — the incumbents (Streamlabs et al.) + email near-useless post-stream summaries; we track the same data natively and maintain it. +- **Journey tracker (TASK 39, slice 1 shipped 2026-09-22).** A **"YPP"** tab below the + Stream Settings pull-out (Live screen right edge) shows the connected creator's position + toward each YPP tier threshold. Slice 1 = current-scope data only: `channels.list?mine=true + &part=statistics,contentDetails` (subs/views/videos + uploads-playlist id) + + `playlistItems.list` for 90-day public uploads. Every refresh appends + a `YppSnapshot` to SQLite (schema v10) — the history a later slice needs for velocity/ETA. + **The `auditDetails` part is OUT (fixed 2026-09-23):** readding it 403s the WHOLE request — + that part alone requires the `youtubepartner-channel-audit` scope no app for normal creators + should request (the standing flags sat unreadable since slice 1 shipped; the app's `youtube` + + `youtube.force-ssl` scopes were never sufficient for them — the "no re-consent" slice-1 claim + was wrong for that part). Channel standing now deep-links to the Earn page; API stats come from + `statistics,contentDetails` only. YPP/API failures log the response body's `error.reason`. + **YPP thresholds are versioned, date-aware DATA, never constants** — the Tier-2 bar doubles + for new applicants on **2027-02-01** (long-form 4,000 → 8,000 qualified hrs / 365d; Shorts + 10M → 20M / 90d), while the Tier-1 fan-funding bar (500 subs / 3,000 hrs / 3M Shorts) is + unchanged. Hardcoding the pre-2027 numbers would ship the wrong ETA. Watch-hours / Shorts / + velocity / ETA are **slice 2**: Analytics-API scopes (`yt-analytics.readonly`, additive + `yt-analytics-monetary.readonly`) behind the `IChannelStatsProvider` seam (additive + capability, requires an OAuth re-consent). Compliance items no API exposes (2FA, AdSense + linkage) are in-app self-reported checkboxes with deep links; channel standing (exposed only + via the partner-audit scope) deep-links to the Earn page. Slice 2's once-daily refresh gate + + snapshot history were baked into slice 1. + +**What was rejected:** always-on watermark (obscurable — replaced by the flash), hard stream-time +cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams +routinely run 2-4 hours), soft-limit nagging, freemium feature tiers, and donation-only (relies on +the kindness of strangers). Resolution/quality ceilings stay rejected (fixed 2026-09-01, no longer +"deferred"): the free tier never loses quality — auto step-down (**TASK 33**) is a *protect the +stream* feature, never a monetization penalty. + +## Auth gates Go Live, but not exploration + +The app is fully usable without authentication: users can build scenes, add sources, compose +previews, and audition the software with zero commitment. But **going live requires authentication** — +it's the one capability gated behind YouTube sign-in. The sign-in should never pressure the user +("sign in (optional)", not a modal wall): the two-state top bar shows **Start Stream** (offline) / +**End Stream** (live), and the Start Stream dialog hosts the account — a saved session appears as +the default with "Change Account"; with none saved, a "Sign in to YouTube" button starts OAuth and +the Start button stays disabled until signed in. + +## Account assumption (do not build an account setup flow) + +Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator +account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no +YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently +replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to +YouTube" button; going live is unreachable until an account is connected. + +## Windows packaging & distribution (⛔ REVERSED 2026-10-22 — direct + Steam; Store dropped) + +**The Microsoft Store route is dead.** A brand name on the Store's *publisher* line requires a +**Company** account, which requires a registered legal entity (an LLC — ~$85 New Mexico with a +~2-week SOS backlog, ~$143 Wyoming). Partner Center does **not** convert an Individual account to +a Company account later, and an Individual account publishes under the verified legal name. The +creator will not fund an entity for a nameplate, so the 2026-09-27 Store ruling was withdrawn on +2026-10-22. Store IAP was never implemented either — `Windows.Services.Store` appears nowhere in +the repo. + +**The route now:** + +1. **Direct download from `llamacasty.com`** — the app is **free forever** (TASK 54), so there is + nothing to bill in v1. +2. **Steam when $100 exists** — Steam Direct is $100 per product (recoupable after $1,000 AGR), + accepted categories include *Audio/Video Production* / *Player Tools* (OBS Studio is the + precedent), the **developer/publisher name is self-chosen** so the brand needs no entity, + 5+ screenshots at 1920×1080 + capsule art, a build Valve runs, and a **2-week live page** + before release. Valve takes 30%. +3. **Polar is dormant, not deleted-in-principle** — chosen checkout/licence provider if a paid + tier ever returns; $50 already paid. `Distribution.md` §4.2/§4.5 holds the design. +4. **The Microsoft Store: not pursued.** The MSIX package stays as a working artifact and is + submitted nowhere. History: `TASKS/research-store-certification.md`, + `TASKS/task-48-distribution-msix.md`. + +**Consequences that are architecture, not packaging detail:** + +- **⛔ The updater is now MISSING, not deleted-by-design.** Velopack and its bootstrap were + removed on 2026-10-22 (TASK 48 slice 3) because the Store was going to deliver updates — that + premise is gone. An update path must come back, and + `ytLive.Tests/StorePackagingTests.cs` (which asserts no updater returns) must be rewritten + with the chosen updater rather than left to veto one. +- **The MSIX package still builds** via `scripts/package-msix.sh` (TASK 48 slice 2) — publish + `Release -r win-x64 --self-contained` into `artifacts/msix-stage`, copy + `Packaging/Package.appxmanifest` in as `AppxManifest.xml` + `Packaging/Assets/`, then + `makeappx pack` → `artifacts/LlamaCasty.msix` (159 MB, gitignored; `MSIX_PFX` signs it for a + local install). The manifest is a **full-trust `packagedClassicApp` at `mediumIL`** with + `runFullTrust` + webcam + microphone. `Square310x310Logo`/`Wide150x150Logo` traps are in + `MyMistakes.md`. Logos come from the app's own artwork via `scripts/make-store-logos.py`. +- **⛔ Bundle ffmpeg — no runtime downloads** (was Store policy 10.2.2; still right for a direct + download, and it fixes the 2026-09-01 404). See [`docs/ai/output.md`](docs/ai/output.md) → "FFmpeg locator". +- **⛔ 64-bit Windows only (creator ruling 2026-10-09).** Both csproj files set + `x64` (the test project too — an AnyCPU test project + referencing an x64 app raises MSB3270 and breaks the 0-warning gate). Why: the bundled ffmpeg + is the win64 build, ONNX Runtime 1.31 ships no win-x86 native (only win-x64/win-arm64), and a + 32-bit address space can't hold the 1080p frame buffers. Windows 11 is 64-bit-only anyway. + Public copy says "64-bit Windows 10 or 11". + +**What does NOT change: the monetization posture.** The app is **free forever with no watermark** +(TASK 54, 2026-10-08), so `IsPremium` gates nothing in v1 and the branding flash is OFF by +default. `Monetization` above governs. + +### ⛔ Durable invariant: full trust, or recording silently breaks + +MSIX packages run at one of two trust levels. A packaged WPF app declaring +`rescap:Capability Name="runFullTrust"` runs at **mediumIL — the same permissions as a +standard desktop app, NOT AppContainer** — and may spawn child processes, which is why +bundling ffmpeg is legal. Writes to user-profile locations (AppData, Downloads, MyVideos) +**pass through unvirtualized at mediumIL**; the package install folder is read-only. + +**`ViewModels/MainViewModel.Recording.cs` writes recordings to +`%USERPROFILE%\Downloads` / `SpecialFolder.MyVideos` (`DefaultRecordFolder()`, +`ChooseRecordFolder()` via `Microsoft.Win32.OpenFolderDialog`), and the whole +temp-write-then-move lifecycle stays in one directory.** That works *only* because we are full +trust. No `broadFileSystemAccess` capability is needed, and none should be added. + +⚠️ **If anyone flips the manifest to `appContainer`, `Directory.CreateDirectory` and +`File.Move` start resolving to a per-package virtualized location and output vanishes with no +error.** A comment at `DefaultRecordFolder()` is owed **when the manifest lands** (TASK 48 +item 6) — not before; a comment describing a manifest that doesn't exist is worse than none. + +### Signing — do not buy EV + +Microsoft **removed the SmartScreen instant-bypass for EV certificates in March 2024**. An +EV-signed file now accrues reputation exactly like an OV-signed one, so EV ($400+/yr) buys +what OV (~$150–300/yr) buys. Signing is **not** instant regardless — a valid cert stops the +*malware* warning immediately, but "unrecognized publisher" clears only as SmartScreen accrues +reputation from real download volume. Cert validity is capped at **460 days** (recurring +line item) and private keys must live on an **HSM or hardware token**. **The 2026-10-22 ruling +is to ship unsigned and accept the "unrecognized publisher" warning** until download reputation +accrues — buying a certificate is deferred, not rejected. `Distribution.md` §4.3 was corrected +2026-09-27 (it previously recommended EV). + +### Distribution and licensing are independent — and the channel decision does not decide licensing + +A **Store-distributed** app *may* still verify licences with **Polar** (Store policy 10.8.1 +explicitly permits a secure third-party purchase API for non-game PC products), so "which +channel?" never implied "which billing?" — route B was a real option. The 2026-10-22 reversal +proves the point in the other direction: dropping the Store did not by itself restart Polar, +because the **2026-10-08 free-forever ruling** (TASK 54) had already removed the paid tier. +Polar is therefore *dormant*, ready if a paid tier ever returns, and nothing in v1 depends on it. +The lesson worth keeping: the licensing choice rides on a **monetization** decision, not on a +distribution decision. + +### ⛔ "Only when the user says so" is enforced by Windows, not by us + +Camera/mic consent is layered: Store policy **10.6** forbids circumventing OS checks; the +**Windows desktop-app camera/microphone toggle** (Settings → Privacy & security, since Win10 +1903) is the gate; hardware lights and tray indicators are the OS's disclosure mechanism. So +**route everything through the standard MF/DShow/WASAPI paths and we never write that +certification ourselves** — the OS does it and gets neither our credit nor our blame. + +⚠️ **Untested:** whether that toggle reliably gates a **DShow webcam and a capture card**. +Microsoft's own support page admits desktop apps "might still be able to access your camera +or microphone even when these settings are turned off." This is **not** a certification +failure (we declare `webcam` honestly and use supported APIs) but it is a real user-trust +issue — so **no privacy-forward marketing copy until it is measured.** Guard note added to +`MARCOM.md`. + +### ⛔ Durable invariant: chat is rendered, never stored + +`TASK 49`'s profanity filter is **local, on-device, opt-in, non-persistent**, with a +**user-supplied word list — never a hardcoded slur list baked into the binary** (unpleasant +artifact, false positives across dialects, extractable). It must **never match the SuperChat +amount or any reward field** — those are financial data under policy 10.5.5. + +The non-persistence half is not just hygiene: it is the load-bearing half of the **11.12 UGC +certification answer** (mirrored YouTube chat is moderated at industrial scale upstream; ~1.6B +comments removed in Q1 2026 alone, child safety the #2 reason at 124.6M). **No future feature +— chat history, moderation logs, analytics, crash payloads — may quietly break that +assumption.** If one is ever proposed, the 11.12 position must be re-argued first. + +### Minors + +The **operator is 13+ by construction**, not by promise: LlamaCasty requires a YouTube +account to stream, and YouTube's ToS sets the account floor at **13** (regional variants reach +14; 13–17 need parent/guardian permission; under-13s are on supervised/YouTube Kids accounts). +So a COPPA theory premised on collecting PI from under-13s has no purchase against the +operator. The residual is **viewers** — still possible on supervised family accounts — and it +is weak: live chat is restricted there, YouTube moderates before we see it, and we persist +nothing. COPPA is very unlikely to bind (not child-directed), but note the enforcement trend +(FTC Sept 2025 *Apitor*: operators are responsible for what third-party components collect) — +so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no +persisted chat or reward payloads." + diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md new file mode 100644 index 0000000..ea17ec1 --- /dev/null +++ b/docs/ai/ui-surface.md @@ -0,0 +1,183 @@ +# UI surface: control room, patterns & transitions + +The control-surface layout, scene strip, preview rail, hotkeys, broadcast drawer, toasts, +transitions, brand logo, launch geometry, and the theme/properties-panel conventions. +Read before changing anything the creator looks at. + +## Launch POSITION is derived, never restored (2026-10-04) + +Creator ruling: the window must open in the **upper-left of its screen with ~75px padding**, because +the restore path was putting it below the fold. `MainWindow.RestoreWindowGeometry` now ignores the +saved position entirely and calls the pure `TopLeftPlacement(workArea, w, h, padding)`, which takes +the work-area origin plus `LaunchPadding = 75` and clamps so the window is never pushed off-screen: +a window that fits stays wholly visible, one too big to fit falls back to the work-area origin. +`LaunchPadding` is in **device-independent units**, so it scales with DPI instead of being a fixed +physical pixel count. + +**Size and maximized state are still persisted and restored** — only position was dropped. The +clamp still applies to size: saved values are `Math.Clamp`ed to `[MinWidth, work.Width]`, which is why +a saved 800×500 comes back as 1366×768 (the XAML `MinWidth`/`MinHeight`). Reverting this is +reverting the one block in `MainWindow.xaml.cs`; the saved values are still written, so nothing is lost. + +**Why the old clamp was wrong (do not "fix" it back):** it tolerated `Left` as low as +`work.Left - width + 120` and `Top` up to `work.Bottom - 80` — ranges wide enough to leave the window +almost entirely off-screen while still looking like a successful clamp. A clamp that permits the +window to be ~unreachable is worse than no clamp, because it reads as a guarantee it does not keep. + +`BroadcastPullOutTests.Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote` (renamed +from `..._WindowRestores_Clamped_...`) is the integration guard — it asserts the constructor applies +the derived placement for both an on-screen and a wildly off-screen (`-99999`) saved position. +Placement math itself is covered by `WindowPlacementTests` (pure function, no HWND needed). + +## Key patterns + +- `ViewModelBase.SetProperty()` for property change notifications +- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows) +- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row; Socials is on the preview rail. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC / ON-AIR pills + signs (TASK 18, 2026-08-29):** the top-center area is now two **sliding pill toggles** (intent) next to two **status signs** (reality). REC pill (`RecordPillOn`) is the local-recording intent — works signed-out; ON-AIR pill (`OnAirPillOn`) is the streaming intent — greyed/disabled until `IsConnected`. The **REC sign** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`) is dark-gray + dim offline and **green-tinged, pulsing only while actually recording** (`IsRecording`), not merely pill-on. The **ON-AIR sign** (`OnAirBrush` = `#555` offline, `#22c55e` live) is green while actually streaming (`IsLive`); the `PRIVATE` badge (`IsLivePrivate`) still shows when the broadcast is private. Both sign labels share the `StatusSignText` style (`Themes/Controls.xaml`) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows while `IsLive || IsRecording`. The connected account's avatar/name shows in the top bar next to the primary button (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live. +- ViewModels are constructed in XAML (`` as DataContext) +- **True decomposition beats partial-shuffling (2026-08-31, Commit G):** `MainViewModel` is a ~4100-line god-object split into 17 partials — but partials are a *myth of decomposition*: every partial shares the same class, same `SetProperty` state, same collaborators via `Scenes`/`StagedScene`/`IsLive`/`Source.VideoImageSource`, so an AI fetching one partial still reconstructs the whole class. The only thing that actually improves retrieval is a boundary where a feature **owns its own state + collaborators**. `Services/ChatOverlayLayer.cs` is the model (extracted from `MainViewModel.Chat.cs` 194→44): the layer owns the message buffer, `ChatBoxRenderer`, fade/mock timers, preview renders, and the live `RenderFrame`; the VM keeps only the binding surface (`ChatMessages` delegates to `_chatLayer.Messages` so XAML + LeftPanel `CollectionChanged` hold; the two computed gate props stay on the VM because their `OnPropertyChanged` is raised from VM setters). **`Services/AlertOverlayLayer.cs` (TASK 43, 2026-09-24) is a second true component built fresh on the same contract** — it owns the event queue, the 33ms playback ticker, the per-source preview renders and the live `RenderFrame`, with `Advance(double)` exposed as the deterministic test clock; the VM again keeps only the facade (`RenderAlertBox`, `UpdateAlertBoxPreview`, `CanAddAlerts`). Two COMPONENT layers, one facade partial. **Diagnostic — glue vs. component:** before extracting, count `OnPropertyChanged`/`SetProperty` touches (glue) and bound-property reads per partial. Zero/low-glue + a cohesive state blob (renderer/timers/buffer) = extract (chat). High glue over an already-extracted service (Audio, Webcam, Background, Scenes — `AudioMixer`/`CameraManager`/`ScreenCaptureManager`/`ChatBoxRenderer` already exist) = leave as VM glue; forcing it adds coupling, doesn't remove it. Don't manufacture seams to hit a line count — the line count is a guideline for context, not a design goal. +- **SceneGraph component + baked-crust compositor (TASK 31):** `Services/SceneGraph.cs` owns the scene collection (`Scenes` — the ViewModel's `Scenes` property delegates to it) and the element mutation surface (`AddElement`/`InsertElement`/`RemoveElement`/`MoveElement`, each invalidating the bake cache) plus queries that were scattered LINQ (`GetBackground`/`GetWebcam`/`GetChatBoxes`/`GetSplitPoint`/`IsStatic`). Elements expose `ElementKind Kind` (`Static` = images/background art, `Dynamic` = webcam/live capture/chat/web). The **split point** is the index of the first dynamic element; `SceneGraph.GetBakedBase` bakes/caches all static layers below it (keyed by scene id + static element identities), and `SceneCompositor.BakeStaticBase`/`CompositeLayers`/the `Render(.., staticBase, split)` overload composite the dynamic/above-split layers per frame. `FramePump.RenderScene` uses the optimized path when a `SceneGraph` is wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (via `InvalidateBake` from the VM's element-property and background-heal paths). **Defensive deviation from the TASK 31 spec:** `ChatOverlayLayer` keeps taking `IEnumerable` instead of depending on `GetChatBoxes()` — it is deliberately decoupled from the graph (its doc comment says "without owning the scene graph"); and the static background helpers (`EnsureBackground`/`NormalizeBackgrounds`) stay on the ViewModel because `BackgroundTests.cs` unit-tests `MainViewModel.EnsureBackground` directly. Queries + invalidation moved; helpers stayed. +- Services are currently instantiated in MainViewModel's constructor — no DI container yet +- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load +- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug). +- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names. +- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox`, the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back — **but that guard sees only those three control types**, so a picker built as something else (a `Button` + a code-built `ContextMenu`) slipped through it and the creator had to ask twice (TASK 63) +- **The properties panel is a HIERARCHY (TASK 59, 2026-10-21):** a **title owns its content, and the content is indented one level (10px) under it** — the creator's ruling: *"the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur."* So the webcam's block reads `Shape` (the mirror switch + the shape choice) → `Geometry` → `Border Color` → `Border Thickness` → `Background` (Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px `#a0a0b0` like the existing ones. **The shape is a CHOICE, not a toggle button:** `IsRectShape`/`IsRoundShape` on `SceneElement` are two settable bools and the panel shows them as two `SegmentToggle` options (Rect | Circle) with **Rect selected by default** (an element that never picked a shape is a rect) — the old single button showed the *next* action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips through `ToggleClipShape()` and both props are re-raised), **not** in a WPF `RadioButton` group — a RadioButton group unchecks its siblings by setting `IsChecked` in code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs` +- **One list for the offered colours (TASK 60):** the border-colour drop-down builds its menu from + `Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` + `NameOf(hex)` — **empty** for a colour + outside the palette, "no name" being the honest answer), NOT from a list inside + the click handler — the creator asked for a colour NAME next to each hex (`#e94560 Crimson`), and a + hard-coded `string[]` cannot carry one. A palette the creator may want to extend belongs in a + testable catalogue, with the UI as a consumer. **TASK 62:** the field that shows the *current* colour + shows the name beside it too (`SceneElement.BorderColorName`), and the hex field keeps a fixed width + so the name stays next to the hex instead of drifting to the row's far edge +- **A picker IS the app's drop-down (TASK 63, 2026-10-21):** the border colour is now a + **`YtComboBox`** — the same widget the Display and Font pickers wear — not a `...` button that + built a `ContextMenu` in code. The creator's complaint (*"one of the first things we did was define + branding. We established look-and-feel standards. And yet you keep ignoring these"*) had a precise + cause: the TASK 58 sweep looked for `ComboBox`/`CheckBox`/`TextBox` **types**, and the border picker + was neither — it was a hand-rolled `Button` + code-built menu, so it was never in scope, and the + guard could not see it. **Lesson: a style sweep must ask "is this the app's widget?", not "is this + control type themed?"** — anything hand-rolled that *looks* like a picker (a button that opens a + menu) is invisible to a type-based guard. The hex field stays (a custom colour can still be typed); + the drop-down carries the swatch and the colour's NAME, the rows carry swatch + hex + name. + Guard: `ytLive.Tests/BorderColorPickerTests.cs` +- **A ComboBox's CLOSED box and its ROWS can differ — only through a template selector (TASK 63):** + WPF copies `ItemTemplate` into `SelectionBoxItemTemplate`, and that property is **read-only in + C#** ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned"), so a plain + `ItemTemplate` renders the SAME content in the closed box and in every row. The one mechanism that + tells the two apart is an `ItemTemplateSelector`: WPF calls it with the `ContentPresenter`'s + **templated parent** — the **`ComboBox`** when the closed box renders, the **`ComboBoxItem`** when a + row renders. `Helpers/ClosedBoxTemplateSelector.cs` does exactly that (`ClosedTemplate` for the + ComboBox, `RowTemplate` for the ComboBoxItem). A `ComboBoxItem.ContentTemplate` set through an + `ItemContainerStyle` does **not** work: WPF overwrites the container's `ContentTemplate` with the + ComboBox's `ItemTemplate` (null) — so use the selector. +- **The theme's drop-down popup sizes to its CONTENT, not to the ComboBox (measured 2026-10-21):** + the `YtComboBox` popup carries no width binding, so the pop-up is as wide as its widest row + (measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped, + and the popup may be wider than the closed box. Measure (`RenderTargetBitmap`/`ActualWidth` in a + throwaway test), don't guess. **And a `Popup`'s visual root (`PopupRoot`) has a `null` visual + parent (`VisualTreeHelper.GetParent(popupRoot) == null`)** even though `PopupRoot` IS a `Visual`: + its only link back to the `Popup` and the owning `ComboBox` is `LogicalTreeHelper.GetParent`. Any + ancestor walk used by a window-level click-outside guard (`LeftPanel.IsDescendantOf`) MUST fall + back to `LogicalTreeHelper.GetParent(child)` when `VisualTreeHelper.GetParent(child)` returns + `null`, or clicking a `ComboBoxItem` inside `PART_Popup` looks like an outside click and + deselects `SelectedElement` before the item click registers (fixed 2026-10-22). +- **A field in a dense panel carries a FIXED width (TASK 61):** a WPF element sizes itself to its + content, and in a `DockPanel` the **last child fills the row** — so a row of same-kind fields looks + broken unless every field has an explicit `Width` AND the row sets `LastChildFill="False"`. The + geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's + `FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard: + `ytLive.Tests/GeometryFieldWidthTests.cs` +- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p30@8 and 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height +- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug). **The log is SIZE-CAPPED (TASK 55, 2026-10-22):** at 5 MB `startup.log` rolls to `startup.1.log` (one rolled copy kept) — the policy lives in `Helpers/LogRotation.cs` as a pure function over a path, so `AppLog`'s 156-call-site surface never changes and the rule is testable on its own. Guard: `ytLive.Tests/AppLogRotationTests.cs` + +## ⚠️ Known issue: screens/layers settings audit — RETIRED INTO THE GOLD PASS (2026-08-22, dispositioned 2026-09-01) + +The AI hallucinated through multiple commits that night on background/scene property placement, repeatedly misreading the spec and introducing bugs. Specifically: moved properties to the wrong superclass level, used `new` instead of `override` breaking WPF bindings, removed working code, and added UI elements that weren't spec'd. The audit was never run since. The creator ruled (2026-09-01): it is a **going-gold requirement**, not session debt — the fine-tooth-comb pass (every screen's Background layer properties, pill toggle persistence, context menu visibility, "Add Layer"/"(+)" menu items) is a named checklist item under **TASK 36 (gold pass)** and this landmine is closed here. + +## Control Surface UX (TASK 19/23 foundation; surface redesign started 2026-10-26) + +The app is a **director's control surface**, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor. + +**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer. + +- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output. +- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand/gear and account surfaces remain at the sides. The gear's left-click menu remains; right-click opens App Settings directly. +- **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition. +- **Preview rail:** stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls. +- **Central monitor:** shows the staged scene while editing and the program scene when editing is off. Preview transitions snapshot and animate the outgoing picture in every mode; output transitions run while streaming or recording. Go Live itself remains a Cut. +- **Edit safety:** edit availability follows `EditingActive`; it is no longer a hard lock derived from "offline and not recording." Keep the explicit toggle and output/edit behavior distinct. +- **Selected-layer preview access:** a draggable layer selected from the list temporarily gets the highest preview `Panel.ZIndex` and first priority in the preview's custom hit test, so it can be grabbed even underneath a higher scene layer. Clearing/replacing selection restores the preview stack; `Scene.Elements` order is untouched, so output and saved z-order do not change. Guard: `ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs`. + +**Scene thumbnails:** `Scene.Snapshot` is a 320×180 compositor render. The strip uses per-scene `ThumbnailAssetId`, then background art, then a placeholder; existing saved `OnAirImageAssetId` can still replace the staged tile. The old hardcoded CRT-thumbnail and on-air seed paths are empty, so a fresh layout no longer imports that artwork. Minis never render live captures. `DESIGN.md` records exploratory Stitch color/type tokens only; it is not a layout spec and has not been reconciled into `Themes/Controls.xaml`. + +## Global hotkeys (TASK 20 — step 1 shipped 2026-08-24) + +- `Services/GlobalHotkeys.cs`: `HotkeyId` enum (9 actions), `IHotkeyRegistrar` seam, `Win32HotkeyRegistrar` (`RegisterHotKey`, no modifiers), `GlobalHotkeyManager`. Fixed defaults: F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop-audio mute, F9 TRAX play/pause. +- `MainWindow.OnSourceInitialized` creates the manager and forwards `Activated` → `MainViewModel.HandleHotkey`; window `Closed` detaches (unregister all). +- `MainViewModel.HandleHotkey` maps ids to `TransitionToScene`/start-end commands (honoring `CanExecute`)/mute toggles/`TraxLeftClick`. +- Tests: `RegistrarOverride` static seam mirrors `LayoutPathOverride`. **Landmine:** forcing a WPF window's handle with `WindowInteropHelper.Handle` does NOT raise `SourceInitialized` and returns Zero pre-show — use `EnsureHandle()` in tests; it both creates the HWND and fires the event. + +## Broadcast metadata pull-out + launch geometry (2026-08-24) + +**The "Text" tab** (always visible, 2026-08-24 revision): a white 30px tab with rotated YouTube-red "Text" sits on the right edge of the preview, vertically centered — **shown regardless of login/stream state** (creator decision; original IsLive gating removed same day). Click **toggles** the drawer (`ToggleDrawerCommand`); any left-click outside the form also collapses it (`Window_PreviewMouseLeftButtonDown` checks `TextPullOutHost` ancestry), as does the ✕. The drawer is a 320px dark panel over the preview's right edge (200ms CubicEase on `Width`, `BroadcastForm.IsDrawerOpen` drives it). The Update button still greys until `CurrentBroadcastId` exists. + +**Fields — the always-editable snippet/status set** (⚠️ creator premise inverted: contentDetails fields like latency/DVR/embed are editable ONLY in created/ready, NOT while live; they are deliberately absent): +- Title / Description / Tags (comma-separated string → `snippet.tags[]`) / Visibility ComboBox (private/unlisted/public) / Made-for-Kids CheckBox — all live-editable pre- and post-launch +- Scheduled Start is read-only ("set at Go Live") + +**Plumbing:** `Models/BroadcastMetadata` POCO; `Services/LiveBroadcastFormViewModel` (owns field state, persists every edit to Settings keys `Broadcast.*` immediately via `Func` seam — survives layout re-pointing; owns drawer state; `UpdateBroadcastCommand` gated by `CurrentBroadcastId != null && !IsUpdating`). `YouTubeStreamService.UpdateBroadcast` PUTs `liveBroadcasts?part=snippet,status` and **must echo scheduledStartTime — update replaces the whole snippet part**, omitting it clears the schedule. Go Live prefills the dialog from the form (`BroadcastForm.Title/Description`) and calls `CaptureGoLive(title, description, now)` on confirm. `MainViewModel.CurrentBroadcastId` exposes the private `_currentBroadcastId`; notified on create/end. + +**The old Default Stream Title/Description** (un-persisted, App Settings overlay) are DELETED — the form owns those fields end-to-end; Go Live prefill routes through it. + +**Launch geometry:** default **1920×1040**, MinWidth **1366**, MinHeight **768**, `WindowStartupLocation=Manual`. Window size/position persist on close via `RestoreBounds` (+ WindowState Normal/Maximized; Minimized treated as Normal) into Settings keys `Window.*`; restored in the MainWindow ctor BEFORE show, clamped to the minimums and the primary `SystemParameters.WorkArea` (a position saved on a since-disconnected secondary monitor falls back on-screen). Multi-monitor restore is not supported yet. + +Test seam note: `YouTubeAuthService.SetSession()` is public — tests inject a channel with future `TokenExpiry` so `EnsureToken()` passes offline; pair with an `HttpClient(new StubHandler())`. + +## Toast notifications (TASK 24 — shipped 2026-08-23, plan in TASKS.md) + +Non-blocking user-facing messages replace blocking MessageBoxes. **Seam:** `INotificationService.Show(title, +message, AppNotificationSeverity)` (`Services/INotificationService.cs`) + `Info/Success/Warning/Error` +extension methods; `MainViewModel` news up the real `NotificationService` at its field initializer +(no DI container — the VM is built from XAML). **Library:** `Notification.Wpf` 11.0.0 (Platonenkov fork, +MIT — notice #10 in `THIRD-PARTY-NOTICES.txt`). Facts that cost a hunt: +- The `NotificationArea` XAML control registers itself with the manager on `Loaded`; routing matches + the area's `Name` against the request's `AreaName` — our host is `x:Name="ToastArea"` in MainWindow's + root grid (last child = topmost z-order, `Grid.RowSpan=4`, bottom-right above the footer via + `Margin="0,0,12,96"`, outside the preview Viewbox). An unknown AreaName silently drops the toast. +- `NeverExpires()` sets `ExpirationTime = TimeSpan.MaxValue` (NOT null); `NotificationColor.ToHex()` + returns `#AARRGGBB` (alpha prefix). +- Lifetimes: Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky until dismissed (the pure mapping + lives in `NotificationService.BuildRequest` + the `Cards` tint table: slate info `#3a3b52`, green + success `#1d5c38`, amber warning `#b8860b`, dark-red error `#8f1f1f` — matching the health-banner language). +- Calls marshal to the captured Dispatcher (`_dispatcher.Invoke`) — encoder/pump events fire off-thread. + +Migrated MessageBoxes: both webcam-acquire warnings, image-read warning, sign-in unsuccessful/failed. +Promoted from log-only (each keeps its AppLog line): go-live prep failures ×3 (reusable stream, +broadcast insert, prep exception), frame-pump death while live, mic-missing at startup, premium lapse, +saved-session refresh failure (sign-out notice). Deliberately left log-only: offline license re-validation +skip (would spam every launch). Still modal by design: SocialsDialog's slot-delete YesNo confirm. + +## Transitions (2026-10-27) + +- **Fade starts from the outgoing picture.** `TransitionService.Start` with no "from" frame sets + `CaptureRequested`; the frame pump copies its last output at the top of its next tick and calls + `SupplyFromFrame`. Before that fix the blend had no starting frame and a scene swap was instant. + Guard: `ytLive.Tests/TransitionFadeTests.cs`. +- **Type and duration** are selected in App Settings and persisted with the layout. Cut, Fade, Move, + and Wipe are offered; Wipe is a CPU left-to-right reveal. Guard: `ytLive.Tests/TransitionPickerTests.cs` + and `ytLive.Tests/WipeTransitionTests.cs`. +- **A scene change animates the outgoing preview in every mode.** Fade and Move fade it out; Wipe + shrinks it away; Cut is immediate. Program output transitions when streaming or recording, while + offline scene changes affect only the preview. Guard: `ytLive.Tests/PreviewTransitionTests.cs`. +- **Still planned (see TASKS.md):** persistent layers for Move and a capability-gated GPU Cube. + +## Brand logo (2026-10-27) + +- One locked image layer per scene, `Source.IsBrandLogo` (persisted column, additive migration), named + "Brand logo", directly above the background. Healed on load by `EnsureBrandLogos`, which also copies the + App Settings image into every scene's layer. Removal is blocked like the background. +- The image lives in the asset table; the settings table keeps its asset id and file name + (`BrandLogoAssetId` / `BrandLogoFileName`). Eye visibility is the layer's ordinary per-scene `IsVisible`. +- Default placement: top-left (32,32), 240x120 master px. diff --git a/docs/ai/youtube-api.md b/docs/ai/youtube-api.md new file mode 100644 index 0000000..dc9d761 --- /dev/null +++ b/docs/ai/youtube-api.md @@ -0,0 +1,255 @@ +# YouTube Live API — design constraints (do not violate) + +Go-live ordering and binding, the deliberate private-only lock, health parsing, end-stream +pre-flight, Test mode and chat-insert rules. Read before touching go-live, broadcasts, health, +or the chat service. The native alert box built on these events lives in +[`overlays.md`](overlays.md). + + +> ## ✅ SOLVED 2026-10-04 — the broadcasts never went live because they were never BOUND. +> +> **The bug, in one line: `contentDetails.boundStreamId` on `liveBroadcasts.insert` does not bind +> the broadcast.** Binding requires an explicit `POST liveBroadcasts/bind?id=…&streamId=…&part=id`. +> +> **How it was finally proven, after a week of misdiagnosis** — the diagnostic line, once its own +> JSON-nesting bug was fixed (`FirstItem`), read: +> ``` +> lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING +> | streamStatus=active activeChat=none cdn=rtmp/variable@variable +> ``` +> Every field is real, and together they are conclusive: +> - `bound=NONE` **with** `autoStart=true`. Both live in the *same* `contentDetails` object on the +> insert, so the request was well-formed and accepted — the API took one field and silently ignored +> the other. That asymmetry is the proof; a malformed request would have failed outright. +> - `lifeCycle=created`, **not** `ready`. Google: `created` = *"incomplete settings, so it is not +> ready to transition to a live or testing status"*. An unbound broadcast is precisely incomplete. +> - `streamStatus=active` — the ingest half was never broken. YouTube had an active stream and a +> broadcast that was not attached to it. +> +> **The causal chain, complete:** no binding → `lifeCycleStatus` stuck at `created` → +> `enableAutoStart` has no bound stream to fire from → never live → close-out 403s +> `invalidTransition` because `created → complete` is not a legal edge (only `ready`/`live` are). +> +> **Why it survived a week, and the lesson worth keeping:** every observable pointed away from the +> binding. ffmpeg pushed ~99 MB cleanly, the reusable stream went `active`, close-out returned a +> *transition* error (which reads like a lifecycle-timing problem), and the insert returned a valid +> broadcast ID (which reads like success). **A reusable stream accepts bytes for a broadcast that is +> not watching it**, so a perfectly healthy RTMP session is exactly what an unbound broadcast looks +> like. Google says it outright in the migration guide: *"a liveBroadcast must be bound to exactly one +> stream before the live broadcast can actually start. The broadcast is not bound to any streams at +> the time that it's created."* That sentence was in the docs I read during the original design and +> was not applied. **Read the failure modes in the docs, not just the happy path.** +> +> **Shipped:** `YouTubeStreamService.BindBroadcastAsync`, called from `PrepareAndStartLiveAsync` +> immediately after `CreateBroadcast` and **before** the pump starts. Failure is fatal to the session +> (an unbound broadcast can never go live, so a live-looking UI over it is a lie). `boundStreamId` was +> **removed** from the insert body and the `streamId` parameter **removed from `CreateBroadcast`** — a +> parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible. +> The old test `CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert`, which asserted the +> disproven behaviour and therefore *laundered a guess into a guarantee*, is replaced by +> `CreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamId` plus +> `BindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body`. +> +> **Bind failures are not all "sign in again."** Google documents six reasons +> (`insufficientLivePermissions`, `liveStreamingNotEnabled`, `liveBroadcastBindingNotAllowed`, +> `liveStreamNotFound`, `liveBroadcastNotFound`, `internalError`, plus rate limiting) and only the +> first is an auth problem. `BindFailureMessage` maps each to its own text; `liveBroadcastBindingNotAllowed` +> matters most, because it means *our* ordering is wrong and a generic "check the sign-in" would hide +> a bug in our own code behind a plausible excuse. + +These are the hard facts behind every decision. Full list in `TASKS.md`. + +- **One-click go-live** — never call `transition(live)`. Insert the broadcast with + `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, + `selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live. + `enableMonitorStream=false` is what lets us skip the testing stage. +- **⛔ CORRECTION (2026-10-04): the push is provably healthy — stop investigating it.** The long + test run settles it: `liveBroadcasts.insert` at 14:17:04.577, ffmpeg `Output #0, flv` to + `rtmp://a.rtmp.youtube.com/live2/5f6z-…` at 14:17:05.686, and at 14:18:56.473 ffmpeg's own + muxing summary reads **`video:101229KiB audio:1419KiB`**, `muxing overhead: 0.202949%` — ~99 MB + of real video plus audio over 112s, which is exactly what 8000+128 kb/s should produce. **No + socket error, no 4xx from ingest, no reconnect.** The broadcast *still* never left `ready`, and + close-out earned 403 `invalidTransition` again. An earlier 8s run behaved identically, so + "YouTube needed longer" is disproven — 112s is longer. The break is between *ingest accepted* + and *broadcast told to start*, nowhere in the RTMP path. +- **The binding is the prime suspect, and the app was blind to it.** `contentDetails.boundStreamId` + ("the live stream bound to the broadcast") and `enableAutoStart` ("start automatically when you + start streaming video **on the bound live stream**") are both valid on insert, with no documented + restriction against a reusable stream. But YouTube's migration guide carries the danger: + **"a liveBroadcast must be bound to exactly one stream before the live broadcast can actually + start. The broadcast is not bound to any streams at the time that it's created."** And this + failure mode is *invisible from the RTMP side*, because **a reusable stream accepts bytes for an + unbound broadcast** — ingest succeeding proves nothing. ⇒ `LogBroadcastDiagnosticsAsync` rides + the **existing** stream-health poll (no second timer) and writes one greppable line: + `lifeCycleStatus`, `privacyStatus`, `recordingStatus`, `boundStreamId` **compared against the + stream id we are actually pushing** (`BoundMatches` → `yes`/`MISMATCH`/`NO-BINDING`/`unknown`), + `boundStreamLastUpdateTimeMs`, `enableAutoStart/Stop`, `liveChatId` presence, the stream's own + `status.streamStatus` + `activeLiveChatId`, and the CDN settings we declared. It is + **self-limiting** — `_broadcastSeenLive` latches on `live`/`liveStarting`, so the line shouts only + while the broadcast is stuck and goes quiet once healthy. **The next run reads this line; it is + the whole experiment.** Guarded by `BroadcastBindingTests.BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush`, + which pins `unknown` for a missing expectation so an absent stream id can never be misreported + as a match. + - **⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).** + `liveBroadcasts.list` returns a **list wrapper**, so the broadcast lives at `items[0]`. The + original code descended into `items` for the *stream* but read the *broadcast's* response root, + so every broadcast field came back `?` — and `BoundMatches` then dutifully reported a confident + **`boundMatches=NO-BINDING`** from a parse that had never happened. Three lines of fabricated + alarm. Two fixes, both load-bearing: **`FirstItem()`** descends into `items[0]` for both halves + (and returns `default` when there is genuinely no item), and the line now prints + **`boundMatches=unread`** when the broadcast could not be read, so an absent parse can never be + reported as a verdict. Guarded by + `BroadcastBindingTests.FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly`. + **Generalisation: a diagnostic must degrade to "I don't know", never to a confident wrong + answer** — otherwise one level of JSON nesting manufactures a bug that isn't there. + - **What the one run that *did* work proved:** the stream half was correct, and it shows + `streamStatus=ready` → **`streamStatus=active`** about 30s into the push. So the ingest does + register as active server-side, which is a genuine step forward — combined with the 99MB muxed + in the earlier run, the stream half of the system is now verified healthy. The broadcast half is + still unobserved pending a re-run. +- **Visibility picker (TASK 9 item 6) — DELIBERATE TEST-PHASE LOCK, not drift (2026-09-01).** + The "always Private" enforcement in `YouTubeStreamService.CreateBroadcast` stays while the creator + runs multi-month real-world testing: non-private test streams would clutter the channel with VODs + highlighting where the product breaks. `recordFromStart`/DVR stay ON — Private VODs are invisible + to subscribers and serve as post-mortem review tapes; bulk-delete pre-GA. The unlock is a + deliberate final-pass item in **TASK 36 (gold pass)**, wired with the dialog selection — never + opportunistic. The PRIVATE badge keeps showing when the stream is actually private. +- **⛔⛔ STOP. The always-Private lock is a DECISION, not a bug, not drift, not an open question. + (Creator ruling, restated 2026-10-04 after being asked about it AGAIN.)** + `privacyStatus = "private"` hardcoded in `YouTubeStreamService.CreateBroadcast` is + **exactly right as written. Do not change it, do not "unlock" it, and — this is the part I keep + getting wrong — do not raise it as a question, an observation, a suggestion, or a + "heads-up".** The creator has now answered this more than once. + - **What it becomes:** the hardcoded lock is replaced by a real, user-selectable + visibility option in the Go-Live dialog — **at v1, not before.** Not at 1.0-candidate, not + at GA prep, not opportunistically during any other unit. + - **Why it exists:** pre-v1 test streams would otherwise publish VODs of a product still + breaking, in public, to subscribers. Private keeps those tapes usable for post-mortem review + while staying invisible to the channel's audience. + - **Do not let these look like symptoms of the one they resemble.** All are working as designed: + `privacy=private` in the broadcast diag line; the PRIVATE badge; `recordFromStart`/DVR ON; + and the absence of any delete call in the codebase (there is no delete feature, by design at + this stage — "can we delete the test VODs" is a *retired* question, answered 2026-10-04: + `videos.delete` accepts `youtube.force-ssl` which we already hold, private is a visibility + setting not a deletion lock, and anyway a broadcast that never reaches `live` never becomes a + VOD at all). + - **`UpdateBroadcast` is NOT the unlock — and it is also currently DEAD CODE. (Fact corrected + 2026-10-04: an earlier version of this note said `CreateBroadcast:112` / + `UpdateBroadcastMetadataAsync`, both wrong.)** The method that can change a live broadcast's + privacy is `YouTubeStreamService.UpdateBroadcast(broadcastId, meta)`, which sends + `privacyStatus = meta.Visibility ?? "private"`. **It has no production caller** — the only + caller in the repo is `BroadcastPullOutTests.cs:120`. So `CreateBroadcast` hardcoding private + is not merely backed up by convention, it is the *only* thing enforcing it. + - **⇒ Is a Go Live video private? YES — verified 2026-10-04, and this answers a standing + creator doubt.** Three independent hardcodes (`CreateBroadcast` → `"private"`; + `GoLiveViewModel.Visibility => "Private"`; `BeginTestStream` → `StreamVisibility = "Private"`) + plus one unreachable method. There is no path by which a broadcast this app creates can become + non-private today. The creator's actual concern — "do not populate my channel with videos + showing the app at its worst" — is fully met. + - **⛔ THE LANDMINE, and the reason the above matters:** `LiveBroadcastFormViewModel` still + exposes `VisibilityOptions = {private, unlisted, public}` and `UpdateBroadcast` would happily + honour it. **The v1 visibility option is the first thing that will ever wire `UpdateBroadcast` + up — so v1 is exactly when this guarantee ends, by the creator's own choice.** When that + happens, private stops being guaranteed and becomes a default the user can change. That is + intended at v1; it is a disaster before v1. Do not wire `UpdateBroadcast` into any + production path before v1, and do not "fix" the dead-code smell by calling it. +- **⛔ The TEST button is HIDDEN at v1 — it exists only because of the lock above (creator + ruling 2026-10-04).** This was **absent from the map entirely** until that date, which is how the + dry-run framing below came to be the only recorded one. Both are true, and the second is the + load-bearing one: + - **Its real job is being the guaranteed-private path.** `BeginTestStream` hard-sets + `StreamVisibility = "Private"` (`MainViewModel.Streaming.Operations.cs:105`), so Test can never + publish. That is *why* it exists for the whole pre-v1 phase — a dry run you cannot leak. + - **At v1 it is redundant and gets hidden**, because once visibility is a real dialog option the + user picks Private themselves and a separate "safe" button no longer earns its place. Hiding it + is part of the v1 visibility work, not a separate cleanup. + - **The pre-existing "dry run" framing is now known to be incomplete** (`ShowTestButton`'s own + comment calls it "a dry-run of the livestream"). Do not read that comment as the reason the + button must persist, and do not re-derive the button's purpose from it. If a dry run is still + wanted at v1 it must be re-justified on its own merits — that question is **open, and is for + v1, not now.** + - **It is a computed predicate, not a setting** — `ShowTestButton => ShowStreamActions && + IsConnected` (`MainViewModel.Streaming.cs:182`), feeding both `CanStartTest` and the XAML + visibility. So the v1 hide is a one-line predicate change, not a settings migration. + - **Not a v1 blocker:** the Test route is the *same* pipeline as Go Live — + `BeginTestStream` → `StartStreamingSession(alsoRecord: false)` → `PrepareAndStartLiveAsync` — + so Test and Go Live create identical broadcasts (`boundStreamId`, `enableAutoStart`). Test + differs only in never recording (`alsoRecord: false`) and forcing Private. **This is why Test + is the correct way to run any diagnostic stream**, and why a Test result is evidence about Go + Live. +- **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 → + `StreamStatus.Error`, never a crash; `StopStream` clears `_currentBroadcastId`. +- **Variable reusable stream** (shipped 2026-08-16) — `GetOrCreateReusableStreamAsync` lists + `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once per channel + (`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 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)` + polls `liveStreams.list?part=status`; render nothing on `good`/`ok`/`noData`, surface a banner only + on `configurationIssues[]` with `warning`/`error` severity. The decision is the pure + `Services/StreamHealthReporter.BannerFor` (null text = no banner; error beats warning). The VM polls + every **30s while live** (`DispatcherTimer` `_healthPollTimer`, first poll right after go-live) and + clears on End via `ResetHealth`; poll failures log only. **Parsing (fix 2026-09-22):** the real API + nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — reading it as + a string crashed every poll (`requires an element of type 'String'`, seen 2026-09-22 on both test + sessions); the parse now reads the nested `.status`/`configurationIssues` and tolerates the old + flat-string shape. UI = a full-width banner strip under the + top bar, `HealthIssueBanner` text + `HealthIssueBackground` (amber `#b8860b` warning / dark red + `#8f1f1f` error), hidden by `NotNullToVis`. The design's bottom-strip YouTube logo + green/red dot + (clickable → dialog) is still queued. +- **One dialog, three states** — not connected / connected-offline (all editable) / live + (title + description + visibility editable; quality + account greyed out). Both entry points + (Start Stream button + bottom strip) open it; prefilled from saved session profile. +- **End stream (SHIPPED 2026-09-01; pre-flight hardened 2026-09-22)** — stop encoder → `YouTubeStreamService.EndBroadcastAsync` POSTs `liveBroadcasts/transition?broadcastStatus=complete&id=…&part=status` (AFTER RTMP EOF, so no frames post-date the end — VOD finalizes immediately instead of ~1min of frozen "stream offline"). **2026-09-22: every end logged 403 `invalidTransition`** — a blind complete races enableAutoStop/YouTube's auto-complete (the old doc's "already fired" guess was wrong; it's a broadcast state race). The call now pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only transitions from `live`/`testing`, skipping silently otherwise (`enableAutoStop` finishes every skip — never throws, never toasts a finished session). Inconclusive pre-check still posts (old behavior, backstopped). Record-only stops make ZERO API calls (guard: `wasLive && _currentBroadcastId != null`). +- **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz, + mono/stereo only. YouTube flags violations via health status. +- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD + (`recordFromStart` + `enableDvr` default true). +- **Test Stream mode (TASK 41, SHIPPED 2026-09-22)** — every broadcast is private-only anyway + (see the visibility lock above), so "Test" is a **session variant over the real go-live + pipeline**, not a new mode: `BeginTestStream` → `StartStreamingSession(alsoRecord:false)` + (the dialog path was extracted into this shared boot — no Go Live dialog for a Test, no + `BroadcastForm.CaptureGoLive` "last live" stamp, no recording). `IsTestStream` flips the face: + gold top bar + glow (`#9c6f1c`), a **TEST** badge, and the End button reads **"End Test"**; + `StopStream` clears it in every path (including pump-failure rollback). Once the liveChatId + resolves, `TestSession.BeginTest(broadcastId, liveChatId)` opens the **TEST drawer** (third + right-rail pull-out, three-way one-open-at-a-time exclusivity wired in `MainViewModel.Ypp.cs`; + any left-click outside the rail closes whichever drawer is open — TEST included (fixed + 2026-09-25, TASK 46; the code-behind outside-click close in `MainWindow` had skipped + TestSession, so only Stream Settings + YPP dismissed on an outside click). + **liveChatId resolution (fixed 2026-09-25, TASK 44; ⚠️ CORRECTED 2026-10-04):** the id is + `snippet.liveChatId` — `contentDetails` has NO liveChatId property — and + `GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` reads part=snippet and + polls with a bounded retry, and `PrepareAndStartLiveAsync` starts the frame pump FIRST before + resolving it. **The old claim that "YouTube only populates it once the broadcast is LIVE" is + WRONG, and it was inferred from a sample rather than from the API.** The 2026-10-04 diagnostic + caught `liveChatId=present` while `lifeCycleStatus=created` — not live, not even `ready`. The + official `GetLiveChatId.java` sample tests `broadcastStatus == active` before returning the id, and + that read as a statement about *when YouTube populates the field*; it is only a guard in the + sample. **Generalisation: a sample's defensive check is not a spec.** Also note the old note said + the missing id meant "the ready state" — the broadcasts were never `ready` at all, they were + `created` and unbound (see the SOLVED box above), so the terminology was wrong too. + The bounded retry is harmless and stays, but the id is available far earlier than assumed — worth + revisiting only if chat startup latency is ever reported as slow. + Drawer tooling: **Mock Chat Input** = a REAL `liveChat/messages.insert` (`YouTubeStreamService.InsertChatMessageAsync`) + that round-trips through the real ~2s poll and renders through the live overlay path; + **simulated Subscriber / New Member / Super Chat** = `YouTubeChatService.InjectSimulatedMessage`, + which raises the poller's `MessageReceived` seam, marked `ChatMessage.IsSimulated`. **API fact + (researched + hard constraint): `liveChat/messages.insert` only creates `textMessageEvent`s — + SuperChats / memberships / sponsorships cannot be inserted via any API**; simulated non-text + events are local-only by design and never reach YouTube. **Insert body shape (fixed 2026-09-25, + TASK 45):** the body MUST include `snippet.type` = `"textMessageEvent"` alongside `liveChatId` + and `textMessageDetails.messageText` — omitting type returns `400 MISSING_REQUIRED_FIELD` + (found live on the next Test Stream; the TASK 41 test had asserted body presence but not + type, so it stayed false-green). Scopes `youtube` + `youtube.force-ssl` + already cover insert. **Real stinger/TTS alert widgets are DONE (TASK 43, shipped + 2026-09-24) as the native alert box** — see the next section; Text source (TASK 3 item 16) + and the RewardEvent persistence (item 20) remain open.