docs(ai): partition ai.md into five subsystem files (verbatim moves)
ai.md 1,308 → 228 lines. No prose rewritten: every block was moved as-is so nothing could be lost in translation, then replaced by a pointer. New files: - docs/ai/environment.md — LAN/SSH + Gitea push recipe, Windows dotnet build/run, test-suite map, hermetic real-MainWindow test rule - docs/ai/product.md — brand/naming, AI transparency, GPL licensing prohibitions, free-forever monetization, auth/account assumptions, packaging & distribution - docs/ai/youtube-api.md — go-live ordering, the broadcast-binding fix, the private-only lock, health parsing, end-stream pre-flight, Test mode, chat insert shape - docs/ai/overlays.md — social bar, native alert box (animations, clip, ticker), chat box conventions - docs/ai/ui-surface.md — control surface, scene strip, preview rail, hotkeys, broadcast drawer, toasts, transitions, brand logo, launch geometry, theme/properties conventions ai.md keeps the response/derivation rules, the architecture table, the four existing invariant stubs, and one pointer per moved subsystem. docs/ai/index.md routes every file with a "read when" column. References falsified by the move were fixed in the same commit: AGENTS.md build note → environment.md → Run, and the "Related" footers in frame-pipeline.md / output.md / capture.md now point at the owning files. Docs-only unit: scope-check run, no build (no source touched).
This commit is contained in:
@@ -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"
|
||||
|
||||
+3
-5
@@ -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).
|
||||
|
||||
@@ -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=<path>` 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 "<file1>" "<file2>" ...` (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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
+6
-3
@@ -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.
|
||||
|
||||
+4
-2
@@ -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.
|
||||
|
||||
@@ -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<VideoFrame?>` 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.
|
||||
|
||||
@@ -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 `<AssemblyName>` 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
|
||||
`<PlatformTarget>x64</PlatformTarget>` (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."
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
Reference in New Issue
Block a user