docs: reconcile Day 2 release policy and output tasks

This commit is contained in:
2026-11-04 11:18:16 -08:00
parent 6310f50c27
commit f921c0174b
17 changed files with 120 additions and 153 deletions
+20 -14
View File
@@ -13,7 +13,8 @@ storefront, support or promo work. Cause of death for every retired path:
- **Pro is the paid Steam app; the free Demo is associated with it.** The paid app is **$79 during
the announced launch period, then $99** on the announced date. No subscription or third-party
checkout.
- **The only delta is output quality.** Features are never gated; a payment buys pixels.
- **The only delta is frame rate.** Features are never gated; every supported resolution is free at
30 fps, and 60 fps is paid. Resolution does not decide entitlement.
- **No watermark, ever**, in either edition. The branding flash is retired as a paid delta and
stays off (`ShowBrandFlash`, default `false`).
- **Clean numbers, no `.99`**, no discount codes, no countdowns. **The one dated exception** is the
@@ -25,33 +26,38 @@ storefront, support or promo work. Cause of death for every retired path:
## Output profile — the one entitlement rule
The cap is a pixel budget plus a frame rate, not the word "720p":
The product rule is frame rate, not pixel count or a resolution label:
| | Free | Pro |
|---|---|---|
| Pixels | ≤ **921,600** | any shipped tier |
| Frame rate | ≤ **30 fps** | any shipped tier |
| Landscape | 1280×720 @30 | 1280×720 @60 · 1920×1080 @30 · @60 |
| Vertical 9:16 | 720×1280 @30 | 1080×1920 @60 |
| Pixels | any supported tier | any supported tier |
| Frame rate | ≤ **30 fps** | up to **60 fps** |
| Landscape | 1280×720 @30 · 1920×1080 @30 | all Free tiers + 1280×720 @60 · 1920×1080 @60 |
| Vertical 9:16 | 1080×1920 @30 | all Free tiers + 1080×1920 @60 |
- **Records too.** The cap governs the file the creator keeps as well as the stream; otherwise
free 1080p recording is a re-upload hole. **There is one ffmpeg process with two output blocks**
- **Implementation gap (2026-11-04):** `Models/OutputProfile.cs` still gates by pixel budget plus
frame rate. Update that rule and its tests to use frame rate alone; this docs unit records the
product decision but does not change code.
- **Records too.** The frame-rate rule governs the file the creator keeps as well as the stream;
otherwise free 60-fps recording is a re-upload hole. **There is one ffmpeg process with two output blocks**
(FLV to RTMP, MP4 to disk) fed by one `EncoderOptions`, and the profile is resolved once when
those options are built (`MainViewModel.Streaming.Operations.cs` → `Models/OutputProfile.cs`).
There is no second encoder to keep in step and therefore no second check to forget.
- **Fail closed.** Unresolved, unreadable or unvalidated entitlement = free tier. Never Pro.
- **The development override exists and never ships.** `MainViewModel.DeveloperMode` bypasses the
budget in a Debug build only; the Settings row that toggles it is **excluded from the Release
entitlement gate in a Debug build only; the Settings row that toggles it is **excluded from the Release
compile** (`ytLive.csproj` removes its Page and code-behind), and the setter refuses the value
when `Helpers/DevBuild.IsDevelopment` is false. Not a hidden row — an absent one.
- **Never downgrade a running stream.** The gate applies to the next start; pulling quality out
of a live broadcast is a worse bug than one free 1080p session.
- **The greyed menu entry is the sales surface.** It stays clickable and is labelled ("720p30 ·
free tier"). Selecting a locked tier opens the in-app About panel; the browser opens only if the
- **Never downgrade a running stream.** The gate applies to the next start; pulling 60 fps out of a
live broadcast is a worse bug than one session starting at an unentitled frame rate.
- **The greyed menu entry is the sales surface.** It stays clickable; locked 60-fps tiers are marked
as paid. Selecting a locked tier opens the in-app About panel; the browser opens only if the
creator then clicks its llamacasty.com link.
- **Ownership is checked at app startup** and cached in `IsAppOwned` for that run. Purchases and
refunds are reflected on the next app start; there is no subscription lapse or sign-out path.
- **Bitrate follows the profile** — a capped user is not offered a 1080p bitrate.
- **Bitrate follows the selected profile** — free users may choose any supported 30-fps tier; paid
users may choose supported 60-fps tiers.
## Entitlement — one source
+4 -3
View File
@@ -28,9 +28,10 @@ paid app's Steam App ID. The website links the store page; it never takes paymen
nothing" proposal was rejected: Steamworks requires in-app purchases to use the Steam Wallet, so
it is a listing-level risk to save ~$24 a sale.
**Kept:** the free tier is the whole app at a 720p30 output profile, the paid delta is output
quality only, and the app never sells anything itself — the greyed tier opens the Steam store
overlay.
**Kept at the time; superseded 2026-11-04:** the free tier was described as a 720p30 profile. The
current policy is the whole app free at any supported resolution up to 30 fps; Pro unlocks 60 fps,
regardless of resolution. The app never sells anything itself — the greyed tier opens the Steam
store overlay.
## Commercial mechanics — died 2026-10-31 (same week they were written)
+1 -1
View File
@@ -15,7 +15,7 @@ Read the file whose row matches the task. There is no required reading order.
| [`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 licensing prohibitions, the freemium posture, auth/account assumptions, and packaging behaviour | Branding, naming, packaging, or a legal/third-party obligation |
| [`commercial.md`](commercial.md) | The live commercial policy: model and prices, the output-profile cap (stream **and** recording), paid-app ownership (`BIsSubscribedApp`), **Steam as the only channel**, signing (moot), costs, **support policy**, anti-piracy posture | Any question about money or support: pricing, what free gets, how it ships, what a sale costs |
| [`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 |
| [`youtube-api.md`](youtube-api.md) | Go-live ordering + broadcast binding, the development/test Private safeguard and release visibility flow, 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 |
| [`decisions.md`](decisions.md) | Retired paths with cause of death: the paid-tier history and pricing, the free-forever era, Store IAP, the Store ordering, rejected monetization and anti-piracy ideas, and the permanent out-of-product list | You are about to re-open a decision that was already settled |
| [`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 |
+26 -78
View File
@@ -1,7 +1,7 @@
# 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,
Go-live ordering and binding, development/test privacy safeguards and release visibility, 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).
@@ -107,77 +107,23 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
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):
- **Visibility policy (creator clarification 2026-11-04):** force Private during development and
test runs so unfinished captures cannot accidentally publish. This is a safeguard for those
environments, not a permanent product lock. The released creator flow offers Private, Unlisted,
or Public, defaults to Private, and supports changing visibility while live. A PRIVATE badge is
shown whenever the current broadcast is private.
- **Release visibility flow (TASK 9, Day 2):** persist a default visibility (Private initially);
ON-AIR opens a preflight with that default selected and an explicit “Start Broadcast — [scope]”
action. Apply the selected scope when creating a broadcast and through
`YouTubeStreamService.UpdateBroadcast` for live changes. Development/test runs stay forced Private.
- **Implementation status:** current code still forces Private for all broadcasts. Do not mistake
that description of today's code for the target Release behavior; the selectable flow is queued,
not implemented. `UpdateBroadcast` currently has no production caller; Day 2 wires it for live
visibility changes, while development/test runs remain Private.
- **Test mode remains Private.** `BeginTestStream` forces `StreamVisibility = "Private"`, so retain
that safeguard after normal Release go-live offers a choice. The existing v1 decision to hide the
separate TEST button remains tracked with the release flow.
- **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, and made-for-kids; release visibility is the Day 2 flow above. Scheduling ships as **TASK 34** (drawer ☑ Scheduled + adoption at Start). The old **Advanced tab is permanently out of product**: latency locked `low`, DVR/record-from-start locked on, embed/projection/captions fixed at sane defaults, invisible. `categoryId` is video metadata, not a `liveBroadcast` field; category selection, tag updates through the video API, and thumbnail upload remain follow-ups. 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 →
@@ -196,8 +142,10 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
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
every **2s until YouTube confirms `lifeCycleStatus=live`**, then every **30s** (`DispatcherTimer`
`_healthPollTimer`, first poll immediately after the frame pump starts); the top bar turns green only
on that confirmed `live` state. It 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
@@ -213,9 +161,9 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
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)`
- **Test Stream mode (TASK 41, SHIPPED 2026-09-22)** — Test remains forced Private even when
normal Release go-live offers a visibility choice, 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"**;